"Reverse Inlines" in Django admin

September 3, 2020 • Oscar Cortez

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 named flag_form.
  • If the current entry exists and contains a reference to the SEOFlag model, add the instance to the SEOFlag 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 the SEOFlag 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.


blog comments powered by Disqus