Autocomplete for Foreign Keys in the Django Admin Explained

September, 03 2010

When the select drop-down for foreign keys in the Django Admin gets big it can become a usability issue. One solution is to create a custom UI for the the model, but stretching the Django Admin to the limit is one of the fun parts of working with Django.

Prior Art

The top hit on Google is django-autocomplete. This project is the basis of my approach and therefore much thanks goes to the author. There hasn't been an update since early 2009 and I wasn't able to get it working without a lot of changes. The changes evolved into what I consider a better approach.

Another interesting project is django-ajax-selects which is very fancy, complicated looking and actively maintained. It allows for very pretty autocompletes. My solution does it without any AJAX so it is a bit easier to use and makes for an easier case study. An AJAX based solution solves another critical problem: the drop down being so large it takes a long time for the admin page to render. Raw id fields also help to solve this problem albeit in a less elegant way.

There are a few other projects, some using YUI, some focusing on many to many fields. The solution presented here is jQuery based and for foreign keys in the admin only. This post attempts to explain the solution end to end and is based on Django 1.2.x.

Progressive Enhancement

Progressive enhancement is one of the key benefits of my approach. Instead of having a hidden input element to hold the real id of the foreign object, I actually keep the select drop-down and merely hide it and show the autocomplete field if Javascript is enabled.

Usage

Once complete, using it will look something like this inside an admin.py file:

admin.site.register(Foreign, AutocompleteTargetModelAdmin)

class MainAdmin(AutocompleteModelAdmin):
    #...
    autocomplete_fields = ('foreign',)
    #...

admin.site.register(Main, MainAdmin)

Where the Main model has a ForeignKey, foreign, to the model named Foreign.

What You Will Need

In addition to Django you will need:

Getting The Autocomplete Data

Normally the hard part is providing an AJAX response from some url, for which you have to define a view which provides the possible values. Requiring the client to do this every time is undesirable. There is a simpler way. Autocomplete can also work on static arrays, and since the drop-down is static it should work just as well. Actually, the select drop-down we are keeping hidden is a static list of possible values! We will use the select to generate a static array of values to populate the autocomplete.

The New Hard Part

The only really hard part left is keeping the "+" adding of items in the Django Admin working. This will force us to do one zany thing: require that our foreign model's admin class is a subclass of a special class we provide, the so called AutocompleteTargetAdminModel. More on this shortly.

Let's Do It

Now that you have some idea of what is coming let's get started. Define a separate app called something clever like "autocomplete". It will contain an admin.py file (abuse of naming: we will be subclassing classes in django.contrib.admin rather than defining new admin classes like in a regular app admin.py file) and a widget.py file where a custom widget is defined.

Starting with widget.py. The ForeignKeyAutocompleteInput is based the ForeignKeySearchInput in the source of django-autocomplete. The major difference is I hide the original select widget with Javascript instead of using a hidden widget.

from django.conf import settings
from django import forms
from django.db import models

from django.utils.safestring import mark_safe

class ForeignKeyAutocompleteInput(forms.widgets.Select):
    class Media:
        css = {
                'all': ('%s/css/jquery.autocomplete.css' % settings.MEDIA_URL,)
        }
        js = (
                '%s/js/jquery.js' % settings.MEDIA_URL,
                '%s/js/jquery.autocomplete.js' % settings.MEDIA_URL,
                '%s/js/autocomplete.popup.js ' % settings.MEDIA_URL
        )

    def text_field_value(self, value):
        key = self.rel.get_related_field().name
        obj = self.rel.to._default_manager.get(**{key: value})

        return unicode(obj)

    def __init__(self, rel, attrs=None):
        """
        rel - the relation for the foreign key.
        """
        self.rel = rel
        super(ForeignKeyAutocompleteInput, self).__init__(attrs)

    def render(self, name, value, attrs=None):
        if attrs is None:
            attrs = {}
        rendered = super(ForeignKeyAutocompleteInput, self).render(name, value, attrs)
        if value:
            text_field_value = self.text_field_value(value)
        else:
            text_field_value = u''
        return rendered + mark_safe(u'''
#
# Insert javascript wrapped in HTML as listed below here
#
                ''') % {
                        'MEDIA_URL': settings.MEDIA_URL,
                        'model_name': self.rel.to._meta.module_name,
                        'app_label': self.rel.to._meta.app_label,
                        'text_field_value': text_field_value,
                        'name': name,
                        'value': value,
                }

Let us focus on the key parts one at a time. First we define an internal class called Media which defines the external resources required to render.

class Media:
    css = {
            'all': ('%scss/jquery.autocomplete.css' % settings.MEDIA_URL,)
    }
    js = (
            '%s/js/jquery.js' % settings.MEDIA_URL,
            '%s/js/jquery.autocomplete.js' % settings.MEDIA_URL,
            '%s/js/autocomplete.popup.js ' % settings.MEDIA_URL
    )

