Django admin inlines and custom form arguments
Posted on Sun 15 November 2015 in Web Development
Django admin inlines are a quick and convenient way to embed related objects into a django admin detail page. All is great until you need a custom form to handle the edits for the related objects, that in turn need custom arguments to be passed to the form constructor. In the following post, I'll describe a possible solution to this problem.
You may ask "Why would I need to pass custom form arguments to a form?". That's a valid question and most of the time you probably don't. There are cases though when you need additional business logic to happen whenever the value of a field is changed.
The Problem
Let's imagine the following use case. We have customers that can have one or more channels associated. Here's a possible way to model these business entities:
from django.db import models
class Customer(models.Model):
user = models.ForeignKey('auth.User')
business_name = models.CharField(max_length=100)
def __unicode__(self):
return unicode(self.user)
class CustomerChannel(models.Model):
customer = models.ForeignKey(Customer)
active = models.BooleanField(default=True)
name = models.CharField(max_length=30)
def __unicode__(self):
return self.name
From the admin detail page of the customer you want to be able to deactivate a channel (and maybe later reactivate it). You could come up with the following admin code:
from django.contrib import admin
from customers.models import Customer
from customers.models import CustomerChannel
class CustomerChannelInline(admin.TabularInline):
model = CustomerChannel
extra = 0
def has_delete_permission(self, request, obj=None):
return False
class CustomerAdmin(admin.ModelAdmin):
inlines = [CustomerChannelInline]
raw_id_fields = ('user',)
admin.site.register(Customer, CustomerAdmin)
The admin UI should look something like this:
Whenever a staff member needs to deactivate or reactivate one or more channels, they just use the active
checkboxes in the corresponding inline forms.
But there is a catch. Your company has a policy that requires to have an audit log of all the deactivations/reactivations: when did the operation happen and who did it. How do we solve it?
Step 1 - A custom ModelForm
First of all we need to abstract the operation as a method on the CustomerChannel
model, so we can invoke it and let it do the necessary business logic. Something like this should work:
class CustomerChannel(models.Model):
# ...
@transaction.atomic
def change_active(self, operated_by, new_value):
self.active = new_value
self.save()
self.log_active_change(operated_by, new_value)
def log_active_change(self, operated_by, activated):
"""
Log the deactivation/reactivation in a separate table that tracks
all these operations.
"""
Next we need a custom ModelForm to handle this. On save, instead of just setting/unsetting the active flag, we can instead call the change_active
method on the channel instance.
The form also needs to know what user is responsible for the change, so we need to pass it to the form constructor.
from django import forms
from customers.models import CustomerChannel
class CustomerChannelForm(forms.ModelForm):
class Meta:
model = CustomerChannel
fields = ('active', 'name')
def __init__(self, *args, **kwargs):
self.staff = kwargs.pop('staff')
super(CustomerChannelForm, self).__init__(*args, **kwargs)
def save(self, commit=True):
channel = super(CustomerChannelForm, self).save(commit=False)
if 'active' in self.changed_data:
channel.change_active(self.staff, self.cleaned_data['active'])
else:
channel.save()
Step 2 - Use the custom ModelForm with the inline
Admin inlines allow us to set the form to be used. The problem is that we can't just set the form
attribute on the inline to CustomerChannelForm
because the admin will fail to instantiate it, due to the required keyword argument staff=
.
Unfortunately there is no way to tweak the form instantiation, so our only option seems to be an override for the get_formset
method on the inline. This option is not as straightforward as it looks like, because get_formset
needs to return a FormSet subclass and not a FormSet instance.
Also, currently, there is no way to pass form arguments to a formset class. This was addressed in Django 1.9, which did not reach the final version as of yet. If you're stuck on an older version of Django than 1.9 you can backport the changes from PR#4757.
In our case we can do the following:
from django.utils.functional import cached_property
from django.forms.models import BaseInlineFormSet
class FormArgumentsPassingInlineFormSet(BaseInlineFormSet):
"""
Backport from Django 1.9 the functionality to pass form kwargs to formsets.
After upgrading to Django 1.9, you can safely remove this class.
"""
def __init__(self, *args, **kwargs):
self.form_kwargs = kwargs.pop('form_kwargs', {})
super(FormArgumentsPassingInlineFormSet, self).__init__(*args, **kwargs)
@cached_property
def forms(self):
forms = [self._construct_form(i, **self.get_form_kwargs(i))
for i in range(self.total_form_count())]
return forms
def get_form_kwargs(self, index):
return self.form_kwargs.copy()
@property
def empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
**self.get_form_kwargs(None)
)
self.add_fields(form, None)
return form
Next we need to make the inline to pass the currently logged-in staff in form_kwargs
keyword argument to the FormSet constructor. The challenge comes from the fact that we need to return a FormSet class from get_formset
. We also need to make sure all other options that are being passed from the inline admin ar kept (for example fields
or excludes
, readonly_fields
, e.t.c.)
To keep all other inline admin customizations and also override the default BaseInlineFormSet
we must first call the super()
implementation and override the formset
keyword argument.
Next we need to tweak the returned InlineFormSet class to always pass the logged-in staff in the form_kwargs
keyword argument upon instantiation. That can be done by overriding __new__
on the FormSet class.
Here is the implementation for the get_formset
method that accomplishes that.
class CustomerChannelInline(admin.TabularInline):
model = CustomerChannel
form = CustomerChannelForm
extra = 0
def has_delete_permission(self, request, obj=None):
return False
def get_formset(self, request, obj=None, **kwargs):
# Get the default generated InlineFormSet
kwargs['formset'] = FormArgumentsPassingInlineFormSet
InlineFormSet = super(CustomerChannelInline, self).get_formset(request, obj, **kwargs)
# Tweak the formset class so that it will receive the currently
# logged-in staff on instantiation as a keyword argument for the forms
class RequestProvidingInlineFormSet(InlineFormSet):
def __new__(cls, *args, **kwargs):
kwargs['form_kwargs'] = {'staff': request.user}
return InlineFormSet(*args, **kwargs)
return RequestProvidingInlineFormSet
That's it! Whenever get_formset
is called by ConsumerAdmin.get_formsets_with_inlines
, we dynamically create an InlineFormSet subclass that will tweak the instantiation of the original InlineFormSet to pass the currently logged-in user.
The formset will pass the arguments in form_kwargs
to all the forms that are being instantiated (which are of the CustomerChannelForm
type). Finally, the form instances will handle the custom business logic of changing the active flag.