← August November

An autocomplete form widget for ForeignKey model fields

In my last post I showed you how to write your own widget for django-tagging form fields that uses the nifty jQuery autocomplete plugin to simplify entering tags. This time I’d like to take on a suggestion PatricK made in the comments:

my question is, if this could be done on a more abstract level for enhancing relations. when having a foreignkey, you get the browse-icon (lens) right near the input-field. it´d be awesome to search the relations just by typing something into the field … the autocomplete-functionality should then search the related entries on the basis of the defined search-fields.

Well, this is what I’m going to do today :)

Preface

Thanks to the way Django’s forms work, the following implementation should be applicable to your own Django code — but please bare in mind that this isn’t a copy and paste how-to. I rather encourage you to learn about the flexibility of forms, fields and widgets and how it’s used in the automatic Admin interface. You’ll be able to — and sometimes required — to customize the code posted below.

As an example I’m going to use the message system that is included in Django’s auth contrib app. As far as I know Django doesn’t provide an admin representation for the Message class by default because it’s supposed to be used programmatically (and is used by the admin itself, too). The model consists of two fields, a ForeignKey to a User and a text field for the message.

Use case

Since ForeignKey fields are rendered as select fields by default there is a good chance that they become unusable if there are a lot of possible choices. So in case you have a lot of users in your database for example it might be a bit cumbersome to choose a user from the ForeignKey dropdown menu. Django provides an option that allows specifying a raw_id_fields attribute in your ModelAdmin subclass that will tell Django to render the ForeignKey as a simple input taking the primary key of the refered object. The actual selection is done by clicking the browse icon next to the field and browsing the model with the standard admin interface in a popup. What I want to provide here though is a way to do that directly in place, in the original form.

Widget

We are going to use the jQuery and the great Autocomplete plugin again which does all the hard work. Please download the sources of the autocomplete plugin if you don’t have it already. It includes everything we need to get started. Copy all files and folders of the extracted jQuery Autocomplete zip file to the directory you specified in the MEDIA_ROOT setting. It should then contain: ‘jquery.autocomplete.js’, ‘jquery.autocomplete.css’ and a ‘lib’ directory with the other necessary files. In case you arrange your files differently don’t hesitate to change the paths below in the inner Media class.

It has been proven to be a good idea to put something like the follwing widget in the widgets.py of your Django app, but any place will do if you change the import paths.

from django import forms
from django.conf import settings
from django.utils.safestring import mark_safe
from django.utils.text import truncate_words

class ForeignKeySearchInput(forms.HiddenInput):
    """
    A Widget for displaying ForeignKeys in an autocomplete search input 
    instead in a <select> box.
    """
    class Media:
        css = {
            'all': ('jquery.autocomplete.css',)
        }
        js = (
            'lib/jquery.js',
            'lib/jquery.bgiframe.min.js',
            'lib/jquery.ajaxQueue.js',
            'jquery.autocomplete.js'
        )

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

    def __init__(self, rel, search_fields, attrs=None):
        self.rel = rel
        self.search_fields = search_fields
        super(ForeignKeySearchInput, self).__init__(attrs)

    def render(self, name, value, attrs=None):
        if attrs is None:
            attrs = {}
        rendered = super(ForeignKeySearchInput, self).render(name, value, attrs)
        if value:
            label = self.label_for_value(value)
        else:
            label = u''
        return rendered + mark_safe(u'''
            <style type="text/css" media="screen">
                #lookup_%(name)s {
                    padding-right:16px;
                    background: url(
                        %(admin_media_prefix)simg/admin/selector-search.gif
                    ) no-repeat right;
                }
                #del_%(name)s {
                    display: none;
                }
            </style>
<input type="text" id="lookup_%(name)s" value="%(label)s" />
<a href="#" id="del_%(name)s">
<img src="%(admin_media_prefix)simg/admin/icon_deletelink.gif" />
</a>
<script type="text/javascript">
            if ($('#lookup_%(name)s').val()) {
                $('#del_%(name)s').show()
            }
            $('#lookup_%(name)s').autocomplete('../search/', {
                extraParams: {
                    search_fields: '%(search_fields)s',
                    app_label: '%(app_label)s',
                    model_name: '%(model_name)s',
                },
            }).result(function(event, data, formatted) {
                if (data) {
                    $('#id_%(name)s').val(data[1]);
                    $('#del_%(name)s').show();
                }
            });
            $('#del_%(name)s').click(function(ele, event) {
                $('#id_%(name)s').val('');
                $('#del_%(name)s').hide();
                $('#lookup_%(name)s').val('');
            });
            </script>
        ''') % {
            'search_fields': ','.join(self.search_fields),
            'admin_media_prefix': settings.ADMIN_MEDIA_PREFIX,
            'model_name': self.rel.to._meta.module_name,
            'app_label': self.rel.to._meta.app_label,
            'label': label,
            'name': name,
        }
