Better UX Django Admin on M2M

Joseph Youngquist
7 min readMay 20, 2023

--

How to get the Edit User Groups: Widget from both directions of the m2m field

With my consulting work, I recently inherited a 10+ year old Django app — which goes to show the testament for Python stability as well as Django’s and the previous Software Developers work. This app didn’t see much love over the past few years and I was brought in to try and get things updated and hopefully less brittle when folks try to do development on it. The brittleness was due to the tests that were written for the software hadn’t been able to run and updating the software dependencies being a game of chance. Getting the app into a place where the tests run and updates could be performed is a story (or a few) for another day.

Today, we’re here to implement a “quality of life” enhancement for the folks who Administrate the above mentioned app. For those looking to get to the code and bypass the journey, scroll down to the “The Fixed Code”. For the fellow traveler who enjoys the journey, read on my friend. Read on.

I’ve boiled this down to use Django’s documented “Pizza” example that demonstrates how to use and setup ManyToManyField relationships:

from django.db import models


class Topping(models.Model):
# ...
pass


class Pizza(models.Model):
# ...
toppings = models.ManyToManyField(Topping)

Backstory: AKA — “the problem”

However, for my story there’s a bit of a twist — the relationship direction was on the Topping model, which isn’t to intuitive. I’m sure at the time of the other Developers, this direction for the relationship may have made more sense, but now that all of those folks are gone and there’s no documentation or Software Design Docs, your guess is as good as mine.

from django.db import models


class Topping(models.Model):
name = models.CharField(...)
pizza = models.ManyToManyField(Pizza)

class Pizza(models.Model):
...

Adding a widget like the lead in screen grab shows above is extremely easy to implement for this Topping model within the Django Admin:

@admin.register(Topping)
class ToppingAdmin(admin.ModelAdmin):
filter_horizontal = ("pizza", )

And, Bam! You’ve got a great interface to add a pizza to each of your toppings…Not the ideal user experience but at least you can filter the available pizza’s 🙄

The Problem

The User Experience from the Pizza side of the relationship isn’t as easy to implement. Let’s start with what seems to be the road most traveled in setting up the Admin for such relationships:

class ToppingsInline(admin.TabularInline):
model = Topping.pizza.through
extra = 0


@admin.register(Pizza)
class PizzaAdmin(admin.ModelAdmin):
inlines = [ToppingsInline,]

The above setup yields the following User Experience:

The above leaves something to be desired.

Add another Topping-pizza relationship

This doesn’t present an experience that’s easy to work with either of the “inline” options:

TabularInline

Or For a StackedInline

StackedInline

In this example, the dropdown list is pretty manageable with only a few options, but imagine a lot more options (and they were not sorted!) let alone having to select one, then click on “Add another Topping-pizza relationship”, scroll, click…rinse and repeat for all teh things!

Okay. Great! I’ll just add filter_horizontal = ('Topping',) and…

Which prompts us to look at the log…

The next thing that’s likely to cross your mind is: “Just move the ManyToManyField()“ from Topping to Pizza. But you’ll remember that you have thousands of relationships in the database already (remember the 10+ year old software?) The next thing you’ll do, is start searching the usual stomping grounds for a solution. Someone has had to have solved this before. Right?!

Maybe I’m just a horrible proompt engineer but I couldn’t find a “good” solution. There’s no “maybe”, I just suck at it but here’s some of the results I found:

And

Which both were pointing me to:

and I ran into this while still trying to get things to work on Django 4.2

Which made my brain hurt and to further my “imposter syndrome” issues.

I was able to get “close” to what I wanted:

Can you spot it?

For one, when I “Saved” the Chosen toppings (right side list) didn’t save (persist to the database). The second, and this is a subtle one, there’s no adding of a new Topping from the Pizza edit interface — aka “the green + button” is missing.

So issue one is bad enough, but every designer will tell you issue 2 is just unthinkable!

By now my Software Engineer Spidey Senses were tingling. I knew I had made a wrong turn in Albuquerque.

from: https://maddogmedia.com/2014/08/03/unreal-estate/

Other than the fact that trying to implement some of the above approaches either: didn’t work at all in Django 4.2 or at best came close to the right User Experience, you know, other than not saving to the database :) So, back to the Django Docs I spelunked. Now don’t get me wrong Django documentation is by in large, great. But in this case, the Magic 8-ball — “future looks bleak” was all that I was coming up with.

The Solution

What I needed was a way to add the relationship to the Pizza model AND keep the existing database table. That’s not so much to ask for right?! The miles of documentation pages that I read had the solution, on this page on ManyToMany Options, I just didn’t understand what it was telling me.

I understood that Django automatically generates a table in the database to manage the relationship map between objects, and the previous developers had specified related_name as well as related_query_name options but the values were just what Django would already default to calling things anyhow. But I knew there was something still there that was pointing me on the right path.

Above in the code for implementing the TabularInline

class ToppingsInline(admin.TabularInline):
model = Topping.pizza.through
extra = 0

I saw the through that was being used and I absolutely did not want to touch this with a database migration (I’ve got bad history with Django Migrations). And this piece of the documentation was bugging me because I didn’t fully understand it and mostly I didn’t read it all since it started off with “Recursive relationships” — but I wasn’t dealing with “Recursive relationships” — I told myself (wrongly):

And then it hit me. I was dealing with a recursive relationship, just not the kind I of recursive I had in mind. I was thinking of the model pointing back to itself, and I was thinking that my case was more of a circular issue. But the key here is this:

If you’d prefer Django not to create a backwards relation, set related_name to '+'

The Fixed Code

Here’s what the final code is to get what I wanted.

models.py:

class Pizza(models.Model):
name = models.CharField(max_length=50, unique=True)
toppings = models.ManyToManyField(
Topping,
through = Topping.pizza.through,
related_name='+',
blank = True
)

admin.py:

@admin.register(Pizza)
class PizzaAdmin(admin.ModelAdmin):
filter_horizontal = ("toppings", )

And the final result in the Admin interface, the UX I was looking for:

Finally!

Final Thoughts

Software Engineering isn’t a linear path, especially when learning something. The final code commits for this change are next to nothing, just like the final equation

It doesn’t seem that complicated on the surface, but the level of understanding to get to that point is volumes! I don’t work in Django on a daily basis and the last time I looked at the framework was when it was on version 2.x or something. Software Engineering isn’t about what you know, it’s largely about what you don’t. It relies on previous lessons learned, what concepts and ideas others have tried to explain and provide as common means to communicate just like Physics and Engineering have Mathematics as the common means to communicate ideas, but us poor Software Developers have just words most of the time.

I hope the above helps the next person — if nothing else, it’ll help me the next time 🤓

--

--

Joseph Youngquist
Joseph Youngquist

Written by Joseph Youngquist

Veteran to Digital Media publishing, Software Engineering and Architecture starting on a pivot to Unity Development

No responses yet