Adding a ManyToManyWidget to the reverse of a ManyToManyField in the Django Admin
Asked Answered
D

5

37

Let's say I have a simple blog app in Django 1.4:

class Post(models.Model):
    title = …
    published_on = …
    tags = models.ManyToManyField('Tag')

class Tag(models.Model):
    name = …

i.e. a post has many tags. On the Django admin, I get a nice little <select multi> if I include tags in the fields for the PostAdmin. Is there an easy way to include the list of the posts (as a simple <select multi>) in the TagAdmin? I tried putting fields = ['name', 'posts'] in the TagAdmin and got an ImproperlyConfigured error. (same result for post_set).

I'm alright with Django, so could whip up a proper AdminForm and Admin object, but I'm hoping there a Right Way™ to do it.

Durfee answered 26/3, 2012 at 20:53 Comment(3)
are you looking for inline edits? docs.djangoproject.com/en/dev/ref/contrib/admin/…Floribunda
You can set up intermediary model using through attribute and set up few inlines in Admin. But that is far from beautiful solution. Take a look at this ticket: code.djangoproject.com/ticket/897Kannada
I'm looking for the same thing -- seems simple enough. Did you ever find a solution?Nabataean
R
33

This is possible to do with a custom form.

from django.contrib import admin
from django import forms

from models import Post, Tag

class PostAdminForm(forms.ModelForm):
    tags = forms.ModelMultipleChoiceField(
        Tag.objects.all(),
        widget=admin.widgets.FilteredSelectMultiple('Tags', False),
        required=False,
    )

    def __init__(self, *args, **kwargs):
        super(PostAdminForm, self).__init__(*args, **kwargs)
        if self.instance.pk:
            self.initial['tags'] = self.instance.tags.values_list('pk', flat=True)

    def save(self, *args, **kwargs):
        instance = super(PostAdminForm, self).save(*args, **kwargs)
        if instance.pk:
            instance.tags.clear()
            instance.tags.add(*self.cleaned_data['tags'])
        return instance

class PostAdmin(admin.ModelAdmin):
    form = PostAdminForm

admin.site.register(Post, PostAdmin)

That False in there can be replaced with a True if you want vertically stacked widget.

Roca answered 31/1, 2014 at 12:30 Comment(4)
Um... Am I missing something, or is this totally missing the point of the OP? The point being "list of the posts in the TagAdmin". TagAdmin, not PostAdmin. The approach looks fine though.Ungrudging
Maybe swap TagAdmin and PostAdmin.Roca
Maybe. Also, save won't work this way for when adding a new object, because Django admin calls it with form.save(commit=False), thus no pk. Instead, move that code to 'TagAdmin.save_model(...)'.Ungrudging
Good call. I probably should actually test that code, rather than just write it in a browser though ;)Roca
P
14

A bit late to the party, but this is the solution that works for me (no magic):

# admin.py

from django.contrib import admin
from models import Post

class TagPostInline(admin.TabularInline):
    model = Post.tags.through
    extra = 1

class PostAdmin(admin.ModelAdmin):
    inlines = [TagPostInline]

admin.site.register(Post, PostAdmin)

Reference: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#working-with-many-to-many-models

Panchromatic answered 2/11, 2012 at 5:42 Comment(1)
Yes, but that doesn't really do what the question asks, which is for a select[multiple] widget.Roca
C
8

Modify your models to add reverse field:

# models.py
from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=100)
    published_on = models.DateTimeField()
    tags = models.ManyToManyField('Tag')

class Tag(models.Model):
    name = models.CharField(max_length=10)
    posts = models.ManyToManyField('blog.Post', through='blog.post_tags')

Then in standard way add field to ModelAdmin:

#admin.py
from django.contrib import admin

class TagAdmin(admin.ModelAdmin):
    list_filter = ('posts', )

admin.site.register(Tag, TagAdmin)
Condottiere answered 14/4, 2016 at 15:27 Comment(5)
This breaks migrations, syncdb etc. There is a hacky workaround: djangosnippets.org/snippets/1295 but I haven't tried it with the new Django migrationsUry
With Django 1.11, this actually works fine now, as far as I can tell. makemigrations generates a migration for the redundant M2M field, but it applies without problems.Jamesy
One usually desirable outcome with this solution is that the 'add another' (+) button is shown besides the reverse field, without any additional work as otherwise required (using ManyToManyRel, RelatedFieldWidgetWrapper).Kymry
Shouldn't it be in the docs or there is some caveat?Frog
I see high coupling. What else?Frog
T
4

Matthew's solution didn't work for me (Django 1.7) when creating a new entry, so I had to change it a bit. I hope it's useful for someone :)

class PortfolioCategoriesForm(forms.ModelForm):
    items = forms.ModelMultipleChoiceField(
        PortfolioItem.objects.all(),
        widget=admin.widgets.FilteredSelectMultiple('Portfolio items', False),
        required=False
    )

    def __init__(self, *args, **kwargs):
        super(PortfolioCategoriesForm, self).__init__(*args, **kwargs)
        if self.instance.pk:
            initial_items = self.instance.items.values_list('pk', flat=True)
            self.initial['items'] = initial_items

    def save(self, *args, **kwargs):
        kwargs['commit'] = True
        return super(PortfolioCategoriesForm, self).save(*args, **kwargs)

    def save_m2m(self):
        self.instance.items.clear()
        self.instance.items.add(*self.cleaned_data['items'])
Truthvalue answered 6/1, 2016 at 15:46 Comment(0)
F
4

You can add a symmetrical many to many filter this way.

Credit goes to https://gist.github.com/Grokzen/a64321dd69339c42a184

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)
Fendig answered 22/10, 2019 at 8:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.