In the past we've written about improving search results in the Django admin. We could probably write a hundred articles on all the little tweaks and hacks we've done to improve the Django admin interface or experience for our clients. Each business has it's own requirements and we have to adapt to it. Luckily the Django admin is quite flexible and can usually bend just enough to fit the business need.
Recently we ran into an interesting issue with Django Admin, which was: how to add reverse inlines in the admin, easily, without repeating ourselves?
Reverse inlines? Yup. We want an inline but we want to reverse the direction in which an inline works. Typically inlines work by displaying a relationship, in the Django admin, from the "linked to" model to the "linked FROM" model.
Basically we want a ForeignKey
admin form to replace the typical ForeignKey
field. And we want it object oriented and without having to customize any admin
templates. You'll see what we mean in the example models below.
In this blog post we'll show you how to solve this, using two Mixin classes.
We'll assume that you know Django, which means, models, forms admin, etc. Feel free to check the docs before continuing if you want a refresher.
The most simple form to declare an ModelAdmin
in Django is using the
register shortcut: admin.site.register(ModelName)
, but sometimes you may
need to tweak the interface in order to extend how it looks, in that case you
have to inherit from the ModelAdmin
class:
class MyModelAdmin(admin.ModelAdmin):
pass
admin.site.register(MyModel, MyModelAdmin)
Django admin offers various methods to edit the auto generated layout, like the fieldsets attribute, or a fully custom Admin form, and this is generally enough. But in this case it's not.
Story time... Our client Surf For Life Inc. has a problem. They're wildly successful and have built up a ton of popular blog posts over the years. As a popular content site their editors need to locate specific pieces of content for SEO purposes. This content may be blog posts, images, blog categories, videos, etc.
It makes sense to add a model, we'll call it SEOFlag
, to set some desired
SEO attributes assigned to specific content. Now we'll set a ForeignKey
from
all models that need custom SEO override support to the new SEOFlag
model.
By default, your inlines would have you digging through SEOFlag
records to
find the correct one that is linked with the specific content piece you're
looking to update. That is a very tedious operation in the default Django
admin.
Let's hook up the Django admin so you can fully edit this SEO metadata from within the specific content you want to update.
Breaking the admin #
Let's look at some hypothetical code for the models and admins for the Surf For Life blog application. Let's start with the model class...
class Entry(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(User, on_delete=models.SET_NULL)
slug = models.SlugField(max_length=200)
body = models.TextField()
seo_flag = models.ForeignKey(
'SEOFlag',
on_delete=models.SET_NULL,
null=True,
blank=True,
)
class Category(models.Model):
name = models.CharField(max_length=50)
slug = models.SlugField(max_length=50)
seo_flag = models.ForeignKey(
'SEOFlag',
on_delete=models.SET_NULL,
null=True,
blank=True,
)
class SEOFlag(models.Model):
meta_description = models.CharField(
max_length=255,
blank=True,
null=True,
help_text='Custom Meta Description.',
)
meta_keywords = models.CharField(
max_length=255,
blank=True,
null=True,
help_text='Custom Meta Keywords.',
)
meta_robots = models.CharField(
max_length=255,
blank=True,
null=True,
help_text='Custom Meta Robots.',
)
As you may see the SEOFlag
model doesn't have a direct relationship with
other models. It's the parent models that are linking to it.
Let's look at the model admin...
class EntryAdmin(admin.ModelAdmin):
list_display = ('title', 'author')
prepopulated_fields = {'slug': ('title',)}
This is basic and pretty standard example of a blog post model and it's model
admin. What we're going to do is extend the EntryAdmin
with the fields from
the SEOFlag
model so then an editor can set the SEOFlag
content and the
Entry
content on the same form.
To do this, we need to write two Mixin, one for the ModelAdmin
, and other
for the Form
. Here's what it would look like...
# admin.py
from django.contrib import admin
from django.contrib.admin.utils import flatten_fieldsets
class SEOAdminMixin:
def get_form(self, request, obj=None, **kwargs):
""" By passing 'fields', we prevent ModelAdmin.get_form from looking
up the fields itself by calling self.get_fieldsets()
If you do not do this you will get an error from
modelform_factory complaining about non-existent fields.
"""
if not self.fieldsets:
# Simple validation in case fieldsets don't exists
# in the admin declaration
all_fields = self.form().fields.keys()
seo_fields = self.form().flag_fields.keys()
model_fields = list(
filter(
lambda field: field not in seo_fields, all_fields
)
)
self.fieldsets = [(None, {'fields': model_fields})]
kwargs['fields'] = flatten_fieldsets(self.fieldsets)
return super().get_form(request, obj, **kwargs)
def get_fieldsets(self, request, obj=None):
fieldsets = super().get_fieldsets(request, obj)
# convert to list just in case, its a tuple
new_fieldsets = list(fieldsets)
seo_fields = [f for f in self.form().flag_fields]
new_fieldsets.append(
('SEO', {'classes': ('collapse',), 'fields': seo_fields})
)
return new_fieldsets
This is going to collect all form fields from both the normal admin generated
form and the SEOFlag
form. You'll notice references to flag_fields
above
and may be curious where in the world that is coming from. Well, see the Form
Mixin below:
# forms.py
from django import forms
class SEOFlagAdminForm(forms.ModelForm):
class Meta:
model = SEOFlag
fields = '__all__'
class SEOFlagAdminFormMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
obj = kwargs.get('instance')
if obj and obj.seo_flag:
self.flag_form = SEOFlagAdminForm(
prefix='seo', instance=obj.seo_flag
)
else:
self.flag_form = SEOFlagAdminForm(prefix='seo')
self.flag_fields = self.flag_form.fields
# Here we extend the main form fields with the fields
# coming from the SEOFlag model
self.fields.update(self.flag_form.fields)
# Bump the initial data in all the SEOFlag fields
for field_name, value in self.flag_form.initial.items():
if field_name == 'id':
continue
self.initial[field_name] = value
def add_prefix(self, field_name):
"""
Ensure flag_form has a prefix appended to avoid field values crash on
form submit and also set prefix on the main form if it exists as well.
"""
if field_name in self.flag_form.fields:
prefix = (
self.flag_form.prefix
if self.flag_form.prefix
else 'seo'
)
return '%s-%s' % (prefix, field_name)
else:
return (
'%s-%s' % (self.prefix, field_name)
if self.prefix
else field_name
)
def save(self, commit=True):
instance = super().save(commit=False)
if instance.seo_flag:
seo_form = SEOFlagAdminForm(
data=self.cleaned_data,
files=self.cleaned_data,
instance=instance.seo_flag,
)
else:
seo_form = SEOFlagAdminForm(
data=self.cleaned_data, files=self.cleaned_data
)
if seo_form.is_valid():
seo_flag = seo_form.save()
if not instance.seo_flag:
instance.seo_flag = seo_flag
if commit:
instance.save()
return instance
Here's a breakdown of what this Mixin is doing:
- On the initialization of the class, add a new class attribute containing a
form instance from the
SEOFlag
model namedflag_form
. - If the current entry exists and contains a reference to the
SEOFlag
model, add the instance to theSEOFlag
form. - Update the main form with the auto generated fields from the
SEOFlag
form. - For each field in the
SEOFlag
model initial instance (if present), set the initial value on the main form. - Override the
add_prefix
method, in case you have other fields with the same name. - Override the
save
method to properly save theSEOFlag
instance.
Now to add those two new Mixin's to the ModelAdmin
and it's Form
:
# forms.py
class EntryAdminForm(SEOFlagAdminFormMixin, forms.ModelForm):
class Meta:
model = Entry
exclude = ['seo_flag']
Note that we're excluding the main reference from the parent model Entry
to
child model SEOFlag
to avoid having two references to the child model in the
same page.
# admin.py
from .forms import EntryAdminForm
class EntryAdmin(SEOAdminMixin, admin.ModelAdmin):
list_display = ('title', 'author')
prepopulated_fields = {'slug': ('title',)}
form = EntryAdminForm
There you have it. Now you have two forms in the same edit/create view in the
default admin for the Entry
model and without having to customize any admin
templates.
Wrapping up #
The Django admin is a powerful application and a huge selling point for the framework in general. The larger projects become, the more you want to customize the admin to fit the specific project needs. This is an example of the possibilities available to meet your desired workflow.
We hope this blog post helps you to build amazing admin layouts.
Have a response?
Start a discussion on the mailing list by sending an email to
~netlandish/blog-discussion@lists.code.netlandish.com.