</select>

This widget renders ForeignKeys as hidden inputs and adds a second input field to search in the related model using asynchronous requests, sometimes refered to as AJAX. Once the user selects a result the widget will automatically set the hidden field to the primary key. When editing an existing entry this will automatically populate the search field with the correct verbose value.

Admin integration

As already stated we are going to create an admin class for the Message model to enable admins to create messages for other users that will be displayed the next time they use the admin (or any other site that uses User.get_and_delete_messages).

The best place for the admin class is an admin.py file in one of your own app directories because it’s then picked up by Django’s autodiscover() function.

import operator
from django.db import models
from django.contrib.auth.models import Message
from django.http import HttpResponse, HttpResponseNotFound
from django.contrib import admin
from django.db.models.query import QuerySet
from django.utils.encoding import smart_str

from yourapp.widgets import ForeignKeySearchInput

class MessageAdmin(admin.ModelAdmin):
    list_display = ('user', 'message')
    related_search_fields = {
        'user': ('username', 'email'),
    }

    def __call__(self, request, url):
        if url is None:
            pass
        elif url == 'search':
            return self.search(request)
        return super(MessageAdmin, self).__call__(request, url)

    def search(self, request):
        """
        Searches in the fields of the given related model and returns the 
        result as a simple string to be used by the jQuery Autocomplete plugin
        """
        query = request.GET.get('q', None)
        app_label = request.GET.get('app_label', None)
        model_name = request.GET.get('model_name', None)
        search_fields = request.GET.get('search_fields', None)

        if search_fields and app_label and model_name and query:
            def construct_search(field_name):
                # use different lookup methods depending on the notation
                if field_name.startswith('^'):
                    return "%s__istartswith" % field_name[1:]
                elif field_name.startswith('='):
                    return "%s__iexact" % field_name[1:]
                elif field_name.startswith('@'):
                    return "%s__search" % field_name[1:]
                else:
                    return "%s__icontains" % field_name

            model = models.get_model(app_label, model_name)
            qs = model._default_manager.all()
            for bit in query.split():
                or_queries = [models.Q(**{construct_search(
                    smart_str(field_name)): smart_str(bit)})
                        for field_name in search_fields.split(',')]
                other_qs = QuerySet(model)
                other_qs.dup_select_related(qs)
                other_qs = other_qs.filter(reduce(operator.or_, or_queries))
                qs = qs & other_qs
            data = ''.join([u'%s|%s\n' % (f.__unicode__(), f.pk) for f in qs])
            return HttpResponse(data)
        return HttpResponseNotFound()

    def formfield_for_dbfield(self, db_field, **kwargs):
        """
        Overrides the default widget for Foreignkey fields if they are
        specified in the related_search_fields class attribute.
        """
        if isinstance(db_field, models.ForeignKey) and \
                db_field.name in self.related_search_fields:
            kwargs['widget'] = ForeignKeySearchInput(db_field.rel,
                                    self.related_search_fields[db_field.name])
        return super(MessageAdmin, self).formfield_for_dbfield(db_field, **kwargs)
admin.site.register(Message, MessageAdmin)

So this code does several things:

  • It inherits from the default ModelAdmin to be able to override the default widget.
  • A new related_search_fields class attribute that is a mapping between lowercase model names and a list of fields to searched in. It uses the same syntax as the search_fields attribute to speed up the lookups or restrict them if needed. The following example would only match the beginning of the first and last name of users:
