You are here: Home Blog Filtering Dropdown Lists in the Django Admin

Filtering Dropdown Lists in the Django Admin

by Dan Fairs last modified Oct 04, 2010 08:19 AM
It's not immediately obvious how to filter dropdown lists in the Django admin interface. This article will talk about how ForeignKeys can be filtered in Django ModelForms and then 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.

Here are the cases that this article will cover:
  • 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:

  1. Figuring out what the area of the parent Trip is
  2. 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.

So that class in full:
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!

Filed under: ,
adrian
adrian says:
Oct 02, 2010 01:24 AM
wow thanks much for this great tip! i have been wanting to know how to do this and hadn't yet found the time to figure it out.

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?
Dan Fairs
Dan Fairs says:
Oct 02, 2010 08:55 AM
Yep - pretty similar!

(I have to confess, I didn't know about limit_choices_to! Thanks for pointing it out).
Zeddy
Zeddy says:
Oct 03, 2010 09:50 AM
One picture worth 1000 words. Please add admin screenshots to your post. Thank you!
Dan Fairs
Dan Fairs says:
Oct 03, 2010 12:03 PM
Point taken, though in this case I didn't feel that having a screen shot of a dropdown with lots of items, followed by a screen shot of a dropdown with few items, actually added much! Perhaps it would have helped illustrate the inlines relationship.

The other issue is that the code here is simplified from a real project, and screenshots from that would include lots of other, unrelated items.

I'll certainly include screenshots in future posts, where I think it's relevant.

Thanks for your feedback!
nerim
nerim says:
Oct 03, 2010 10:08 PM
There is small bug in:

class MountaineeringInfoAdmin(admin.ModelAdmin):
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if field.name == 'area':
            ...

I think that in IF statement should be a db_field.name, not field.name.

Anyway, great article :)
Dan Fairs
Dan Fairs says:
Oct 04, 2010 08:20 AM
Ah, the perils of simplifying from existing code!

Well spotted, I've fixed that now. Thanks!
Chez
Chez says:
Oct 15, 2010 05:32 PM
Dan,

Firstly, thanks for taking the time to share...

I have a question...How do you apply this to two a dropdown just in a normal admin class for a model.

I have a model that has regions and cities and I want to be able to filter the cities on selection of the region. I don't use an inline, both dropdowns contain foreignkeys for region and cities. Now I done loads of googling and have been playing around with your examples but can't seem to get it to work...Can it be done...

Thanks

Chez
Dan Fairs
Dan Fairs says:
Oct 18, 2010 06:13 PM
From memory, I think you need to override formfield_for_foreignkey and return an appropriate ModelChoiceField with a custom queryset - filtered appropriately for your needs.

There's actually an example in the Django docs:

http://docs.djangoproject.c[…]in.formfield_for_foreignkey
xaralis
xaralis says:
Dec 14, 2010 09:41 AM
I think I've found a way to solve "Filtering an inline's dropdown based on the parent" problem in a less-hacking way. It goes like this:

    def get_formset(self, request, obj=None, **kwargs):
        """
        Enable formfield_for_foreign key to be given obj instance
        """
        from django.utils.functional import curry
        return super(OrderDiscountInline, self).get_formset(request, obj=obj,
            formfield_callback=curry(self.formfield_for_dbfield, request=request, obj=obj)
        )
    
    def formfield_for_foreignkey(self, field, request, **kwargs):
        """
        Filter only discounts which are currently applicable
        """
        from dronte.commercial.fees_discounts.models import DiscountDescription
        if field.name == 'discount':
            if kwargs['obj'] is not None:
                kwargs['queryset'] = DiscountDescription.objects.applicable_to(kwargs['obj'])
        del kwargs['obj']
        return super(OrderDiscountInline, self).formfield_for_foreignkey(field, request, **kwargs)

The core idea is that get_formset method on the inline model is given parent obj instance sometimes. Then, we can alter the formfield_callback extra arg to contain different function which includes that obj as keyword argument. The formfield_for_foreign key than simply uses this when filtering.

