Creating an Alphabetical Filter in Django’s Admin

by Corey Oordt •  Published 15 Jul 2010

Update: See the new blog post about the updated version.

Django’s admin includes a great date hierarchy filter for navigating date data in a model. I’ve come into a few situations where it would be nice to have a similar filter for the alphabet.

If you are managing hundreds or hundreds of thousands of names, tags or other alphabetical data, it would be nice to filter things alphabetically.

Using the date hierarchy as the template, I’ve implemented an alphabetical filter for the admin.

The code and sample project is available on github and will soon be on PyPi.

Implementing the Alphabet Filter

There are three parts:

  • The admin change_list.html template
  • The alphabet filter template tag
  • The alphabet.html template

The change_list.html Template

This template is easy to override on a per-model basis or per-application basis simply by where you save it. The current change_list.html template cannot be installed on a project wide basis as it extends admin/change_list.html and doing so leads to an infinite loop.

Per Model: <application>/templates/admin/<application>/<model>/change_list.html or <project>/templates/admin/<application>/<model>/change_list.html

Per Application: <application>/templates/admin/<application>/change_list.html or <project>/templates/admin/<application>/change_list.html

Project Wide:  Would have to copy contrib/admin/templates/admin/change_list.html to <project>/templates/admin/change_list.html and apply the appropriate changes.

The template extends admin/change_list.html adding in some extra styling and overriding the date_hierarchy block.

{% extends "admin/change_list.html" %}
{% load admin_list alphafilter %}
{% block extrastyle %}
{{block.super}}
    <style type="text/css" media="screen">
    .change-list ul.alphabetfilter li {
        width: 0.7em;
    }
    .inactive {
        color: #999;
    }
    .selected {
        color: red;
    }
    </style>
{% endblock %}
{% block date_hierarchy %}{% date_hierarchy cl %}{% alphabet cl %}{% endblock %}

The alphafilter Template Tag

The simplest part of the template tag renders the alphabet.html template with letters, with each letter linking to the same page and adding a query string in the format <field>__istartswith=<letter>.

In order to know which field to query, we’ll look for an alphabet_filter attribute on the model’s admin object, just as you would have a date_hierarchy attribute.

We’ll get all this information through the ChangeList object the admin template passes. The primary attributes we’ll be using are:

  • model_admin: The ModelAdmin object for the model
  • params: The GET parameters
  • get_query_string: A method to generate a query string based on the current query string.

The first part of our template tag looks like:

def alphabet(cl):
    if not getattr(cl.model_admin, 'alphabet_filter', False):
        return
    field_name = cl.model_admin.alphabet_filter
    alpha_field = '%s__istartswith' % field_name
    alpha_lookup = cl.params.get(alpha_field, '')
    link = lambda d: cl.get_query_string(d)

Django’s date hierarchy only allows the selection of data that is in the database. It doesn’t show every year, it shows the years that have data behind them. While the alphabet is pretty static and we might get away with just listing the alphabet with the query string links, what about numbers, punctuation, foreign languages and the plethora of characters that can be in today’s strings? And wouldn’t it also be nice to know which characters have data behind them and which don’t?

To accomplish this, we’re going to have to look in the database. We’ll create a new function called _get_available_letters to query the distinct leading characters of the field specified.

def _get_available_letters(field_name, db_table):
    from django.db import connection, transaction
    from django.conf import settings
    qn = connection.ops.quote_name
    sql = "SELECT DISTINCT UPPER(SUBSTR(%s, 1, 1)) as letter FROM %s" \
                % (qn(field_name), qn(db_table))
    cursor = connection.cursor()
    cursor.execute(sql)
    rows = cursor.fetchall() or ()
    return set([row[0] for row in rows])

The custom query should work for all database engines. The function returns a set instead of a list to make it easy to keep the list of characters unique.

Going back to the alphabet function, we’ll deal with the variability in characters and showing which characters are used.

def alphabet(cl):
    if not getattr(cl.model_admin, 'alphabet_filter', False):
        return
    field_name = cl.model_admin.alphabet_filter
    alpha_field = '%s__istartswith' % field_name
    alpha_lookup = cl.params.get(alpha_field, '')
    link = lambda d: cl.get_query_string(d)

    import string
    letters_used = _get_available_letters(field_name, cl.model._meta.db_table)
    all_letters = list(set([x for x in string.uppercase+string.digits]) | letters_used)
    all_letters.sort()

We end up with two collections: the characters used in the database and all the characters available. The collection of all characters is created by taking a base set of uppercase letters and digits and joining it with the characters used set. The Python string module has locale-aware constants to handle foreign languages. (Being an Ameri-centric a-hole, I can’t verify this works in other languages. Please let me know!)

The all_letters variable now is a list that will contain at least digits and letters, but also any other character that happens to be in the database. The letters_used is a set of characters found in the data.

The last part of the template tag is setting up the context. We will supply the template with a list of dictionaries, each with link, title, active, and has_entries. We’ll create the list of choices using a list comprehension and add in a choice of “All” at the beginning.

def alphabet(cl):
    if not getattr(cl.model_admin, 'alphabet_filter', False):
        return
    field_name = cl.model_admin.alphabet_filter
    alpha_field = '%s__istartswith' % field_name
    alpha_lookup = cl.params.get(alpha_field, '')
    link = lambda d: cl.get_query_string(d)

    import string
    letters_used = _get_available_letters(field_name, cl.model._meta.db_table)
    all_letters = list(set([x for x in string.uppercase+string.digits]) | letters_used)
    all_letters.sort()

    choices = [{
        'link': link({alpha_field: letter}), 
        'title': letter,
        'active': letter == alpha_lookup,
        'has_entries': letter in letters_used,} for letter in all_letters]
    all_letters = [{
        'link': cl.get_query_string(None,alpha_field),
        'title': _('All'),
        'active': '' == alpha_lookup,
        'has_entries': True
    },]
    return {'choices': all_letters + choices}
alphabet = register.inclusion_tag('admin/alphabet.html')(alphabet)

The alphabet.html Template

The alphabet.html template is pretty basic. Here it is with some extra formatting for readability:

<div class="xfull">
    <ul class="toplinks alphabetfilter">
    {% for choice in choices %}
        <li>
        {% if choice.has_entries %}
            <a href="{{ choice.link }}">
        {% else %}
            <span class="inactive">
        {% endif %}
        {% if choice.active %}<span class="selected">{% endif %}
        {{ choice.title }}
        {% if choice.active %}</span>{% endif %}
        {% if choice.link %}</a>{% else %}</span>{% endif %}
        </li>
    {% endfor %}
    </ul><br class="clear" />
</div>

There’s a few conditions to check so we can have proper formatting.

  1. If the choice has entries, render the character within a link, otherwise render the character within a span.

  2. If the choice is currently selected, render the character within a span

The Final Product

The implemented alphabet filter

The example project included with the alphafilter source includes an app called testtags. It has the alphabet filter applied to the “sorted_name” field. In the screen shot you see that ‘All’ is currently selected and all the characters are grey except ‘#’ and ‘O’. The ‘#’ symbol was added to the list only because it exists in the database.

blog comments powered by Disqus