This might be a bit late, but I’ve been struggling with the same.
Considering the through
model can be automatically added (that is, its non-foreignkey fields have defaults), my models look like this:
from django.db.models import CharField
from django.db.models.base import Model
from django.db.models.fields import PositiveSmallIntegerField
from django.db.models.indexes import Index
from django_stubs_ext.db.models import TypedModelMeta
from ktools.django.utils.translation import gettext_safelazy as _
class NewsletterCategory(Model):
'Storing categories for the `Newsletter`s.'
name = CharField(verbose_name=_('name'), max_length=100)
description = CharField(verbose_name=_('description'), max_length=255)
sort_value = PositiveSmallIntegerField(
verbose_name=_('Sort value'), default=10, help_text=_(
'The value by which this item will get sorted in the list of ' +
'newsletter categories. Lower is better.'))
class Meta(TypedModelMeta):
verbose_name = _('Newsletter category')
verbose_name_plural = _('Newsletter categories')
ordering = ('sort_value', 'name')
indexes = [
Index(fields=['sort_value', 'name'], name='s')
]
def __str__(self):
return self.name
class User(AbstractUser):
slug = AutoSlugField(
verbose_name=_('Slug of the user'), max_length=_username_max_length,
unique=True, populate_from='username', slugify_function=slugify)
phone = PhoneNumberField(verbose_name=_('Phone number'), blank=True)
newsletter_categories = ManyToManyField(
to=NewsletterCategory, through='UsersettingsToNewslettercategories',
verbose_name=_US_NC_VERBOSE_PLURAL, blank=True)
class Meta(AbstractUser.Meta, TypedModelMeta):
abstract = False
ordering = ('-date_joined',)
class UsersettingsToNewslettercategories(Model):
user = ForeignKey(to=User, on_delete=CASCADE)
newslettercategory = ForeignKey(
verbose_name=NewsletterCategory._meta.verbose_name,
to=NewsletterCategory, on_delete=CASCADE)
date_changed = DateTimeField(
verbose_name=_('Date changed'), default=now, editable=False)
class Meta(TypedModelMeta):
verbose_name = _US_NC_VERBOSE
verbose_name_plural = _US_NC_VERBOSE_PLURAL
constraints = [
UniqueConstraint(
fields=['user', 'newslettercategory'],
name='unique-constraint')
]
def __str__(self):
return f'{self.user} -> {self.newslettercategory}'
Now, I’ve had to dive into metaclasses and stuff to get the original filter_horizontal
(or filter_vertical
) to display right, circumventing django-admin’s checks where it will refuse to display that widget for a ManyToManyField that has a through
specified. Brace yourselves, here comes my admin.py
that solves the problem:
from __future__ import annotations
from typing import Any, ClassVar
from django.contrib.admin.options import ModelAdmin
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.forms.fields import CharField, MultipleChoiceField
from django.forms.models import ModelForm, ModelFormMetaclass
from django.http.request import HttpRequest
from .models import User
# Metaclass typing is unavailable with static type checking, see:
# https://github.com/microsoft/pyright/issues/7311#issuecomment-1956995047
class MyUserAdminFormMetaclass(ModelFormMetaclass):
base_fields: ClassVar[dict[str, CharField | MultipleChoiceField]]
def __new__(
mcs: type[MyUserAdminFormMetaclass], name: str,
bases: tuple[type, ...], attrs: dict[str, Any]
) -> type[MyUserAdminFormMetaclass]:
new_class = super().__new__(mcs, name, bases, attrs)
new_class.base_fields['hacked_newsletter_categories'] = \
new_class.base_fields['newsletter_categories']
del new_class.base_fields['newsletter_categories']
return new_class
class MyUserAdminForm(ModelForm, metaclass=MyUserAdminFormMetaclass):
class Meta(object):
model = User
fields = (
'username', 'last_name', 'first_name', 'is_active', 'email',
'newsletter_categories')
widgets = dict(newsletter_categories=FilteredSelectMultiple(
verbose_name=User.newsletter_categories.field.verbose_name,
is_stacked=True))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'newsletter_categories' in self.initial:
initial = dict(self.initial)
initial['hacked_newsletter_categories'] = \
initial['newsletter_categories']
del initial['newsletter_categories']
self.initial = initial
def full_clean(self):
super().full_clean()
if self.is_bound:
self.cleaned_data['newsletter_categories'] = \
self.cleaned_data['hacked_newsletter_categories']
del self.cleaned_data['hacked_newsletter_categories']
class UserAdmin(ModelAdmin):
model = User
list_filter = ('is_active', 'date_joined', 'first_name')
list_display = (
'username', 'is_active', 'date_joined', 'last_name', 'first_name')
fields = (
'username', ('last_name', 'first_name'), 'is_active', 'email',
'hacked_newsletter_categories')
date_hierarchy = 'date_joined'
autocomplete_fields = ['newsletter_categories']
def get_form(
self, request: HttpRequest, obj: User | None = None,
change: bool = False, **kwargs
):
return MyUserAdminForm
I spent a couple days in getting this working right. It is crazy I know, but here it is for people coming after me, pondering the same problem.
Feel free to (ab)use it in any way you like.