I'm not sure it works always but it seems to work properly in my case.
Dan Fairs
Dan Fairs says:
Dec 15, 2010 01:38 PM
Interesting - I'll revisit my code and see if your approach makes it cleaner. Thanks!
volksman
volksman says:
Jan 11, 2011 01:34 AM
@xaralis must be missing something cause Django complains about the obj kwarg being passed in. Would be nice to get that route working though...
volksman
volksman says:
Jan 11, 2011 01:42 AM
Wow. Filtering an inline's dropdown based on the parent is pretty hacky but it works. Can't believe this is the only way though. Seems very wrong.
Greg
Greg says:
Mar 05, 2011 11:15 PM
Thank you very much for the great article! I finally found out how to filter an inline with the current request' user...

I used to do it by adding a "form = <ModelForm subclass>" to my TabularInline subclass. By overring __init__, I could modify each field's queryset. However, I did not have access to the current request instance.

The method you've described using formfield_for_dbfield works perfectly!

Thanks again!
Bernat Bonet
Bernat Bonet says:
Mar 24, 2011 03:24 PM
It works fine if you don't change parent value, because inline childs items have to be filtered by parent.
Anto Binish Kaspar
Anto Binish Kaspar says:
Apr 25, 2011 01:46 PM
Nice article. Good Job. It worked fine for me.
eng. Ilian Iliev
eng. Ilian Iliev says:
Jun 02, 2011 12:31 PM
You may also want to filter the options in the list view filter. You can see my approach to this here http://ilian.i-n-i.org/[…]/
Joni
Joni says:
Jul 22, 2011 01:17 AM
In the last example, instead of returning a new ModelChoiceField object:

return forms.ModelChoiceField(queryset=contained_areas)

It's better to let the super method create it to maintain label, help_text and other options defined in the model or the form.

You can do something like this:

f = super(MountaineeringInfoInline, self).formfield_for_dbfield(field, **kwargs)
f.queryset = (... filtered queryset ...)
return f
Xeli
Xeli says:
Aug 01, 2011 10:28 AM
If you need to access current instance object, another solution is to follow instructions in the post below instead of get_object and path hack.

http://stackoverflow.com/qu[…]ance-from-within-modeladmin

In my situation, I have classes Location and Coordinate. Location may have multiple coordinates attached to it and one default_coordinate. On admin view of a location I wanted dropdown list for default coordinate. The list views only coordinates attached to the location.

This is my admin.py code:

class CoordinateInline(admin.TabularInline):
  fields = ['title','latitude','longitude']
  model = Coordinate
  extra = 1

class LocationForm(forms.ModelForm):
  def __init__(self, *args, **kwargs):
    super(LocationForm, self).__init__(*args, **kwargs)
    self.fields['default_coordinate'].queryset
      = Coordinate.objects.filter(
        attach_to=self.instance.pk
      )

class LocationAdmin(admin.ModelAdmin):
  inlines = [CoordinateInline]
  form = LocationForm

admin.site.register(Location, LocationAdmin)
Tony
Tony says:
Aug 25, 2011 03:46 PM
Trying to figure out how to use this guide, but at the moment I don't it.. I think the database model you have used is pretty bad for educational purposes.
Eric
Eric says:
Aug 25, 2011 05:22 PM
Would need pictures to understand anything of this :(
Rand Iken
Rand Iken says:
Sep 13, 2011 04:46 AM
Thanks this is really good. However there is a pretty big shortcoming here. The models are totally meaningless to anyone who doesn't understand your app because they're not based around something in common knowledge. I have no idea what they mean thus it's confusing.
J. Heasly
J. Heasly says:
Dec 22, 2011 04:43 AM
The "Filtering a Django Admin Dropdown" example is missing this line:

return super(MountaineeringInfoAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

... just before the
admin.site.register(MountaineeringInfo, MountaineeringInfoAdmin)
line.

As you have it now, the filtered MountaineeringInfoAdmin field never gets returned to the form. Great bits otherwise!
Add comment

You can add a comment by filling out the form below. Plain text formatting.

Stereoplex is sponsored by Fez Consulting Ltd