In part one of this tutorial, we made a simple “colors” website in which you can search based on substrings, and choose one or more as your favorites (here is a JavaScript-only demonstration). In part two, we updated the like buttons with Ajax, using JQuery. Ajax provides the benefit of reloading only the just-pressed button, instead of the whole page.
[All parts: ONE, TWO, THREE]
In this final part, we’re also going to implement Ajax into the search feature. In addition, we’ll make the search execute automatically on each key-press, and prevent rapid-fire requests, by ignoring key-presses less than one-tenth-of-a-second apart.
(A reminder to backup your work before proceeding.)
Changing the views
In the original code, the search is implemented as a logic branch in the main ColorList
view. If a GET
is detected
if(self.request.method == "GET"):
(and there’s at least two characters) then a search is executed. Otherwise, it’s just a "normal" request, without interactivity–in other words: just load the page.
In order to refresh only the search results, that portion of the main template must be separated out into it’s own file, and its corresponding view code must be moved out of the ColorList
and into its own (function-based) view.
Do this by replacing the existing single ColorList
(class-based) view in
/home/myname/django_files/django_ajax_demo/color_liker/views.py
with the following two views:
class ColorList(ListView):
"""
Displays all colors in a table with only two columns: the name
of the color, and a "like/unlike" button.
"""
model = Color
context_object_name = "colors"
def dispatch(self, request, *args, **kwargs):
return super(ColorList, self).dispatch(request, *args, **kwargs)
def get_queryset(self):
"""
This returns the all colors, for display in the main table.
The search result query set, if any, is passed as context.
"""
return super(ColorList, self).get_queryset()
def get_context_data(self, **kwargs):
#Get the current context.
context = super(ColorList, self).get_context_data(**kwargs)
context["MIN_SEARCH_CHARS"] = MIN_SEARCH_CHARS
return context
def submit_color_search_from_ajax(request):
"""
Processes a search request, ignoring any where less than two
characters are provided. The search text is both trimmed and
lower-cased.
See <link to MIN_SEARCH_CHARS>
"""
colors = [] #Assume no results.
global MIN_SEARCH_CHARS
search_text = "" #Assume no search
if(request.method == "GET"):
search_text = request.GET.get("color_search_text", "").strip().lower()
if(len(search_text) < MIN_SEARCH_CHARS):
"""
Ignore the search. This is also validated by
JavaScript, and should never reach here, but remains
as prevention.
"""
search_text = ""
#Assume no results.
#Use an empty list instead of None. In the template, use
# {% if color_search_results.count > 0 %}
color_results = []
if(search_text != ""):
color_results = Color.objects.filter(name__contains=search_text)
#print('search_text="' + search_text + '", results=' + str(color_results))
context = {
"search_text": search_text,
"color_search_results": color_results,
"MIN_SEARCH_CHARS": MIN_SEARCH_CHARS,
};
return render_to_response("color_liker/color_search_results__html_snippet.txt",
context)
Update the templates
Create the search-results sub-template
[The template from parts one and two]
In order to render the search results separately, they must be moved from the main template into its own sub-template.
Save the following as
/home/myname/django_files/django_ajax_demo/color_liker/color_liker/templates/color_liker/color_search_results__html_snippet.txt
{% if search_text|length >= MIN_SEARCH_CHARS %}
<p><b>Searching for "<code>{{ search_text }}</code>":</b>
{% if color_search_results.count > 0 %}
</p>
<ul>
{% for color in color_search_results %} {# No colon after "color_search_results" #}
<li>{{ color.name }}</li>
{% endfor %}
</ul>
{% else %}
<i>No results</i></p>
{% endif %}
{% endif %}
Change the main template
The changes:
- The search-results are no longer in the main template,
- Instead, they are now represented in the main template by a div, which is populated (and un-populated) by the yet-to-be-created JavaScript
- The search form is no longer a form–it’s only a text box,
- The JavaScript imports are different, and require two additional Django variables to be assigned to JavaScript variables, before the imports.
Replace the contents of
/home/myname/django_files/django_ajax_demo/color_liker/templates/color_liker/color_list.html
with
{% comment %}
humanize:
For the "apnumber" filter, to display "two" instead of
"2". Requries 'django.contrib.humanize' in INSTALLED_APPS
static:
To access the public static file directory, without having to hard
code urls.
{% endcomment %}
{% load humanize %}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<title>Color Likenatorizer</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width"/>
<link rel="stylesheet" type="text/css" href="{% static 'stylesheet.css' %}">
</head>
<body>
<div class="search_section">
<!--
The form is submitted on every key press ("keyup") to the ajax
function. If the number of characters is greater than
MIN_SEARCH_CHARS, then it is submitted to the Django view. Django
then renders the sub-template
color_search_results__html_snippet.txt
whose output is fed back to the ajax function. Ajax then populates
the rendered sub-template into the below div.
This no longer needs to be a form since the JavaScript directly
reads both fields (that is, it attaches event listeners to them,
which automatically react to key-presses). To be clearer, I've
added "color_" to the beginning of the text field's id.
Notes:
- csrf_token-s are not required in get requests.
- Only because the view expects a GET request, the search may
be directly tested with
http://my.website/color_liker/search?color_search_text=bl
-->
<input type="text" id="color_search_text" name="search_text"/>
<p>(Requires {{ MIN_SEARCH_CHARS|apnumber }} or more characters)</p>
<div id="color_search_results"></div>
</div>
<div class="content_section">
<h1>Color Likenatorizer</h1>
{% if colors.count == 0 %}
<p><i>There are no colors in the database.</i></p>
{% else %}
<table>
<tr>
<th colspan="2">Color</th>
<th>Favorite?</th>
</tr>
<!--
The yes/no like button is contained in the third column.
The stylesheet eliminates the button's border and expands
its width and height to 100%, so it fills its entire
container: the table cell. It therefore appears to *be*
the table cell.
The table cell is in this main template, the button in
it, is in the "include"d sub-template. The button
sub-template is used by the below for-loop, and also by
the toggle_color_like view, which is called by Ajax.
Ajax calls Django, which renders the sub-template and
feeds it back to Ajax, which then replaces the current
button/sub-template with the new one.
(The data-color_id is how the id is passed to JQuery. See
http://api.jquery.com/data/ )
-->
{% for color in colors %} {# No colon after "colors" #}
<tr>
<td style="background-color: {{ color.name }};" class="td__color_color">{{ color.name }}</td>
<td class="td__color_name">{{ color.name }}</td>
<td id="toggle_color_like_cell_{{ color.id }}" class="td__toggle_color_like_button" data-color_id="{{ color.id }}">
{% include "color_liker/color_like_link__html_snippet.txt" %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
<script type='text/javascript' src="{% static 'jquery-1.11.1.min.js' %}"></script>
<script type='text/javascript' src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js"></script>
<script language="javascript">
/*
Before our JavaScript can be imported, the following JavaScript
variables need to be set from Django variables. While these
values could be hard-coded into the JavaScript, this allows
them to be centrally located.
*/
//From color_liker.views.MIN_SEARCH_CHARS
var MIN_SEARCH_CHARS = {{ MIN_SEARCH_CHARS }};
//The url to submit the search form. From color_liker.urls
var SUBMIT_URL = "{% url 'color_list' %}";
/*
the url to toggle the like. From color_liker.urls
Since an id must be provided to the Django url, give it a
bogus one, then immediately lop it off (along with the
ending '/'). It is re-appended by the JavaScript.
*/
var LIKE_URL_PRE_ID = "{% url 'toggle_color_like' color_id='999999999' %}"
LIKE_URL_PRE_ID = LIKE_URL_PRE_ID.substring(0, LIKE_URL_PRE_ID.length - "999999999/".length);
</script>
<script type='text/javascript' src="{% static 'color_ajax_search.js' %}"></script>
<script type='text/javascript' src="{% static 'color_ajax_like.js' %}"></script>
<script type='text/javascript' src="{% static 'color_ajax_main.js' %}"></script>
</body></html>
Update the URL configuration
There is a new view for the search form, so a new url must point to it. Replace the contents of
/home/myname/django_files/django_ajax_demo/color_liker/urls.py
with
from django.conf.urls import patterns, include, url
from color_liker.views import ColorList
urlpatterns = patterns('',
url(r"^$", ColorList.as_view(), name="color_list"),
url(r"^like_color_(?P<color_id>\d+)/$", "color_liker.views.toggle_color_like", name="toggle_color_like"),
url(r"^search/$", "color_liker.views.submit_color_search_from_ajax", name="color_list"),
)
Ajax: JavaScript
The JavaScript for the search feature will be placed in a new file. Before creating it, lets split the single existing JavaScript file, containing both the like button and "main" functionality, into two. While all this JavaScript code could easily be placed into a single file (without comments or empty lines, there’s only about 50 lines of code), I find it clearer to split them into three: main, like, and search.
This code now uses Underscore.js. The original code doesn’t.
File one: The like button
The "main" function is moved out of this file, and into the one below.
File name:
/home/myname/django_files/django_ajax_demo/static/color_ajax_like.js
//THIS FILE MUST BE IMPORTED BEFORE THE "main" FILE.
/**
Executes a like click. Triggered by clicks on the various yes/no links.
*/
var processLike = function() {
//In this scope, "this" is the button just clicked on.
//The "this" in processServerResponse is *not* the button just clicked
//on.
var $button_just_clicked_on = $(this);
//The value of the "data-color_id" attribute.
var color_id = $button_just_clicked_on.data('color_id');
var processServerResponse = function(sersverResponse_data, textStatus_ignored,
jqXHR_ignored) {
//alert("sf sersverResponse_data='" + sersverResponse_data + "', textStatus_ignored='" + textStatus_ignored + "', jqXHR_ignored='" + jqXHR_ignored + "', color_id='" + color_id + "'");
$('#toggle_color_like_cell_' + color_id).html(sersverResponse_data);
}
var config = {
url: LIKE_URL_PRE_ID + color_id + '/',
dataType: 'html',
success: processServerResponse
//Should also have a "fail" call as well.
};
$.ajax(config);
};
File two: The "main" function
This includes the original main function, with the addition of one that attaches a key-press listener to the search text box. The new function is called by both the main and the below search-JavaScript file.
Save the following as:
/home/myname/django_files/django_ajax_demo/static/color_ajax_main.js
//THIS MUST BE IMPORTED AS THE VERY LAST THING BEFORE THE CLOSE </body>
//tag.
/**
The number of milliseconds to ignore key-presses in the search box,
after a key *that was not ignored* was pressed. Used by
`$(document).ready()`.
Equal to <code>100</code>.
*/
var MILLS_TO_IGNORE_SEARCH = 100;
/**
The number of milliseconds to ignore clicks on the *same* like
button, after a button *that was not ignored* was clicked. Used by
`$(document).ready()`.
Equal to <code>500</code>.
*/
var MILLS_TO_IGNORE_LIKES = 500;
/**
The Ajax "main" function. Attaches the listeners to the elements on
page load, each of which only take effect every
<link to MILLS_TO_IGNORE_SEARCH> or <link to MILLS_TO_IGNORE_LIKES>
seconds.
This protection is only against a single user pressing buttons as fast
as they can. This is in no way a protection against a real DDOS attack,
of which almost 100% bypass the client (browser) (they instead
directly attack the server). Hence client-side protection is pointless.
- http://stackoverflow.com/questions/28309850/how-much-prevention-of-rapid-fire-form-submissions-should-be-on-the-client-side
The protection is implemented via Underscore.js' debounce function:
- http://underscorejs.org/#debounce
Using this only requires importing underscore-min.js. underscore-min.map
is not needed.
*/
$(document).ready(function() {
/*
Warning: Placing the true parameter outside of the debounce call:
$('#color_search_text').keyup(_.debounce(processSearch,
MILLS_TO_IGNORE_SEARCH), true);
results in "TypeError: e.handler.apply is not a function"
*/
$('#color_search_text').keyup(_.debounce(processSearch,
MILLS_TO_IGNORE_SEARCH, true));
/*
There are many buttons having the class
td__toggle_color_like_button
This attaches a listener to *every one*. Calling this again
would attach a *second* listener to every button, meaning each
click would be processed twice.
*/
$('.td__toggle_color_like_button').click(_.debounce(processLike,
MILLS_TO_IGNORE_LIKES, true));
});
File three: The search feature
Save the following as:
/home/myname/django_files/django_ajax_demo/static/color_ajax_search.js
///THIS FILE MUST BE IMPORTED BEFORE THE "main" FILE.
/**
Executes a search for colors containing a substring.
*/
var processSearch = function() {
//The key-press listener is no longer attached
//Get and trim the search text.
var searchText = $('#color_search_text').val().trim().toLowerCase();
if(searchText.length < MIN_SEARCH_CHARS) {
//Too short. Ignore the submission, and erase any current results.
$('#color_search_results').html("");
} else {
//There are at least two characters. Execute the search.
var processServerResponse = function(sersverResponse_data, textStatus_ignored,
jqXHR_ignored) {
//alert("sersverResponse_data='" + sersverResponse_data + "', textStatus_ignored='" + textStatus_ignored + "', jqXHR_ignored='" + jqXHR_ignored + "'");
$('#color_search_results').html(sersverResponse_data);
}
var config = {
/*
Using GET allows you to directly call the search page in
the browser:
http://the.url/search/?color_search_text=bl
Also, GET-s do not require the csrf_token
*/
type: "GET",
url: SUBMIT_URL,
data: {
'color_search_text' : searchText,
},
dataType: 'html',
success: processServerResponse
};
$.ajax(config);
}
};
That’s it! Give it a try!
Start your server, and go to
http://my.website/color_liker
Once again, you should see something like this. The obvious difference is the page no longer reloads on each search request. To see the "rapid-fire request protection" in action, dramatically increase the value of MILLS_TO_IGNORE_SEARCH
.
[All parts: ONE, TWO, THREE]
Thank you for going through this tutorial.
If you need help or have any suggestions, please leave a comment below. I’ll be glad to assist you.