Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Grokzen/a64321dd69339c42a184 to your computer and use it in GitHub Desktop.
Save Grokzen/a64321dd69339c42a184 to your computer and use it in GitHub Desktop.
Symmetrical ManyToMany Filter Horizontal in Django Admin
# Based on post from: https://snipt.net/chrisdpratt/symmetrical-manytomany-filter-horizontal-in-django-admin/#L-26
# Only reposting to avoid loosing it.
"""
When adding a many-to-many (m2m) relationship in Django, you can use a nice filter-style multiple select widget to manage entries. However, Django only lets you edit the m2m relationship this way on the forward model. The only built-in method in Django to edit the reverse relationship in the admin is through an InlineModelAdmin.
Below is an example of how to create a filtered multiple select for the reverse relationship, so that editing entries is as easy as in the forward direction.
IMPORTANT: I have no idea for what exact versions of Django this will work for, is compatible with or was intended for.
I am sure this have stopped working slightly for new:er versions of Django. I do not use this myself currently with any new code so i can't really tell for sure.
!! Use code at your own risk !!
"""
### pizza/models.py ###
from django.db import models
class Pizza(models.Model):
name = models.CharField(max_length=50)
toppings = models.ManyToManyField(Topping, related_name='pizzas')
class Topping(models.Model):
name = models.CharField(max_length=50)
### pizza/admin.py ###
from django import forms
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from django.contrib.admin.widgets import FilteredSelectMultiple
from .models import Pizza, Topping
class PizzaAdmin(admin.ModelAdmin):
filter_horizonal = ('toppings',)
class ToppingAdminForm(forms.ModelForm):
pizzas = forms.ModelMultipleChoiceField(
queryset=Pizza.objects.all(),
required=False,
widget=FilteredSelectMultiple(
verbose_name=_('Pizzas'),
is_stacked=False
)
)
class Meta:
model = Topping
def __init__(self, *args, **kwargs):
super(ToppingAdminForm, self).__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields['pizzas'].initial = self.instance.pizzas.all()
def save(self, commit=True):
topping = super(ToppingAdminForm, self).save(commit=False)
if commit:
topping.save()
if topping.pk:
topping.pizzas = self.cleaned_data['pizzas']
self.save_m2m()
return topping
class ToppingAdmin(admin.ModelAdmin):
form = ToppingAdminForm
admin.site.register(Pizza, PizzaAdmin)
admin.site.register(Topping, ToppingAdmin)
@StevenLimpert
Copy link

Thanks!

@dickermoshe
Copy link

This does not work on create as topping wont have a pk

@Grokzen
Copy link
Author

Grokzen commented Jun 1, 2023

@dickermoshe I have not run or used this gist since it was created so i can't say if it is broken or not when running in any new django or python version. If you have a patch to make it work, please post it and i can update the gist with it

@seguri
Copy link

seguri commented Jan 29, 2024

I think the "Calendar Events" below are as broken as editing pizzas from a toppings record.

I have

class Technique:
    videos = models.ManyToManyField(Video, related_name="techniques", blank=True)

class CalendarEvent:
    techniques = models.ManyToManyField(Technique, related_name="calendar_events", blank=True)

In the screenshot you can see:

class TechniqueAdmin:
    filter_horizontal = ("videos",)
    # Manages the reverse relation: technique.calendar_events
    form = TechniqueAdminForm

Videos are managed inside Techniques and displayed through a filter_horizontal, so everything works correctly. CalendarEvents are displayed through a manually defined form like ToppingAdminForm and the resulting HTML is different from the preceding row.
Do you know how to fix this?

image

@bgaudino
Copy link

@seguri The resulting HTML is different because Django wraps the widget with RelatedFieldWidgetWrapper. https://github.com/django/django/blob/0a560eab550696dbc163d57258ef6f3cdb9511a3/django/contrib/admin/options.py#L213

I got around this by subclassing FilteredSelectMultiple and specifying a custom template

class ReverseFilterSelectMultiple(FilteredSelectMultiple):
    template_name = "admin/widgets/reverse_filter_select_multiple.html"

@bgaudino
Copy link

Or better yet, just use RelatedFieldWidgetWrapper. That way you would get the plus icon to add new model instances. I was having issues at first, but I think something along these lines would work.

from django.contrib import admin
from django.contrib.admin.widgets import FilteredSelectMultiple, RelatedFieldWidgetWrapper
from django.db import ManyToManyRel, ManyToManyField

RelatedFieldWidgetWrapper(
    FilteredSelectMultiple(
        verbose_name='Calendar Events',
        is_stacked=False,
    ),
    rel=ManyToManyRel(
        field=ManyToManyField(Video),
        to=CalendarEvent,
        through=CalendarEvent.through,
    ),
    admin_site=admin.site,
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment