Related ManyToManyField in Django admin site

Published byGoogle on

Hello everyone. There is something quite common people are trying to do in Django which is accessing a ManyToMany relationship from both ends in Django admin.

Since this is something I am going to have to explain to someone tomorow, I thought I'd take the opportunity to write a short tutorial about it.

Let's start with a pretty usual use-case scenario: books and authors. Here is what your models.py looks like:

from django.db import models


class Author(models.Model):

  name = models.CharField(max_length=255)

  def __unicode__(self):
    return self.name


class Book(models.Model):

  name = models.CharField(max_length=255)
  author = models.ManyToManyField(Author, blank=True, null=True, related_name='books')

  def __unicode__(self):
    return self.name

And your admin.py:

from django.contrib import admin

from related_m2m.models import Book, Author

admin.site.register(Author)
admin.site.register(Book)

Now, this is what the admin form for Book looks like. And one would expect to be able to have the same ManyToMany field on the  Author form.

Django doesn't automatically add "related" ManyToMany relationships to the admin by default. Since you can access books associated to an author using my_author_object.books.all(),  the intuitive way to achieve that would be to create a ModelAdmin for Author and to add a 'books' field to its default fieldset. Like the following:

from django.contrib import admin

from related_m2m.models import Book, Author

class AuthorAdmin(admin.ModelAdmin):

  fieldsets = (
    (None, {'fields': ('name', 'books')}),
  )

admin.site.register(Author, AuthorAdmin)
admin.site.register(Book)

While it is correct, this is not enough. The FormFactory which creates ModelForm's for the admin doesn't create fields for related ManyToMany relationships so we are going to have to create a custom form ourselves. In this form we will add a ModelMultipleChoiceField and customize its init and save method in order to handle the data related to this field.

Here is our forms.py:

from django import forms
from django.contrib import admin
                                                               
from related_m2m.models import Author, Book                     
                                                               
class AuthorForm(forms.ModelForm):                             
                                                               
  books = forms.ModelMultipleChoiceField(                      
    Book.objects.all(),                                        
# Add this line to use the double list widget                  
#    widget=admin.widgets.FilteredSelectMultiple('Books', False),
    required=False,                                            
  ) 
    
  def __init__(self, *args, **kwargs):                         
    super(AuthorForm, self).__init__(*args, **kwargs)
    if self.instance.pk:
      #if this is not a new object, we load related books                                       
      self.initial['books'] = self.instance.books.values_list('pk', flat=True)
  
  def save(self, *args, **kwargs):                             
    instance = super(AuthorForm, self).save(*args, **kwargs)   
    if instance.pk:
      for book in instance.books.all():
        if book not in self.cleaned_data['books']:            
          # we remove books which have been unselected 
          instance.books.remove(book)
      for book in self.cleaned_data['books']:                  
        if book not in instance.books.all():                   
          # we add newly selected books
          instance.books.add(book)      
    return instance

 After which you have to update your admin.py file like this:

from django.contrib import admin

from related_m2m.models import Book, Author
from related_m2m.forms import AuthorForm

class AuthorAdmin(admin.ModelAdmin):

  form = AuthorForm

  fieldsets = (
    (None, {'fields': ('name', 'books')}),
  )

admin.site.register(Author, AuthorAdmin)
admin.site.register(Book)

Once you have done this, the ManyToMany relationship will also be available from the Author admin form.

Now some of you might have noticed there was something missing from that last screenshot. There is no plus sign next to the books field.
The way Django does it in "regular" admin forms is to wrap relationship fields widgets with a "special wrapper" to add the plus sign (and everything associated with it).
All we have to do to get the plus sign is to add this wrapper in our form as well. Here is the code to add to your forms.py:

# Add those two import lines at the top
from django.db.models.fields.related import ManyToManyRel
from django.contrib.admin.widgets import RelatedFieldWidgetWrapper

.
.
.

class AuthorForm(forms.ModelForm):
.
.
.
  def __init__(self, *args, **kwargs):                         
    super(AuthorForm, self).__init__(*args, **kwargs)
    if self.instance.pk:                                       
      self.initial['books'] = self.instance.books.values_list('pk', flat=True)
      # Add thos to lines to __init__
      rel = ManyToManyRel(Book)
      self.fields['books'].widget = RelatedFieldWidgetWrapper(self.fields['books'].widget, rel, admin.site)
.
.
.

With this last change you now have a ManyToMany field working exactly the same way either from the Book admin form or the Author admin form.

