Building Django admin inlines for non-related models
Django's admin app is an incredible piece of software, making it trivial to do 99% of what you'd expect a CRUD admin interface to do with minimal code. Unfortunately there's always that 1% of cases when you need to step outside the pattern, and then it's down the rabbit hole of digging through source code, messing with private interfaces, and inevitable tears.
Recently a client asked me to experiment with implementing an admin inline view for a third party app where records are linked via natural keys but no explicit foreign key relationship exists between models. It turns out not only is this possible with minimal tears, it can also be applied in a more generic way to allow arbitrary relationships between any two models.
The code is available on GitHub.
The players
There are four components at work when dealing with inline admin relationships:
- The inline model admin class (I'll use
admin.StackedInline
for this example). This you're probably already familiar with. It's a more specialized case of a regularModelAdmin
that has configuration options for related models. - An instance of
admin.checks.InlineModelAdminChecks
. This class basically checks to make sure a related model instance and its parent do actually have a relationship. It's part of Django's system checks framework, and we're going to fool it. - A formset for handling creation of non-related inline objects. This will be a subclass of
forms.models.BaseModelFormSet
. The base formset makes assumptions about FK relationships, so we're going to override those. - A formset factory for building classes of the formset in the previous step with a few slight modifications, mostly to pass things from our inline admin on to our formset class.
The inline admin
The goal here is to move as much of the configuration for this relationship into the inline admin subclass. Where an explicit FK relationship would know exactly how to fetch related models and save or update related models, we'll create methods here to allow subclasses to define that behavior. The interface will look like this:
class NonrelatedStackedInline(admin.StackedInline):
def get_form_queryset(self, obj):
raise NotImplementedError()
def save_new_instance(self, parent, instance):
raise NotImplementedError()
Here the get_form_queryset
, given a parent object, should return all related child instances. On the other side, save_new_instance
should set up the implicit relationship between child instances whenever a form is saved.
To give you an example, let's say we have a third-party app that handles billing and contains Invoice
objects. Due to circumstances beyond our control these invoices don't have FKs to our User
model. Instead, we know an Invoice
belongs to a User
if they share the same email address. Now if I want to create an Invoice
belonging to a User
, I just need to make sure I set the email address of that Invoice
to whatever the user's email is, so my subclass would look like:
class UserInvoiceStackedInline(NonrelatedStackedInline):
model = Invoice
def get_form_queryset(self, obj):
return self.model.objects.filter(email=obj.email)
def save_new_instance(self, parent, instance):
instance.email = parent.email
And that's it. Pretty straightforward.
Now that we have our interface defined, we need to make sure our lookup and save methods are called in the correct places. This is where things get a little tricky.
The inline base class has a method get_formset
that is called as part of the form lifecycle. Normally get_formset
ends up calling the built-in inlineformset_factory
with the parent model, child model, and a bunch of other configuration options, but this won't work in our case because the default formset makes too many assumptions about FK relationships. We'll modify the get_formset
method to call a factory for a custom formset we'll define later, and use this opportunity to query get_form_queryset
and pass that along. We'll pass our save_new_instance
method as well so the formset save method will have access to this as a callback.
class NonrelatedStackedInline(admin.StackedInline):
...
def get_formset(self, request, obj=None, **kwargs):
...
defaults = {
...
} # This is all from the parent class method
# Pass our custom queryset on to the formset factory
queryset = self.model.objects.none()
if obj:
queryset = self.get_form_queryset(obj)
defaults['queryset'] = queryset
return nonrelated_inlineformset_factory(
self.model,
save_new_instance=self.save_new_instance,
**defaults
)
Our formset factory now has everything it needs to deal with these two models.
The formset and formset factory
Our goals for our custom formset are to 1) override the queryset used to look up instances, and 2) override the save method so it uses the callback we've defined in our inline admin.
For the first, a BaseModelFormset
has a queryset
attribute that it uses to find relevant instances, but unfortunately it's overwritten during __init__
in a complicated way and we need to be a little creative here. What I ended up doing was using the factory method to define what we want to queryset attribute to look like after initialization and then setting it explicitly once the parent class __init__
method is finished. That looks like this:
def nonrelated_inlineformset_factory(
model, obj=None,
queryset=None,
formset=NonrelatedInlineFormSet,
save_new_instance=None,
**kwargs
):
FormSet = modelformset_factory(model, formset=formset, **kwargs)
FormSet.real_queryset = queryset
FormSet.save_new_instance = save_new_instance
return FormSet
We're essentially attaching our desired queryset as a class attribute. I've also taken the opportunity to attach our save callback.
Now to set the queryset
attribute:
class NonrelatedInlineFormSet(BaseModelFormSet):
def __init__(self, instance=None, save_as_new=None, **kwargs):
self.instance = instance
super().__init__(**kwargs)
self.queryset = self.real_queryset
Slightly hacky, yes, but our formset will now use whatever we've specified in the inline's get_form_queryset
method instead of the default FK lookup. In addition we also have a reference to the parent instance we can use when it comes time to save related instances later.
The last piece for forms is saving new instances. To do this we'll just override the built-in save_new
method and have it call the save_new_instance
callback we also defined in our admin inline:
class NonrelatedInlineFormSet(BaseModelFormSet):
...
def save_new(self, form, commit=True):
obj = super().save_new(form, commit=False)
self.save_new_instance(self.instance, obj)
if commit:
obj.save()
return obj
If you try to run this as-is, Django will still complain that there's no FK relationship between these models, so let's fix that next.
The checks object
If you look at the Django source code for InlineModelAdmin
, it has a checks_class
attribute that is set to InlineModelAdminChecks
. This class performs quite a few sanity checks on an inline object, most of which are still applicable to non-related inlines such as whether or not certain configuration values are the right type, the formset has the right base class, etc. There are two in particular, however, that we want to override:
class NonrelatedInlineModelAdminChecks(InlineModelAdminChecks):
def _check_exclude_of_parent_model(self, obj, parent_model):
return []
def _check_relation(self, obj, parent_model):
return []
These two checks examine the relationship between an inline object and its parent model, and I've overriden them to simply return an empty array indicating all is well, since as far as Django can tell there is no actual relationship here. Now we need to attach that checks class to our inline admin class:
class NonrelatedStackedInline(admin.StackedInline):
checks_class = NonrelatedInlineModelAdminChecks
...
That's it, we now have the 4 components necessary to deal with non-related inline objects.
A full example
Continuing with the user and invoice example above, I would most likely want my inline admin to look like this:
class CustomerInvoiceStackedInline(NonrelatedStackedInline):
model = Invoice
fields = [
'id',
'amount'
...
]
def get_form_queryset(self, obj):
return self.model.objects.filter(email=obj.email)
def save_new_instance(self, parent, instance):
instance.email = parent.email
I've included an explicit fields
list so that the email field is hidden. It's not the end of the world if it shows up on the form, as it will just be reset to the correct value on the next save, but it should be implicit and is unnecessary to display.
Generalizing this pattern
To make this more useful, I've packaged these components into a PyPI release along with some unit tests against different Python and Django versions since we are mucking around a bit with private interfaces which could change in future releases. I'll include writeup on how these were tested in a future article.