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)
@n6151h
Copy link

n6151h commented Aug 20, 2020

Brilliant! Just what I was looking for. Thanks!

Just one correction. Needs a fields or exclude attribute (I used empty exclude list) in Django 2.0 and later.

@ysinsane
Copy link

ysinsane commented Sep 2, 2020

Brilliant! Just what I was looking for. Thanks!

Just one correction. Need to use "topping.pizzas.set(self.cleaned_data['pizzas'])" in Django3.0 or get error: "TypeError: Direct assignment to the reverse side of a many-to-many set is prohibited. Use choices.set() instead."

@neoerwin
Copy link

neoerwin commented Apr 26, 2021

add this

class Meta:
    model = Topping
    fields = '__all__'

and change this:

if topping.pk:
    topping.pizzas.set(self.cleaned_data['pizzas'])
    self.save_m2m()

@answer-huang
Copy link

Hey brother, you saved my life. Thanks!

@mike-allaway
Copy link

thanks so much for this, it's great. Though it's disappointing that it's not a standard feature of Django. I mean, it feels like such a basic feature... Anyway thanks again!

@DidibaDEV
Copy link

DidibaDEV commented Dec 24, 2021

BEAUTIFUL! I followed @neoerwin's corrections on the comments and got it working on Django 4 too!

Thank you so much, you two!

Also, ugettext_lazy doesn't work anymore (deprecated on Django 3), so replace it with gettext_lazy :

from django.utils.translation import gettext_lazy as _

@mnieber
Copy link

mnieber commented Mar 27, 2022

Thanks, this really helped me!

Note that if you define fields on ToppingAdmin and not on ToppingAdminForm then you can leave out the class Meta.

@zvolsky
Copy link

zvolsky commented Jun 22, 2022

Looking for a admin filter on the reverse side. So I am on the half way (of impossible?). But this one is great. And probably such filter can be written based on this and hints for writing of custom filters...

@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