Filtering Dropdown Lists in the Django Admin
Automatically-generated dropdown lists can seem a little mysterious at first - particularly when you first want to customise what they contain in the Django admin. I'm going to go through a number of examples of increasing complexity of customising the content of dropdowns in various contexts: ModelForms, and then into the Django admin.
Here are the models that the examples will work with. They're abbreviated and slightly modified versions of some models from the Swoop project I'm currently working on:
class Area(models.Model):
title = models.CharField(max_length=100)
area = models.MultiPolygonField(blank=True, null=True)
class Trip(models.Model):
title = models.CharField(max_length=100)
area = models.ForeignKey(Area)
class Landmark(models.Model):
title = models.CharField(max_length=100)
point = models.PointField()
class MountaineeringInfo(models.Model):
trip = models.ForeignKey(Trip)
area = models.ForeignKey(Area, blank=True, null=True)
base_camp = models.ForeignKey(Landmark, blank=True, null=True)
As you can see, we're using GeoDjango here - I'm not going to talk much about that here, but it should be obvious what's going on when we get it it. Note that these examples assume Django 1.2.
- Filtering a forms.ModelForm's ModelChoiceField
- Filtering a Django admin dropdown
- Filtering a Django admin dropdown in an inline, based on a value on the main instance (phew!)
Filtering a form's ModelChoiceField
Consider this form:
class MountaineeringForm(forms.ModelForm):
class Meta:
model = MountaineeringInfo
This'll generate a simple form for us, including dropdowns with options for every Landmark, Trip and Area we have defined. Let's look at the area foreign key first. Note how the area attribute of the Area model is nullable. Let's say we only wanted to be able to select Areas from our MountaineeringForm which had a valid area attribute set - put another way, we want to filter out those records which are null.
This is pretty straightforward, and is in fact covered in the docs. And there are, in fact, two ways to do it:
class MountaineeringForm(forms.ModelForm):
area = forms.ModelChoiceField(queryset=Area.objects.exclude(area=None))
class Meta:
model = MountaineeringInfo
This is probably the simplest way, and works well whenever the filtering you need to do does not depend on any request-specific or context-specific information.
The other option we have is to allow the ModelForm base class to do its usual thing, and then modify the fields that were generated directly.
class MountaineeringForm(forms.ModelForm):
class Meta:
model = MountaineeringInfo
def __init__(self, *args, **kwargs):
super(MountaineeringForm, self).__init__(self, *args, **kwargs)
self.fields['area'].queryset = Area.objects.exclude(area=None)
Now, this is slightly more verbose, and to my eye, not so clear as the first version. However, this approach of modifying the form after the fields have been constructed is a pattern we'll see in the coming examples.
Filtering a Django Admin Dropdown
Now, let's say that we want to edit MountaineeringInfo instances in the Django admin. At the moment, we just have this in our admin.py:
admin.site.register(MountaineeringInfo)
This generates a form much as we had previously with our simple ModelForm definition. However, we still want to filter out those Area instances which don't have an area set. We do this by providing a custom ModelAdmin sublcass, and overriding the formfield_for_foreignkey method:
class MountaineeringInfoAdmin(admin.ModelAdmin):
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == 'area':
kwargs['queryset'] = Area.objects.exclude(area=None)
admin.site.register(MountaineeringInfo, MountaineeringInfoAdmin)
This is pretty straightforward, and is indeed documented in the Django docs. Note that the request is passed into this method, so it's easy to perform some filtering based on some aspect of the request - the currently logged-in user, for example.
Filtering an inline's dropdown based on the inline instance
This is slightly more complicated. Let's say we're editing a Trip, and we're editing MountaineeringInfo instances by way of an inline. In code terms, we've got something like this:
class MountaineeringInfoInline(admin.TabularInline):
model = MountaineeringInfo
class TripAdmin(admin.ModelAdmin)
inlines = [MountaineeringInfoInline]
Now, referring back to our models, let's say we want to filter the available Landmarks for an inline depending on what Area is selected. There are two cases we have to consider: what happens when the inline is displayed but blank (ie. it's not bound to a MountaineeringInfo instance); and then, once we've got a MountaineeringInfo instance to bind to.
This can be solved using custom inline formsets. Let's extend the MountaineeringInfoInline class:
class MountaineeringInfoInline(admin.TabularInline):
model = MountaineeringInfo
formset = MountaineeringInfoInlineFormset
Note we've defined an extra attribute, specifying a custom formset. Let's go ahead and define that formset:
class MountaineeringInfoInlineFormset(BaseInlineFormSet):
def add_fields(self, form, index):
super(MountaineeringInfoInlineFormset, self).add_fields(form, index)
landmarks = Landmark.objects.none()
if form.instance:
try:
area = form.instance.area
except Area.DoesNotExist:
pass
else:
landmarks = Landmark.objects.filter(point__within=area.area)
form.fields['base_camp'].queryset = landmarks
Here, we override the inline formset's add_fields() method which - unsurprisingly - is called to generate all the fields that will appear in the inline formset. Note that the form is passed in as an argument. Since this is a ModelForm, the underlying instance (which will be a MountaineeringInfo instance, remember) is available using the instance attribute on the form. Now, if Django is generating a new, blank inline formset, then form.instance will be None. In this case, we don't want any landmarks to display - we want the user to have chosen an area first. Hence, we assign an empty QuerySet to the base camp field on the form.
On the other hand, if instance is set, then we have an existing MountaineeringInfo instance to work with. In this case, we get the area associated with it (note that area is nullable, so we have to wrap the access in a try/except to guard against the possibility that no area has been set) and create a QuerySet of all landmarks whose point lies within the area. So, when a user selects an area from the dropdown and presses Save, the landmarks contained in the base camp dropdown filter themselves to only those within the specified area.
Depending on your app, you might want the default value for landmarks to be Landmark.objects.all() rather than none() as per the example above - if so, remember that all() is the default, so you could eliminate some of that code.
The only thing to be aware of with the above code is if a user selects an area and a landmark, then changes the area so that the selected landmark is no longer valid for the area, the old landmark will remain set in the database. Of course, the base_camp dropdown would be blank, and would be reset to None when Save was pressed. If this were a problem, it would be possible to set the queryset to be Landmark.objects.filter(pk=form.instance.base_camp.pk).
Filtering an inline's dropdown based on the parent
OK, confession first - this feels like a hack. But I haven't found a cleaner way to do it - let me know if you know how to!
Note the Trip model has an Area foreign key as well. How might we go about filtering the 'area' dropdown in new MountaineeringInfo instances to only contain areas that are within the parent Trip's area?
Well, there are two parts to this:
- Figuring out what the area of the parent Trip is
- Filtering our own area dropdown depending on this values
We've actually done most of the work necessary to understand how to do the second part already. So let's do that part first. The key thing to know is that BaseInlineFormset-derived instances, like ModelAdmin instances, have a formfield_for_dbfield method. We can therefore override this to restrict the queryset used in fields contained within the inline. Let's extend our existing definition:
class MountaineeringInfoInline(admin.TabularInline):
model = MountaineeringInfo
formset = MountaineeringInfoInlineFormset
def formfield_for_dbfield(self, field, **kwargs):
if field.name == 'area':
# Note - get_object hasn't been defined yet
parent_trip = self.get_object(kwargs['request'], Trip)
contained_areas = Area.objects.filter(area__contains=parent_trip.area.area)
return forms.ModelChoiceField(queryset=contained_areas)
return super(MountaineeringInfoInline, self).formfield_for_dbfield(field, **kwargs)
As you can see - very similar to what we've seen before. We use the get_object call to extract the Trip that this MountaineeringInfo instance is (or will be) related to, and find all Area instances which are contained by that parent Trip's area.
So, what does that get_object() method look like?
def get_object(self, request, model):
object_id = request.META['PATH_INFO'].strip('/').split('/')[-1]
try:
object_id = int(object_id)
except ValueError:
return None
return model.objects.get(pk=object_id)
This clearly isn't ideal, as it depends on the URL structure used by the Django admin: it extracts the object ID by stripping off slashes, splitting on slashes, and taking the last element. It then looks up the appropriate object using the model class passed on.
class MountaineeringInfoInline(admin.TabularInline):
model = MountaineeringInfo
formset = MountaineeringInfoInlineFormset
def formfield_for_dbfield(self, field, **kwargs):
if field.name == 'area':
# Note - get_object hasn't been defined yet
parent_trip = self.get_object(kwargs['request'], Trip)
contained_areas = Area.objects.filter(area__contains=parent_trip.area.area)
return forms.ModelChoiceField(queryset=contained_areas)
return super(MountaineeringInfoInline, self).formfield_for_dbfield(field, **kwargs)
def get_object(self, request, model):
object_id = request.META['PATH_INFO'].strip('/').split('/')[-1]
try:
object_id = int(object_id)
except ValueError:
return None
return model.objects.get(pk=object_id)
(In the real app code, that get_object() is in a base class, for easier reuse - hence the parameterisation of the model.)
Can you do better?
This kind of filtering is often required in non-trivial applications: be it filtering on security (which is relatively easy, as the request is usually present in most ModelAdmin APIs) or filtering on other data values - which seems a lot trickier than you might want. However, once you understand how the various ModelAdmin classes, inlines and formsets fit together, it's not too bad.
I'm keen to hear how you're tackling this in your Django apps - and whether this can be simplified!

no more waiting for thousands of related objects to load :P
btw, for filtering a form's modelchoicefield - is this practically the same as specifying limit_choices_to on the model's fk field declaration?