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)
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!
You might also do something like:
Then there’s no need to override ModelAdmin.get_formset, or have a game_form_factory function.
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.
Yeah, that’s what I’d like to know. This is no problem with a form, it’s the formset that’s the problem
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.
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()
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
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.
The modelAdmin method is get_form and not get_formset
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).