Later this week we will have the next tutorial in the Babbler series. In the mean time, have a great week everyone!

As always, you can browse the code for this tutorial on http://vc.lasolution.be/projects/snippets/repository.
The code is available for checkout on http://code.lasolution.be/snippets.
The branch associated with this tutorial is related_m2m

Edit 2014-10-05: For Django 1.5.9, 1.5.10, 1.6.6, 1.6.7 and 1.7, see this post.

Tutorial Snippet Django

Comments

There is an error in your forms.py. In the save() function you are referring to "instance", but this should be "self.instance".

After this change your solution works flawlessly. Thank you very much for posting this, I couldn't have figured it out myself!

By JJ - 3 years, 2 months ago Reply 

Hi,

you're welcome.

In the form I use instance which is the return value of super().save() which is the same as self.instance, so it shouldn't matter.

On what version of Django did you have trouble with this and what was the error message/flowed behaviour?

By Emmanuelle Delescolle - 3 years, 2 months ago Reply 

You are totally right. For some reason, I didn't assign the return value of super().save() to anything, so the error I got was: NameError: name 'instance' is not defined.

I do have another problem and that is the litle green plus sign that's created by the RelatedFieldWidgetWrapper. When I click on it, django responds with "Bad Request(400)", even in debug mode. The URL it redirects to seems perfectly valid (http://localhost:8000/admin/portfolio/book/add/?_to_field=id&_popup=1)

I'm using django 1.7.

By JJ - 3 years, 2 months ago Reply 

You are right too :-)

Django 1.7 wasn't officially out when this post was released and I hadn't tested it in 1.7 so far.
It does work as is in 1.6 (and 1.5) but I just tested it in 1.7 and I get the same error as you.
I am going to look into this, my guess is that since this uses some quite undocumented Django internals, changes in behavior from 1.6 to 1.7 might also be undocumented.

Thanks again for the feedback.

By Emmanuelle Delescolle - 3 years, 2 months ago Reply 

Ok, I found out why it doesn't work in Django 1.7 anymore.

I will make a post with a longer explanation later today but here is already a quick-fix:

create (and register) a BookAdmin class and override the to_field_allowed method like this:
def to_field_allowed(self, request, to_field):
return True

By Emmanuelle Delescolle - 3 years, 2 months ago Reply 

Wow, thank you very much for these quick and accurate responses. I have added the method "to_field_allowed" to the BookAdmin class, and I don't get the error anymore! You are a real django wizard.

I find it very satisfying that using your tweaks, a ManyToMany relation becomes truly symmetrical from the user's point-of-view. You should submit this as a patch to the django team!

(Well, in the case of Authors and Books it was probably fine the way it was. I'm using it for categories and portfolio-projects though. It's nice to be able to add projects to categories, while at the same time being able to add categories to projects)

By JJ - 3 years, 2 months ago Reply 

Thanks :-)

Yes, I'm working on a patch right now. I would say this is regression from a security patch introduced in 1.7/1.6.6/1.5.9.
What the new "to_field_allowed" field method does is to say whether it is ok to have a popup for that model in the admin. The quick fix I gave you is totally ok for the case illustrated in this post (since, yes, we know it is ok to show a popup with that specific model).

As far as the whole admin is concerned, it is a bit more complex than simply "return True" but I am in the process of creating a failing test case and a patch to submit the Django team.

Cheers!

By Emmanuelle Delescolle - 3 years, 2 months ago Reply 

The ticket with test and fix has been created (https://code.djangoproject.com/ticket/23604), feel free to triage (https://docs.djangoproject.com/en/1.7/internals/contributing/triaging-tickets/) if you feel up to it ;-)

By Emmanuelle Delescolle - 3 years, 2 months ago Reply 

FYI: the bugfix has been merged into master and it will be part of Django 1.7.1 (and 1.6.8, 1.5.11, 1.4.16)

By Emmanuelle Delescolle - 3 years, 2 months ago Reply 

Thanks for this writeup. It was very helpful. A suggestion - you can replace lines 24-31 in the save() method with instance.books = self.cleaned_data['books'] instead of explicitly doing all the add() and remove() calls. Cheers.

By Ethan McCreadie - 2 years, 3 months ago Reply 

Hi,

This was not possible at the time of writing, thanks for the update :-)

By Emmanuelle Delescolle - 2 years, 3 months ago Reply 

Awesome stuff helped me a lot thanks

By Anonymous - 9 months, 4 weeks ago Reply 

Post a comment