Ticket #12203

Pull Request: https://github.com/django/django/pull/18612

Description

BaseModelAdminChecks prevents from setting a many-to-many field in the ModelAdmin fields list when the many-to-many relationship was configured with a custom through model (instead of one auto-generated by Django). Example:

# models.py ------------------------------------
class Author(models.Model):
    name = models.CharField(max_length=100)

class Book(models.Model):
    name = models.CharField(max_length=100)
    authors = models.ManyToManyField(Author, through='BookAuthor')
    
class BookAuthor(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE)
    author = models.ForeignKey(Author, on_delete=models.CASCADE) 
# admin.py --------------------------------------
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    fields = ['authors'] # <---- HERE. This would raise an AdminCheck error

Why was this AdminCheck Error set in the first place?

Before #6095 and #9475 were solved, custom through models weren’t initially supported in ModelAdmin and the through db operations wouldn’t work so we wanted to prevent developers from using a field in an Admin model that couldn’t be possibly be operated with in standard forms.

Why would we want have this new behavior?

  1. For symmetry with auto-generated m2m relationships (same usage of fields when configuring a ModelAdmin )
  2. Cleaner, simpler UI

Extended examples:

There’s a “basic & nice behavior” when a ManyToMany relationship doesn’t have a through= argument.

  1. Basic case, Django automatically renders the Authors: widget.
# models.py ------------------------------------
class Author(models.Model):
    name = models.CharField(max_length=100)
    
    def __str__(self) -> str:
        return self.name

class Book(models.Model):
    name = models.CharField(max_length=100)
    authors = models.ManyToManyField(Author)                 
    
# admin.py ------------------------------------
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    fields = ['name']
    list_display = ['name']

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    fields = ['name', 'authors']

👉🏻

The create/edit view of the BookAdmin model displays a widget to add existing Authors to the Book that’s being edited.

  1. Using a through model throws a SystemCheckError to alert developers this isn’t supported by Django (yet).
# models.py ------------------------------------
class Author(models.Model):
    name = models.CharField(max_length=100)
    
    def __str__(self) -> str:
        return self.name

class Book(models.Model):
    name = models.CharField(max_length=100)
    authors = models.ManyToManyField(Author, through='BookAuthor')
    
class BookAuthor(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)               
    
# admin.py ------------------------------------
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    fields = ['name']
    list_display = ['name']

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    fields = ['name', 'authors']

👉🏻

SystemCheckError: System check identified some issues. Errors: : (admin.E013) The value of 'fields' cannot include the ManyToManyField 'authors', because that field manually specifies a relationship model

  1. The current alternative is using InlineModelAdmin objects
# models.py ------------------------------------
class Author(models.Model):
    name = models.CharField(max_length=100)
    
    def __str__(self) -> str:
        return self.name

class Book(models.Model):
    name = models.CharField(max_length=100)
    authors = models.ManyToManyField(Author, through='BookAuthor')
    
class BookAuthor(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    
# admin.py ------------------------------------
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    fields = ['name']
    list_display = ['name']
# class BookAuthorsInline(admin.StackedInline):
class BookAuthorsInline(admin.TabularInline):
    model = Book.authors.through
    extra = 1

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    fields = ['name']
    list_display = ['name']
    
    inlines = [
        BookAuthorsInline,
    ]

👉🏻

With TabularInline: The list of authors is rendered in a table with “Author” as table Header and a row per additional author row. Each row contains a dropdown to pick an author and a few buttons to edit, add another and view.

With StackedInline: The list of authors is just slightly different; each row has it’s own row header “Book author: #n”.