We require jQuery and jquery.autocomplete which provides the jquery.autocomplete.css. We will need to write a bit of Javascript which I called autocomplete.popup.js to support adding new values.

Next we write some boiler plate the widget class needs to work.

def text_field_value(self, value):
    key = self.rel.get_related_field().name
    obj = self.rel.to._default_manager.get(**{key: value})

    return unicode(obj)

def __init__(self, rel, attrs=None):
    """
    rel - the relation for the foreign key.
    """
    self.rel = rel
    super(ForeignKeyAutocompleteInput, self).__init__(attrs)

The interesting bit is render.

def render(self, name, value, attrs=None):
    if attrs is None:
        attrs = {}
    rendered = super(ForeignKeyAutocompleteInput, self).render(name, value, attrs)
    if value:
        text_field_value = self.text_field_value(value)
    else:
        text_field_value = u''
    return rendered + mark_safe(u'''

This is followed by a huge Javascript filled string which is the real meat of what we are doing here. We have rendered the select normally and in the very last line we append a bunch of additional HTML which includes the autocomplete widget and the Javascript to create the array of possible values and pass it into an .autocomplete function.

<input type="text" id="lookup_%(name)s" value="%(text_field_value)s" size="40" style="display: none;"/>
<script type="text/javascript">

$(document).ready(function(){
    // Javascript is required to show the autocomplete field and hide the select field.
    $("#id_%(name)s").hide();
    $("#lookup_%(name)s").show();

    function liFormat_%(name)s (row, i, num) {
            var result = row[0] ;
            return result;
    }

    var %(name)s_data = Array();
    var %(name)s_id_map = {};

    function load_autocomplete_data_from_select() {
        %(name)s_data = Array();
        %(name)s_id_map = {};
        $("#id_%(name)s option").each(function(d) {
            %(name)s_data.push($(this).html());
            %(name)s_id_map[$(this).html()] = $(this).val();
        })

        $("#lookup_%(name)s").autocomplete(%(name)s_data, {
            delay:10,
            minChars:1,
            matchSubset:1,
            autoFill:false,
            matchContains:1,
            cacheLength:10,
            selectFirst:true,
            formatItem:liFormat_%(name)s,
            maxItemsToShow:10
        }); 
    }

    load_autocomplete_data_from_select(); // Inital load

    // Changing the autocomplete field needs to change the hidden select field
    $("#lookup_%(name)s").change(function() {
        new_value = %(name)s_id_map[$(this).val()];
        if (new_value == undefined) {
            new_value = ""
        }
        $("#id_%(name)s").val(new_value); 
    })

    // It is possible to "change" the autocomplete text field and have the change
    // event not happen.  This double checks right before we submit.
    $("form").submit(function() {
        $("#lookup_%(name)s").change(); // Just to make sure
    })

    // When the add feature is used, it only knows how to change the select field
    // so the auto complete field needs to be updated too.
    $("#id_%(name)s").change(function () {
        $("#lookup_%(name)s").val($(this).find("option:selected").html());
        load_autocomplete_data_from_select(); // Could be a new value from an add
    })    
});
</script>

We pass some values into the Javascript block allowing us to do some % substitution. For example the name of the field will be unique on the page so we can use for ids.

) % {
        'MEDIA_URL': settings.MEDIA_URL,
        'model_name': self.rel.to._meta.module_name,
        'app_label': self.rel.to._meta.app_label,
        'text_field_value': text_field_value,
        'name': name,
        'value': value,
}

Admin Integration

The widget is handy, and could probably be used alone pretty effectively. But to support adding new values, and to make it much easier to use, some deep admin integration is handy. This approach might not be extremely future-proof, but I have tried to cut down on the zany-ness.

admin.py is also based on django-autocomplete. The big change is splitting up the AdminModel subclasses into a target and main variants.

Let's look at it in four parts. First import a bunch of stuff. We are going deep into Django so we will need a lot of utils functions.

from django.conf import settings
from django.contrib import admin
from django.db import models

from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _
from django.utils.html import escape
from django.utils.encoding import force_unicode

from django.contrib.auth.models import Message
from django.http import HttpResponse, HttpResponseNotFound, HttpResponseRedirect

from widgets import ForeignKeyAutocompleteInput

Moving quickly on we have the part which makes

autocomplete_fields = ('foreign',)

work.

class AutocompleteModelAdmin(admin.ModelAdmin):
    def formfield_for_dbfield(self, db_field, **kwargs):
        # For ForeignKey use a special Autocomplete widget.
        if isinstance(db_field, models.ForeignKey) and hasattr(self, "autocomplete_fields") and db_field.name in self.autocomplete_fields:
            kwargs['widget'] = ForeignKeyAutocompleteInput(db_field.rel)
            # extra HTML to the end of the rendered output.
            if 'request' in kwargs.keys():
                kwargs.pop('request')

            formfield = db_field.formfield(**kwargs)
            # Don't wrap raw_id fields. Their add function is in the popup window.
            if not db_field.name in self.raw_id_fields:
                # formfield can be None if it came from a OneToOneField with
                # parent_link=True
                if formfield is not None:
                    formfield.widget = AutocompleteWidgetWrapper(formfield.widget, db_field.rel, self.admin_site)
            return formfield

        return super(AutocompleteModelAdmin, self).formfield_for_dbfield(db_field, **kwargs)

One class overriding one function. If the field is a ForeignKey and is named in autocomplete_fields, then we use our widget from above, and do a bit of funky wrapping to make the "+" appear. More on the AutocompleteWidgetWrapper later.

I have separated out the next class. We use it on models we are targeting with an autocomplete field like this.

admin.site.register(Foreign, AutocompleteTargetModelAdmin)

The class looks like this:

class AutocompleteTargetModelAdmin(admin.ModelAdmin):

    def response_add(self, request, obj, post_url_continue='../%s/'):
        """
        Determines the HttpResponse for the add_view stage.
        """
        opts = obj._meta
        pk_value = obj._get_pk_val()

        msg = _('The %(name)s "%(obj)s" was added successfully.') % {'name': force_unicode(opts.verbose_name), 'obj': force_unicode(obj)}
        # Here, we distinguish between different save types by checking for
        # the presence of keys in request.POST.
        if request.POST.has_key("_continue"):
                self.message_user(request, msg + ' ' + _("You may edit it again below."))
                if request.POST.has_key("_popup"):
                        post_url_continue += "?_popup=%s" % request.POST.get('_popup')
                return HttpResponseRedirect(post_url_continue % pk_value)

        if request.POST.has_key("_popup"):
                #htturn response to Autocomplete PopUp
                if request.POST.has_key("_popup"):
                        return HttpResponse('<script type="text/javascript">opener.dismissAutocompletePopup(window, "%s", "%s");</script>' % (escape(pk_value), escape(obj)))

        elif request.POST.has_key("_addanother"):
                self.message_user(request, msg + ' ' + (_("You may add another %s below.") % force_unicode(opts.verbose_name)))
                return HttpResponseRedirect(request.path)
        else:
                self.message_user(request, msg)

                # Figure out where to redirect. If the user has change permission,
                # redirect to the change-list page for this object. Otherwise,
                # redirect to the admin index.
                if self.has_change_permission(request, None):
                        post_url = '../'
                else:
                        post_url = '../../../'
                return HttpResponseRedirect(post_url)

One class, one complete method copied except for one key line:

return HttpResponse('<script type="text/javascript">opener.dismissAutocompletePopup(window, "%s", "%s");</script>' % (escape(pk_value), escape(obj)))

Here "opener" is the window a user spawns by hitting the green "+". This change in the response lets us call our slightly different popup handling code which enables our autocomplete field to update with the new value after the hidden select drop-down is updated by the default Django Admin code.

There might be a better way, but I haven't been able to find it yet. Suggestions are welcome. The key problem is, though the select will change by default, it won't produce a change event, so there is no way to update the autocomplete field.

Finally the wrapper, which is all thanks to the author of django-autocomplete.

class AutocompleteWidgetWrapper(admin.widgets.RelatedFieldWidgetWrapper):
        def render(self, name, value, *args, **kwargs):
                rel_to = self.rel.to
                related_url = '../../../%s/%s/' % (rel_to._meta.app_label, rel_to._meta.object_name.lower())
                self.widget.choices = self.choices
                output = [self.widget.render(name, value, *args, **kwargs)]
                if rel_to in self.admin_site._registry: # If the related object has an admin interface:
                        # TODO: "id_" is hard-coded here. This should instead use the correct
                        # API to determine the ID dynamically.
                        output.append(u'<a href="%sadd/" class="add-another" id="add_id_%s" onclick="return showAutocompletePopup(this);"> ' % \
                                (related_url, name))
                        output.append(u'<img src="%simg/admin/icon_addlink.gif" width="10" height="10" alt="%s" /></a>' % (settings.ADMIN_MEDIA_PREFIX, _('Add Another')))
                return mark_safe(u''.join(output))

That is basically it except for a bit of Javascript that's needed to stitch together the popup logic which I placed in a file called autocomplete.popup.js

function showAutocompletePopup(triggeringLink) {
    var name = triggeringLink.id.replace(/^add_/, '');
    name = id_to_windowname(name);
    href = triggeringLink.href
    if (href.indexOf('?') == -1) {
        href += '?_popup=2';
    } else {
        href  += '&_popup=2';
    }
    var win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes');
    win.focus();
    return false;
}

function dismissAutocompletePopup(win, newId, newRepr) {
    newId = html_unescape(newId);
    newRepr = html_unescape(newRepr);
    var name = windowname_to_id(win.name);
    dismissAddAnotherPopup(win, newId, newRepr);
    $("#" + name).change();
}

Not super simple, but an effective way to get a friendlier admin user interface experience.

If you find this useful please let me know. If there is enough interest I will look into cleaning out the cruft and making an installable package out of this code.


Tweet comments, corrections, or high fives to @amjoconn