Runtime ChoiceField filtering in Django’s admin

Django 1.x brought with it much finer grained control over the admin application with admin forms and inline form sets. However, I still keep running into the same problem that I have since I started using Django – you cannot provide a limited queryset for a select field that depends on other instance variables.

Take this trivial example:

from django.db import models
 
class Sport(object):
    name = models.CharField(max_length=50)
 
class Season(models.Model):
    starts = models.DateField()
    ends = models.DateField()
    sport = models.ForeignKey(Sport)
 
class Team(object):
    name = models.CharField(max_length=100)
    sport = models.ForeignKey(Sport)
 
class Game(object):
    season = models.ForeignKey(Season)
    home_team = models.ForeignKey(Team, related_name="home_games")
    away_team = modesl.ForeignKey(Team, related_name="away_games")

In the admin change form for Game, it is obviously desirable to only permit teams to be selected that match the Season‘s Sport. Unfortunately, because fields are defined on the class rather than the instance (such as inside of __init__), there is no obvious way to create a relationship based on the values in the instance.

Inside the ModelAdmin class is the method get_formset(self, request, obj=None, **kwargs). The parameter obj stores the current instance, if any. The significance of this is that this method is a hook with access to the instance data and is called for every form as it is built.

That makes it possible to filter the Teams based on the current form’s instance.

from django.contrib import admin
from django import forms
from myapp.models import Team, Game
 
def game_form_factory(sport):
    class RuntimeGameForm(forms.ModelForm):
        home_team = forms.ModelChoiceField(label="Home",
                queryset=Team.objects.filter(sport=sport))
        away_team = forms.ModelChoiceField(label="Away",
                queryset=Team.objects.filter(sport=sport))
 
        class Meta:
            model = Game
 
    return RuntimeGameForm
 
class GameAdmin(admin.modelAdmin):
    model = Game
 
    def get_formset(self, request, obj=None, **kwargs):
        if obj is not None:
            self.form = game_form_factory(obj.season.sport)
        return super(GameAdmin, self).get_formset(request, obj, **kwargs)

Here is how it works. When the GameAdmin form is built, get_formset is called. If this is an edit form (add form’s will not have instance data) the Game instance is passed as the obj parameter. In this case, the instance sets the form attribute to be the result of calling game_form_factory, which is a class factory function.

What if we want the Game form to be an inline form for the Season form? The major difference with inline form sets is that the instance passed to get_formset is now that of the parent form, rather than the form set model (in this case, Season instead of Game.)

The class factory function remains essentially unchanged. The Game admin model requires only a small change.

class GameAdminInline(admin.TabularInline):
    model = Game
 
    def get_formset(self, request, obj=None, **kwargs):
        if obj is not None:
            self.form = game_form_factory(obj.sport) # obj is a Season
        return super(GameAdminInline, self).get_formset(request, obj,
                **kwargs)
Leave a comment | Trackback
Jul 31st, 2009 | Posted in Programming
Tags: ,
  1. Jul 31st, 2009 at 09:42 | #1

    I did this last year…I think some guy on the mailing list gave me the code. I’m glad to see a blog post about this, because there was none at the time so it took me forever to find the solution. This is one of those things that I think a lot of people need at some point in Django development whenever the models get more complex like this and it’s not exactly obvious how to do it. Thanks!

  2. Ryan
    Jul 31st, 2009 at 16:42 | #2

    You might also do something like:

    class RunTimeGameForm(forms.ModelForm):
        [...]
        def __init__(*args, **kwargs):
            super(RunTimeGameForm, self).__init__(*args, **kwargs)
            if self.instance.id:
                self.fields['home_team'].queryset=Team.objects.filter(
                    sport=self.instance.season.sport)
    

    Then there’s no need to override ModelAdmin.get_formset, or have a game_form_factory function.

    • Jeff
      Jul 31st, 2009 at 22:49 | #3

      That’s a good point. How would that work with a form set, though? I didn’t see that the parent instance is passed to the form set’s constructor.

    • Andri Jan
      Aug 3rd, 2009 at 08:02 | #4

      Yeah, that’s what I’d like to know. This is no problem with a form, it’s the formset that’s the problem

  3. Andrew Wall
    Feb 22nd, 2010 at 11:40 | #5

    Thanks very much for writing about this. I’m new to both python and django and there didn’t seem to be a way to handle this kind of problem without abandoning the Admin screens from the django doc.

  4. AJ
    Jun 11th, 2010 at 14:11 | #6

    The only way I could think to handle a formset is to sublcass django.forms.models.BaseInlineFormSet, override the _construct_form method and pass in some an additional kwarg(the parent instance) that will then get passed to the form. So you would need to then override the __init__ method in the form and use the extra kwarg to change the queryset on the field. The pseudo code would be something like:


    class BilletAdminFormSet(BaseInlineFormSet):
    def _construct_form(self, i, **kwargs):
    kwargs.update({'instance': self.instance})
    return super(BilletAdminFormSet, self)._construct_form(i, **kwargs)

    class BilletAdminForm(CustomModelForm):
    ...
    def __init__(self, *args, **kwargs):
    super(BilletAdminForm, self).__init__(*args, **kwargs)
    if 'instance' in kwargs and kwargs['instance']:
    self.fields['employees'].queryset = SomeQuerySet()

    • AJ
      Jun 11th, 2010 at 14:13 | #7

      Also you would need to assign the form and formset to the inline.

      admin.py

      class BilletInline(admin.StackedInline):
      model = Billet
      extra = 1
      form = BilletAdminForm
      formset = BilletAdminFormSet

  5. Simon Charette
    Oct 11th, 2010 at 20:58 | #8

    Thank you very much for posting this, it’s definitely the most clever approach i’ve found to tackle this issue. I can’t believe the django doc completely ignore this subject.

    • Simon Charette
      Oct 12th, 2010 at 18:24 | #9

      The modelAdmin method is get_form and not get_formset

  6. Derek
    Oct 21st, 2010 at 01:29 | #10

    You can also look at:

    http://www.stereoplex.com/blog/filtering-dropdown-lists-in-the-django-admin

    which covers the same topic, and has examples for both forms and formsets (inline forms).