Metal Toad has been building applications and cloud environments for some of the most well-known global brands for over a decade. Learn more > >

Creating a Custom Glossary Filter in Django

The Scenario

You have a project that has lists of data and you need to have a way for users to filter the list by the first letter of the title/name/etc. This is commonly referred to as Glossary Filtering and can be a bit trickier than you'd think to do well.

The Example


This post uses code that was done in the Django 1.5 Python framework but the concepts used could easily be transferred to other languages/frameworks.

Disclaimer: The code snippets shown were pulled from a full project and are not meant to work entirely on their own. If you copy and paste it all as is, it will likely give you an error. However, if you want use it as inspiration for your own code; you're in the right place.

The Requirements

  • Each letter of the alphabet must be represented.
  • A button for "#" must be there to represent all values that do not start with a standard a-z or A-Z alpha characters.
  • There must be a way to clear the filter and go back to "All" results.
  • Only filters that have results available should be able to be clicked, others should be greyed out.
  • Which glossary filters are available should also take into account other filters that have also been applied.

The Django Code

settings.py
The first thing we need to do is define our list of available filters. There are several ways you can about this like looping through a range of ASCII characters, or even just using Python's range() method. For this example, let's just keep things simple and make a global tuple in our settings.py that can also easily include our "#" button.

# Links to use in the Glossary filters.
GLOSSARY_FILTERS = ('#','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z')

views.py
First make sure you include our setting in your imports. You'll also need Q from the django.db.models for doing some complex queries. There are also a few other imports to fully support this entire example.

from my_project.settings import GLOSSARY_FILTERS
from django.db.models import Q
# For the example I am using two models I have defined in my models.py (Keyword and Url).
# I'm using some other Django code like Paginator and the render_to_response shortcut.

Now you need to define a function for determining which filters are actually available. In this case we also want to take into account other filters that may be applied. This method accepts two arguments, one for our list of possible filters, and an optional dict of other filters that have been set.

def _fetch_avail_keyword_glossary_filters(glossary_filters, other_filters={}):
    """
    Processes the glossary filters and checks for available records.
    :param glossary_filters: Tuple.
    :param other_filters: Dict.
    """
    # If not filters passed, bail out gracefully.
    if not glossary_filters:
        return {}
 
    filters = []
    for filter in glossary_filters:
        avail = False
        if filter is '#':
            # Here we do some special handling of our non-alpha filter.
            # We're using Django's exclude in combination with __regex to find all 
            # rows that start with a number or other non-alpha character.
            results = Keyword.objects.exclude(keyword__regex=r'^[a-zA-Z]')
        else:
            # For regular alpha characters filter with a case insensitive __istartswith
            results = Keyword.objects.filter(keyword__istartswith=filter)
 
        if other_filters:
            for filter_name, filter_info in other_filters.items():
                tmp = ''
                if filter_name is 'Search':
                    # Special handling for a keyword search field since it looks across multiple fields. 
                    tmp = 'results = results.filter('
                    query = []
                    for field in filter_info['fields']:
                        query.append('Q(%s="%s")' % (field, filter_info['value']))
                    tmp += ' | '.join(query)
                    tmp += ')'
                else:
                    # For all other filters that are not a keyword search, apply them ad a field value pair.
                    if 'field' in info and 'value' in info:
                        tmp = 'results = results.filter(%s="%s")' % (filter_info['field'],filter_info['value'])
 
                # Generally I am not a fan of doing exec's in any language, so make sure you've 
                # done your validation thoroughly when you accepted your GET or POST variables. 
                if tmp:
                    exec tmp
 
        if results:
            # If results were found from the final query, set this filters avail flag to True.
            avail = True
 
        # Add this filter with it's availability flag to the dict that will be returned.
        filters.append({
            'value': filter,
            'display': filter,
            'avail': avail,
        })
 
    return filters

Now you need to make sure the view that your page is using includes the list of filters in the template variables and is all set to apply the glossary filters if applied.

