36

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.

Amandasaurus
  • 58,203
  • 71
  • 188
  • 248
  • 1
    are you looking for inline edits? https://docs.djangoproject.com/en/dev/ref/contrib/admin/#working-with-many-to-many-models – Jingo Mar 26 '12 at 23:07
  • 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: https://code.djangoproject.com/ticket/897 – ilvar Mar 27 '12 at 02:47
  • 1
    I'm looking for the same thing -- seems simple enough. Did you ever find a solution? – Chris Forrette Jun 19 '12 at 20:35

5 Answers5

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.

Matthew Schinckel
  • 35,041
  • 6
  • 86
  • 121
  • 1
    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. – frnhr Sep 06 '14 at 23:06
  • Maybe swap TagAdmin and PostAdmin. – Matthew Schinckel Sep 08 '14 at 23:27
  • 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(...)'. – frnhr Sep 09 '14 at 00:00
  • Good call. I probably should actually test that code, rather than just write it in a browser though ;) – Matthew Schinckel Sep 09 '14 at 07:06
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

dyve
  • 5,893
  • 2
  • 30
  • 44
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)
Adam Dobrawy
  • 1,145
  • 13
  • 14
  • This breaks migrations, syncdb etc. There is a hacky workaround: https://djangosnippets.org/snippets/1295/ but I haven't tried it with the new Django migrations – Andy Baker Jul 25 '16 at 14:25
  • 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. – René Fleschenberg Nov 23 '17 at 15:43
  • 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`). – amolbk Jul 30 '20 at 10:49
  • Shouldn't it be in the docs or there is some caveat? – Denis Apr 14 '21 at 11:57
  • I see high coupling. What else? – Denis Apr 14 '21 at 12:11
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'])
Blaise
  • 13,139
  • 9
  • 69
  • 97
3

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)
Jonathan
  • 8,453
  • 9
  • 51
  • 74