-
-
Save Grokzen/a64321dd69339c42a184 to your computer and use it in GitHub Desktop.
# 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) |
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."
add this
class Meta:
model = Topping
fields = '__all__'
and change this:
if topping.pk:
topping.pizzas.set(self.cleaned_data['pizzas'])
self.save_m2m()
Hey brother, you saved my life. Thanks!
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!
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 _
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
.
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...
Thanks!
This does not work on create as topping wont have a pk
@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
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?
@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"
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,
)
Brilliant! Just what I was looking for. Thanks!
Just one correction. Needs a
fields
orexclude
attribute (I used emptyexclude
list) in Django 2.0 and later.