Filtering querysets in django.contrib.admin forms

I make extensive use of the django admin interface. It is the primary tool for our support team to look at user data for our product, and I have stretched it in many ways to suit my needs.

One problem I often come back to is a need to filter querysets in forms and formsets. Specifically, the objects that should be presented to the admin user in a relationship to the currently viewed object should be filtered. In most cases, this is something as simple as making sure the Person and the Units they work at are within the same company.

There is a simple bit of boilerplate that can do this. You need to create a custom form, and attach this to the ModelAdmin for the parent object:

 1 from django.contrib import admin
 2 from django import forms
 3 from models import Person, Unit
 4 
 5 class PersonAdminForm(forms.ModelForm):
 6     class Meta:
 7         model = Person
 8     
 9     def __init__(self, *args, **kwargs):
10         super(PersonAdminForm, self).__init__(*args, **kwargs)
11         # This is the bit that matters:
12         self.fields['units'].queryset = self.instance.company.units
13 
14 class PersonAdmin(admin.ModelAdmin):
15     form = PersonAdminForm

In actuality, it is a little more complicated than this: you need to test if the selected object has a company, and really, if the user has changed the company (or selected it on a new person), you should use that instead. So the code looks a bit more like:

 1 company = None
 2 if self.data.get('company', None):
 3     try:
 4         company = Company.objects.get(pk=self.data['company'])
 5     except Company.DoesNotExist:
 6         pass
 7 else:
 8     try:
 9         company = self.instance.company
10     except Company.DoesNotExist:
11         pass
12 if company:
13     self.fields['units'].queryset = company.units.all()

Now, having to write all of that every time you have to filter the choices available wears rather thin. And wait until you need to do it to a formset instead: you need to also do stuff to the empty_form, so that when you dynamically add an inline form, it has the correct choices.

Enter FilteringForm, and her niece FilteringFormSet:

 1 from django import forms
 2 from django.core.exceptions import ObjectDoesNotExist
 3 
 4 class FilterMixin(object):
 5     filters = {}
 6     instance_filters = {}
 7     def apply_filters(self, forms=None):
 8         # If we didn't get a forms argument, we apply to ourself.
 9         if forms is None:
10             forms = [self]
11         # We need to apply instance filters first, as they allow us to
12         # select an attribute on our instance to be the queryset, and
13         # then apply a filter onto that with filters.
14         for field, attr in self.instance_filters.iteritems():
15             # It may be using a related attribute. person.company.units
16             tokens = attr.split('.')
17             
18             source = None
19             # See if there is any incoming data first.
20             if self.data.get(tokens[0], ''):
21                 try:
22                     source = self.instance._meta.get_field_by_name(tokens[0])[0].rel.to.objects.get(pk=self.data[tokens[0]])
23                 except ObjectDoesNotExist:
24                     pass
25             # Else, look for a match on the object we already have stored
26             if not source:
27                 try:
28                     source = getattr(self.instance, tokens[0])
29                 except ObjectDoesNotExist:
30                     pass
31             
32             # Now, look for child attributes.
33             if source:
34                 for segment in tokens[1:]:
35                     source = getattr(source, segment)
36                 if forms:
37                     for form in forms:
38                         form.fields[field].queryset = source
39         
40         # We can now apply any simple filters to the queryset.
41         for field, q_filter in self.filters.iteritems():
42             for form in forms:
43                 form.fields[field].queryset = form.fields[field].queryset.filter(q_filter)
44     
45 
46 class FilteringForm(forms.ModelForm, FilterMixin):
47     def __init__(self, *args, **kwargs):
48         super(FilteringForm, self).__init__(*args, **kwargs)
49         self.apply_filters()
50 
51 class FilteringFormSet(forms.models.BaseInlineFormSet, FilterMixin):
52     filters = {}
53     instance_filters = {}
54     
55     def __init__(self, *args, **kwargs):
56         super(FilteringFormSet, self).__init__(*args, **kwargs)
57         self.apply_filters(self.forms)
58     
59     def _get_empty_form(self, **kwargs):
60         form = super(FilteringFormSet, self)._get_empty_form(**kwargs)
61         self.apply_filters([form])
62         return form
63     empty_form = property(_get_empty_form)

Now, to use all of this, you still need to subclass, but you can declare the filters:

1 class PersonAdminForm(FilteringForm):
2     class Meta:
3         model = Person
4     
5     instance_filters = {
6         'units': 'company.units'
7     }

You can also have non-instance filters, and they will be applied after the instance_filters:

 1 from django.db import models
 2 
 3 class PersonAdminForm(FilteringForm):
 4     class Meta:
 5         model = Person
 6     
 7     instance_filters = {
 8         'units': 'company.units'
 9     }
10     filters = {
11         'units': models.Q(is_active=True)
12     }

I think it might be nice to be able to add an extra set of filtering for the empty form in a formset, so you could make it that only choices that hadn’t already been selected, for instance, were the only ones available. But that isn’t an issue for me right now.

blog comments powered by Disqus