related_search_fields = {
    'user': ('^first_name', '^last_name'),
}

  • It overrides the __call__ method to add additional URL handler to the admin to be used by the widget for searching. It will be waiting for requests on /<root-admin-path>/auth/message/search/.
  • The search view that constructs the querysets depending on the provided model and search fields and returns the data as a simple string. The Autocomplete plugin can parse that and will display it in the selection.
  • A formfield_for_dbfield method that will override the widget of any ForeignKey field whose name is specified in the related_search_fields class attribute.

Once assembled the result will mostly look like that:

Please note that I use a different stylesheet for the Autocomplete plugin here.

Django, Python Nov. 14, 2008, 11:57 a.m. comments (15)

An autocomplete widget for django-tagging form fields

Today while working on django-page-cms I received a feature request by an user to enable the admin form field for tags of a page to automatically suggest input options — just like YouTube‘s or Google search field for example. Go ahead and try it if you don’t know what I mean.

Like my last article about a custom WYMEditor widget I will show you how such a autocomplete widget could be implemented by using jQuery and the great Autocomplete plugin which does all the hard work for us. Please download the sources of the autocomplete plugin if you don’t have it already. It includes everything we need to get started.

On the server side I’m going to use the well-known django-tagging which luckily also makes handling the tags a piece of cake.

The model

Although the whole process is entirely applicable to your own models, I’m going to use the following model as a matter of example.

from django.db import models
from tagging.fields import TagField

class Article(models.Model):
    title = models.CharField(max_length=50)
    content = models.TextField()
    tags = TagField()

It’s a rather simple model with a field that uses django-tagging‘s backend. It will later be rendered with your autocomplete widget.

The widget

Copy all files and folders of the extracted jQuery Autocomplete zip file to the directory you specified in the MEDIA_ROOT setting. It should then contain: 'jquery.autocomplete.js', 'jquery.autocomplete.css' and a 'lib' directory with the other necessary files. In case you arrange your files differently don’t hesitate to change the paths below in the inner Media class. It has been proven to be a good thing to put something like this widget in the widgets.py of a Django app.

from django import forms
from django.db.models import get_model
from django.utils import simplejson
from django.utils.safestring import mark_safe
from tagging.models import Tag

Article = get_model('yourapp', 'article')

class AutoCompleteTagInput(forms.TextInput):
    class Media:
        css = {
            'all': ('jquery.autocomplete.css',)
        }
        js = (
            'lib/jquery.js',
            'lib/jquery.bgiframe.min.js',
            'lib/jquery.ajaxQueue.js',
            'jquery.autocomplete.js'
        )

    def render(self, name, value, attrs=None):
        output = super(AutoCompleteTagInput, self).render(name, value, attrs)
        page_tags = Tag.objects.usage_for_model(Article)
        tag_list = simplejson.dumps([tag.name for tag in page_tags],
                                    ensure_ascii=False)
        return output + mark_safe(u'''<script type="text/javascript">
            jQuery("#id_%s").autocomplete(%s, {
                width: 150,
                max: 10,
                highlight: false,
                multiple: true,
                multipleSeparator: ", ",
                scroll: true,
                scrollHeight: 300,
                matchContains: true,
                autoFill: true,
            });
            </script>''' % (name, tag_list))

The render method queries all tags saved in other articles instances and passes them as a JavaScript array to the autocomplete initializer in the HTML.

The form

Next the widget needs to be hooked up with the admin interface. One way to do that is to override the default form Django is using when generating the admin site. This form inherits from the default ModelForm class and has a tags field that uses your custom AutoCompleteTagInput widget and is used for the model field with the same name. Please save the following code in the 'forms.py' of your app.

from django import forms
from django.db.models import get_model
from tagging.forms import TagField
from yourapp.widgets import AutoCompleteTagInput

class ArticleAdminModelForm(forms.ModelForm):
    tags = TagField(widget=AutoCompleteTagInput(), required=False)

    class Meta:
        model = get_model('yourapp', 'article')

Assembling

