Filtered Menus in Django

For a recent project I needed to make some classic filtered menus. This is the typical cascading choice type where a selection in one field of a form filters the available options in a subsequent field.

I cam across several methods to get this done in Django, most notably the Dajax project. However, I was expecting that this would be a one-off ajax call and I had recently done me some ajax learning so I figured I would just write it myself.

First off I need to define my model.

models.py:

class Country(models.Model):
    name = models.CharField(unique=True, max_length=255,)
    capital = models.CharField(unique=True, max_length=255)

Now I need a form.

forms.py:

from django import forms

class SearchForm(forms.Form):
    country = forms.ModelChoiceField(
                queryset=Country.objects.values_list('name'), 
                empty_label='Not Specified', 
                widget=forms.Select(attrs={ 
                                   "onChange":'getCity()'})
                )

    city = forms.ModelChoiceField(
                queryset=Country.objects.values_list('city'), 
                empty_label='Not Specified'
                )

Not much of a form but sufficient for demonstration purposes. A couple of things to note here:

1. I set up the querysets as value_lists for each field. This means that our default is to display a full list of both country name and city in our unmodified select boxes. In case the javascript breaks or is disabled, I will still be able to make our selection. Just not so elegantly.

2. I use the Widget.attrs argument to tweak the html output of the capital select field. Specifying additional attributes in the form is a very powerful method for adding specific markup to the form fields. In this case I add an onchange event handler.

Then of course the template which holds the html output of the form.

index.html:

<div id="select_form">
    <form action="{% url search %}" method="post">{% csrf_token %}
    {% for field in form %}
    <div class="field_wrapper">
        {{ field.errors }}
        {{ field.label_tag }} {{ field }}
    </div>
    {% endfor %}
    <input type="submit" name="submit" value="Search"  />
    </form>
</div>

Now, in my urls.py I need to specify two url patterns. One for the template and one for the ajax call.

urls.py:

 
    url(r'^$', views.form, name='form'), 
    url(r'^find_cities/$', views.find_cities, name='find_cities'),   

And in the views I make two views which handle the template and the ajax call respectively.

views.py:

 
#   index
def index(request):
    # create context dictionary
    context = {}
    # variables...
    context['form'] = SearchForm()
    return render(request, 'index.html', context)


#   find_cities (ajax processor)   
def find_cities(request, qs=None):
    if qs is None:
        qs = Country.objects.values_list('city', flat=True).all()
    if request.GET.get('country_name'):
        country_name=request.GET.get('country_name')
    # create an empty list to hold the results
    results = []
    qs = Country.objects.values_list('city', flat=True).filter(name=country_name).order_by('city')
    # iterate over each city and append to results list 
    for city in qs:
        results.append(city)
    # if no results found then append a relevant message to results list
    if not results:
        # if no results then dispay empty message
        results.append(_("No cities found")) 
    # return JSON object
    return HttpResponse(simplejson.dumps(results))

The last piece of the puzzle is the ajax call itself. Note that this particular method relies on jQuery being installed/ linked to.

find_cities.js:

// set up a new XMLHttpRequest variable
var request = false;
try {
    request = new XMLHttpRequest();
} catch (trymicrosoft) {
    try {
        request = new ActiveXObject("Msxml2.XMLHTTP");
    } catch (othermicrosoft) {
        try {
            request = new ActiveXObject("Microsoft.XMLHTTP");
        } catch (failed) {
            request = false;
        }
    }
}

if (!request)
    alert("Error initializing XMLHttpRequest!");

function getCity() {
    var countryName = document.getElementById("country").value;
    var url = "http://localhost:8000/collections/find_cities?country_name=" + escape(countryName);
    request.open("GET", url, true);
    request.onreadystatechange = updatePage;
    request.send(null);
} 

// what to do when http ready state changes
function updatePage() { 
    if (request.readyState == 4) {
        if (request.status == 200) {
 
            // get response array
            var data = JSON.parse(request.responseText); 
            update_select($('select[name=city]'), data);
        }  
        // some error checking
        else if (request.status == 404) {
            alert("Request url does not exist");
        }
        else {
            alert("Error: status code is " + request.status);
        }
    }
}   

function update_select(select, data) {
    select.find('option').remove();
    for (var k in data) {
        select.append($('<option value="'+data[k]+'">'+data[k]+'</option>'));
    }
}

This is pretty straightforward AJAX.

1. An XMLHttpRequest() is instantiated, with a special method of instantiating for IE (oh the joy!).

2. Then the getCity() function gets the value selected in the Country field which it then passes along with the url for our ajax view to request.open(). The final parameter, when set to true, requests an asynchronous connection (thus making this Ajax). When the readystate changes the updatePage function is called (note the lack of parenthesis when calling the function.

3. The updatePage() function checks that the readystate has changed to 4 which means the response from the server is complete. It then checks that the request.status is 200 (as opposed to 404 or 500). If all is good then we can grab the JSON data from the request.responseText and pass it to the updateSelect function along with the name of the select target (in this case city).

4. Finally we reach our updateSelect() function which first removes any existing options form the city select field. As a default this holds only the string Not Specified which I defined in the forms.py as empty_label='Not Specified'. The function then iterates through the JSON data array and populates the select function appropriately. Of course if no results were found then there will be only one element in the dump, namely the ‘No cities found’ fallback string.

And that’s it. This clearly lays out the moving parts associated with this common task. Of course this code could be written more concisely. For example we could use only one view + url which handles both the template and the ajax call, where we just pass an argument to the view which makes it execute the ajax call in an if loop, or return the template as a default.

Leave a comment