def keywords_page(request):
    """
    Keywords page
    :param request:
    :return keywords page:
    """
    variables = {
        'page_title': 'Keywords',
        'domains': Url.objects.values('domain').distinct().order_by('domain'),  # I'm pulling in possible domain values from a Url model.
        'sort_opts': SORT_OPTS_KEYWORDS,    # This is another global in my settings.py
    }
 
    # The main query. If no options or filters are set, this is all it needs.
    keyword_list = Keyword.objects.all()
 
    set_filters = {}
 
    # Check for the domain filter.
    if 'domain' in request.GET:
        for domain in variables['domains']:
            # Only set the domain filter that was passed if it was already found in the DB.
            if domain['domain'] == request.GET['domain']:
                # Filter our list.
                keyword_list = keyword_list.filter(urls__domain=domain['domain']).distinct()
                # Add this to our filters list that the glossary filter will use to check for available filters.
                set_filters['Domain'] = {
                    'field': 'urls__domain',
                    'value': domain['domain'],
                }
 
    # Check for search filter.
    if 'search' in request.GET:
        # Do some scrubbing of the user input string.
        variables['search_string'] = escape(strip_tags(request.GET['search']))
        search_string = strip_tags(request.GET['search'])
        # We want to check for results that match the keyword itself, and also a many-to-many relationship's title. 
        # Make sure you add the .distinct() or you'll likely wind up with duplicates.
        keyword_list = keyword_list.filter(Q(keyword__icontains=search_string) | Q(urls__title__icontains=search_string)).distinct()
        # Add this to our filters list that the glossary filter will use to check for available filters.
        set_filters['Search'] = {
            'fields': ['keyword__icontains', 'urls__title__icontains'],   # Using a list here since we have multiple fields.
            'value': search_string,
        }
 
    # Here is where we make sure the list of filters in the glossary are available for the template.
    variables['glossary_filters'] = _fetch_avail_keyword_glossary_filters(GLOSSARY_FILTERS, set_filters)
 
    # Check for glossary filter and apply it if set.
    if 'glossary' in request.GET:
        # Only apply a filter if we already know about it in the list of possible filters.
        for glossary in variables['glossary_filters']:
            if glossary['value'] == request.GET['glossary']:
                # Make note of which glossary filter is active so we can style it differently in the template.
                glossary['active'] = True
                variables['active_glossary'] = glossary['value']
                if glossary['value'] is '#':
                    # Here we do some special handling of our non-alpha filter.
                    # We're using Django's exclude in combination with __regex to find all 
                    # rows that start with a number or other non-alpha character.
                    keyword_list = keyword_list.exclude(keyword__regex=r'^[a-zA-Z]')
                else:
                    # For regular alpha characters filter with a case insensitive __istartswith
                    keyword_list = keyword_list.filter(keyword__istartswith=glossary['value'])
                set_filters['Glossary'] = {
                    'value': glossary['value'],
                }
 
    # Check for sort.    
    if 'sort' in request.GET:
        # Only apply the sort if we knew about it in the list of possible sorts.
        for sort in variables['sort_opts']:
            if sort['field'] == request.GET['sort']:
                sort['default'] = True    # Flag as the default for templating.
                if sort['dir'] == 'DESC':
                    keyword_list = keyword_list.order_by(sort['field']).reverse()
                else:
                    keyword_list = keyword_list.order_by(sort['field'])
            else:
                sort['default'] = False
    else:
        keyword_list = keyword_list.order_by('keyword')   # Our default sort (could use a global setting)
 
 
    # Check on the currently selected number of results to show per page.
    cur_num_show = 0
    # Only use the number to show if it is a digit and in our list of possible values defined in a global.
    if 'show' in request.GET and request.GET['show'].isdigit():
        for opt in NUM_SHOW_OPTS:
            if int(opt['value']) == int(request.GET['show']):
                cur_num_show = int(request.GET['show'])
 
    if cur_num_show == 0:
        for opt in NUM_SHOW_OPTS:
            if 'default' in opt and opt['default']:
                cur_num_show = int(opt['value'])
 
    variables['num_show_opts'] = NUM_SHOW_OPTS
    for opt in variables['num_show_opts']:
        if int(opt['value']) == int(cur_num_show):
            opt['default'] = True
        else:
            opt['default'] = False
 
    # Let's use the Django Paginator
    paginator = Paginator(keyword_list, cur_num_show)
 
    # Check for the current page, default to 1.
    cur_page = 1
    if 'page' in request.GET and request.GET['page'].isdigit():
        cur_page = int(request.GET['page'])
 
    keywords = {}
    try:
        keywords = paginator.page(cur_page)
    except PageNotAnInteger:
        keywords = paginator.page(1)
    except EmptyPage:
        # If page is out of range (e.g. 9999), deliver last page of results.
        keywords = paginator.page(paginator.num_pages)
 
    variables['keywords'] = keywords
 
    return render_to_response(
        'keywords.html',
        variables,
        context_instance=RequestContext(request)
    )

