Achieving Symmetrical ManyToMany Filtering in Django Admin

I recently tackled an interesting challenge that I think could benefit others in the community. The problem? Creating a symmetrical ManyToMany filter widget in the Django admin dashboard. You know, the kind where you can filter and select related objects from both sides of the relationship with the same neat horizontal filter interface.

The Problem

Let's say you have a CompanyStructure model with a ManyToMany relationship to Django's CustomUser. By default, Django admin gives you a nice horizontal filter widget on the CompanyStructure side, but not on the CustomUser side. This creates an inconsistent user experience when managing these relationships.

The Solution

After some digging into Django's internals, I found an elegant solution using Django's RelatedFieldWidgetWrapper and a custom ModelForm. Here's how to implement it:

1. First, let's look at our models:

from django.contrib.auth.models import AbstractUser
from django.db import models
import treenode.models as treenode_models

class CustomUser(AbstractUser):
    objects = CustomUserManager()

    def __str__(self):
        return self.username

class CompanyStructure(treenode_models.TreeNodeModel):
    name = models.CharField(max_length=255)
    users = models.ManyToManyField(
        CustomUser, 
        related_name='company_structures', 
        blank=True
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.name

2. The Magic: Custom Form with RelatedFieldWidgetWrapper

Here's where the magic happens. We'll create a custom form that adds the horizontal filter widget:

from django import forms
from django.contrib import admin
from django.db import models
from django.contrib.admin import widgets

class CustomUserForm(forms.ModelForm):
    company_structures = forms.ModelMultipleChoiceField(
        queryset=CompanyStructure.objects.all(),
        widget=widgets.RelatedFieldWidgetWrapper(
            widget=widgets.FilteredSelectMultiple('company structures', False),
            rel=models.ManyToManyRel(
                field=CustomUser,
                to=CompanyStructure,
                through=CustomUser.company_structures.through
            ),
            admin_site=admin.site
        )
    )

    class Meta:
        model = CustomUser
        fields = '__all__'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Initialize with existing relations
        if self.instance and self.instance.pk:
            self.fields['company_structures'].initial = (
                self.instance.company_structures.all()
            )

    def save(self, commit=True):
        instance = super().save(commit=False)
        if commit:
            instance.save()
        # Save the ManyToMany relations
        instance.company_structures.set(self.cleaned_data['company_structures'])
        return instance

3. Register with Admin

Finally, we hook it all up in the admin:

@admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
    form = CustomUserForm
    # ... other admin configurations ...

See how it looks

How It Works

Let's break down the key components that make this solution work:

  1. RelatedFieldWidgetWrapper: This is the secret sauce. It's the same widget Django uses internally for related fields, but we're explicitly implementing it for our reverse relationship.

  2. FilteredSelectMultiple: This creates the actual horizontal filter interface with search functionality.

  3. ManyToManyRel: This tells Django about the relationship type and provides necessary metadata for the widget to function properly.

Key Benefits

  • Consistent UI: Users get the same filtering experience from both sides of the relationship

  • Better UX: The horizontal filter widget is more user-friendly than a basic select field

  • Search Functionality: Built-in search makes it easier to find specific items in large datasets

Potential Gotchas

  1. Performance: With large datasets, you might want to optimize the queryset in the ModelMultipleChoiceField

  2. Form Initialization: Make sure to handle the initial values correctly in the __init__ method

  3. Saving: Don't forget to implement the save method to handle the M2M relationship properly

Attention: Django 5.1+

Since we override the form class, we might want to bring back this beauty

Complete Code: https://gist.github.com/Kenan7/0a5169242f13b95acd362e5d7a841607

In Conclusion

This solution provides a clean, symmetrical interface for managing ManyToMany relationships in Django admin. While it requires a bit more code than the default setup, the improved user experience makes it worth the effort.

Remember to adapt the code to your specific needs and consider adding any necessary validation or customization to match your project's requirements.

0
Subscribe to my newsletter

Read articles from Mirkenan Kazımzade directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mirkenan Kazımzade
Mirkenan Kazımzade