Amandasaurus
Amandasaurus

Reputation: 60649

Adding a ManyToManyWidget to the reverse of a ManyToManyField in the Django Admin

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.

Upvotes: 37

Views: 8668

Answers (5)

Jonathan
Jonathan

Reputation: 8890

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)

Upvotes: 4

Adam Dobrawy
Adam Dobrawy

Reputation: 1215

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)

Upvotes: 8

Blaise
Blaise

Reputation: 13479

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'])

Upvotes: 4

Matthew Schinckel
Matthew Schinckel

Reputation: 35599

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.

Upvotes: 33

dyve
dyve

Reputation: 6013

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

Upvotes: 14

Related Questions