In templates/keywords.html
Now we need to add the glossary filter to our template. This is also where we pick up the "ALL" filter. Note that we're also setting classes for whether or not a filter is available and if it is the currently active filter. There is also code in this template that would build the other filter select elements based on lists passed to the template.

...
<ul class="glossary" id="glossary">
    <li data-id="all" class="{% if active_glossary %}avail{% else %}active{% endif %}">All</li>
    {% for opt in glossary_filters %}
        <li data-id="{{ opt.value }}" class="{% if opt.avail %}avail{% endif %}{% if opt.active %} active{% endif %}">{{ opt.display }}</li>
    {% endfor %}
</ul>
...

In static/js/main.js
Now we need some javascript to hold it all together and help us apply our filters. This example has a function that analyzes the filter values and reloads the page using them as GET arguments. Of course you could always AJAX-ify this to make it more sexy.

/**
 * Sets up the Keywords list page. Called on page ready.
 */
function keywords_list_init(){
  // Set the click event for the delete buttons.
  ....
 
  // Initialize the ability to select all rows from a checkbox in the data header row.
  ....
 
  // Set up the delete all selected button.
  ....
 
  // Set up pagination links click events.
  ....
 
  // Setup the keyword search filter.
  $('#search').bind('keydown', function(e) {
    if (e.keyCode == 13) {
      rebuild_keywords_list_args();
    }
  });
  $('#search-button').bind('click', function() {
    rebuild_keywords_list_args();
  });
 
  // Setup the number of results to show, domain filter, and sort options change events.
  $('#num-show, #domain, #sort').bind('change', function() {
    rebuild_keywords_list_args();
  });
 
  // Setup all of our glossary filters that are available as clickable. 
  $('#glossary li.avail').bind('click', function() {
    $('#glossary li').removeClass('active');
    $(this).addClass('active');
    rebuild_keywords_list_args();
  });
 
}
 
/**
 * Rebuild the list of filter and sort arguments and reload the page.
 */
function rebuild_keywords_list_args(export_flag) {
  var show = $('#num-show').val();
  var domain = $('#domain').val();
  var sort = $('#sort').val();
  var search = $('#search').val();
  var page = $('#page-num').val();
  var glossary = $('#glossary li.active').attr('data-id');
  if (!page) {
    page = 1;
  }
 
  var new_loc = window.location.pathname + '?page=' + page;
  if (show) {
    new_loc += '&show=' + show;
  }
  if (sort) {
    new_loc += '&sort=' + sort;
  }
  if (domain) {
    new_loc += '&domain=' + domain;
  }
  if (glossary) {
    new_loc += '&glossary=' + encodeURIComponent(glossary);
  }
  if (search) {
    new_loc += '&search=' + encodeURIComponent(search);
  }
  if (export_flag) {
    new_loc += '&export=1';
  }
 
  window.location.href = new_loc;
}

In static/css/styles.css
Just to add some finishing touches, let's add a few styles to make sure our glossary properly indicates which filter is active and which ones can be clicked on.

.glossary {
  list-style: none;
  margin: 0;
  padding: 0;
  cursor: default;
}
 
.glossary li {
  margin: 0 1px;
  font-size: 12px;
  display: inline;
  color: #929496;
  cursor: default;
}
 
.glossary li.avail {
  color: #dfdfdf;
  cursor: pointer;
}
 
.glossary li.active {
  text-decoration: underline;
  cursor: default;
  color: #8eb887;
}

End Results

Clicking on a Letter
When we click the "S" filter we will only see entries that start with "S" or "s".

Setting Multiple Filters
When we also set the optional domain filter then we only see entries that start with "S" or "s" from that specific domain. Also note that because a domain filter is set, less glossary filter buttons are active.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • You can enable syntax highlighting of source code with the following tags: <code>, <blockcode>, <cpp>, <java>, <php>. The supported tag styles are: <foo>, [foo].
  • Web page addresses and email addresses turn into links automatically.
  • Lines and paragraphs break automatically.

Ready to get started?