Now all what’s left to do is to connect your new ModelForm class with the admin interface by creating a custom ModelAdmin to override the default. At the end just register your model with the default admin site.

from django.contrib import admin
from django.db.models import get_model
from yourapp.forms import ArticleAdminModelForm

class ArticleAdmin(admin.ModelAdmin):
    # ..
    form = ArticleAdminModelForm

admin.site.register(get_model('yourapp', 'article'), ArticleAdmin)

Django, Python Nov. 6, 2008, 1:50 a.m. comments (13)

A WYSIWYM editor widget for Django’s admin interface

Recently I’ve been looking for a useful WYSIWYM editor to be used in Django’s admin interface. Thanks to the awesome admin being backed by the forms module it’s rather easy to replace a form field widget with a custom widget.

Although it’s more or less the defacto standard, I’m not going to use TinyMCE this time, partly because it feels a bit like bloatware and partly because it’s not based on jQuery — the JavaScript framework of choice of the project where I also want to use it: django-page-cms. The editor I’m going to use is WYMeditor because it provides authors some helpers to write sane (X)HTML instead of hiding it away while not leaving them with a blank textarea.

The widget

To use the following widget you need to download the source files of WYMedtior first and put them in the directory you specified in the MEDIA_ROOT setting of your Django-based site. So your MEDIA_ROOT directory should now contain a 'jquery' and a 'wymeditor' directory.

Also, copy the following code to a place of your convenience, e.g. the widgets.py of the Django app you are going to add the WYMeditor functionality to.

from django import forms
from django.conf import settings
from django.utils.safestring import mark_safe

class WYMEditor(forms.Textarea):
    class Media:
        js = (
            'jquery/jquery.js',
            'wymeditor/jquery.wymeditor.pack.js',
        )

    def __init__(self, language=None, attrs=None):
        self.language = language or settings.LANGUAGE_CODE[:2]
        self.attrs = {'class': 'wymeditor'}
        if attrs:
            self.attrs.update(attrs)
        super(WYMEditor, self).__init__(attrs)

    def render(self, name, value, attrs=None):
        rendered = super(WYMEditor, self).render(name, value, attrs)
        return rendered + mark_safe(u'''<script type="text/javascript">
            jQuery('#id_%s').wymeditor({
                updateSelector: '.submit-row input[type=submit]',
                updateEvent: 'click',
                lang: '%s',
            });
            </script>''' % (name, self.language))

The WYMEditor widget inherits most of its features from the default forms.Textarea widget and only adds a small bit of JavaScript to initialize in the browser. The inner Media class does all the hard work of adding the correct path to WYMEditor’s source files to the admin template.

The form

Next you need to create a ModelForm subclass that will later be used by the admin as the default form while rendering the edit page. You are again free to put it anywhere in your files as long as you are able to import it. In this case let’s add it to the forms.py of your app, a good place to store form classes.

Please note you need to customize some parts of the following snippet to match your app:

  • The second line needs to successfully import the WYMEditor widget
  • Line five should have the name of the field you want to apply the WYMeditor to, in this example the model field body would get it.
  • The last line uses a nifty tool of Django that loads a model without the need to import it. In fact you just need to give it the lowercase name of the app and the lowercase name of the model.
from django import forms
from django.db.models import get_model
from yourapp.widgets import WYMEditor

class ArticleAdminModelForm(forms.ModelForm):
    body = forms.CharField(widget=WYMEditor())

    class Meta:
        model = get_model('yourapp', 'article')

Assembling

Now open the 'admin.py' of your app to hook that ModelForm up with the admin interface. First import your ModelForm subclass. Then set the class attribute of the ModelAdmin subclass called form to the form subclass you just imported. Finally register your custom admin class and your model with the default admin site.

from django.contrib import admin
from django.db.models import get_model
from yourapp.forms import ArticleAdminModelForm

class ArticleAdmin(admin.ModelAdmin):
    # ..
    form = ArticleAdminModelForm

admin.site.register(get_model('yourapp', 'article'), ArticleAdmin)

That’s it for today, I hope everything works for you. Let me know what you think.

Django, Python Nov. 3, 2008, 3:55 a.m. comments (11)

← Previous 1 2