Django Admin: Как отобразить связи многие-ко-многим?

Что такое связи многие-ко-многим и когда их использовать

Связи многие-ко-многим (ManyToManyField, M2M) в Django ORM позволяют ассоциировать одну запись модели с несколькими записями другой модели, и наоборот. Классические примеры: статьи и теги (статья может иметь много тегов, тег может быть присвоен многим статьям), пользователи и группы, рекламные кампании и ключевые слова.

Эти связи незаменимы для моделирования сложных взаимосвязей данных, где объекты могут принадлежать к нескольким категориям или иметь множественные атрибуты, представленные другими моделями.

Проблема отображения связей многие-ко-многим в Django Admin по умолчанию

По умолчанию, Django Admin использует стандартный HTML-виджет <select multiple> для отображения и редактирования M2M-полей. Этот виджет становится крайне неудобным и непроизводительным при работе с большим количеством связанных объектов.

Выбор нескольких элементов из длинного списка с использованием Ctrl/Shift-клика — плохой UX. Кроме того, загрузка и отображение тысяч опций в <select> может существенно замедлить загрузку страницы администратора.

Цель статьи: отображение связей многие-ко-многим в удобном формате

Цель этой статьи — рассмотреть встроенные и кастомные способы улучшения отображения и управления M2M-связями в интерфейсе Django Admin, делая его более удобным и производительным для разработчиков и контент-менеджеров.

Использование ‘filter_horizontal’ и ‘filter_vertical’

Django Admin предлагает два встроенных виджета, которые значительно улучшают стандартный <select multiple>.

Настройка ModelAdmin для использования ‘filter_horizontal’ или ‘filter_vertical’

Для активации этих виджетов достаточно указать имя M2M-поля в атрибутах filter_horizontal или filter_vertical вашего класса ModelAdmin.

# models.py
from django.db import models

class Keyword(models.Model):
    name = models.CharField(max_length=100, unique=True)

    def __str__(self) -> str:
        return self.name

class AdCampaign(models.Model):
    name = models.CharField(max_length=200)
    keywords = models.ManyToManyField(Keyword, blank=True)
    # ... другие поля

    def __str__(self) -> str:
        return self.name

# admin.py
from django.contrib import admin
from .models import AdCampaign, Keyword

@admin.register(Keyword)
class KeywordAdmin(admin.ModelAdmin):
    search_fields = ('name',)

@admin.register(AdCampaign)
class AdCampaignAdmin(admin.ModelAdmin):
    list_display = ('name',)
    search_fields = ('name',)
    # Используем filter_horizontal для поля 'keywords'
    filter_horizontal: tuple[str, ...] = ('keywords',) # type: ignore
    # Или альтернативно:
    # filter_vertical: tuple[str, ...] = ('keywords',) # type: ignore

Преимущества и недостатки ‘filter_horizontal’ и ‘filter_vertical’

Преимущества:

Значительно лучший UX по сравнению со стандартным <select multiple>.

Позволяет легко перемещать элементы между списками ‘Доступные’ и ‘Выбранные’.

Включает поиск/фильтрацию, что упрощает навигацию по большому количеству опций.

Просты в интеграции – требуют всего одной строки конфигурации в ModelAdmin.

Недостатки:

Могут все еще быть медленными, если количество связанных объектов исчисляется многими тысячами, так как все опции загружаются на страницу (хотя и скрыты до фильтрации).

Занимают значительное пространство на экране, особенно filter_vertical.

Пример использования ‘filter_horizontal’ для полей многие-ко-многим

В приведенном выше примере для модели AdCampaign поле keywords настроено с использованием filter_horizontal. Это предоставляет интерфейс с двумя блоками: слева – доступные ключевые слова (с поиском), справа – выбранные для данной кампании. Пользователь может легко перемещать ключевые слова между блоками.

Применение ‘raw_id_fields’

Когда количество связанных объектов становится очень большим, filter_horizontal может быть недостаточно производительным. raw_id_fields предлагает альтернативный подход.

Настройка ModelAdmin для использования ‘raw_id_fields’

Аналогично filter_horizontal, raw_id_fields настраивается путем добавления имени M2M-поля (или ForeignKey) в соответствующий кортеж в ModelAdmin.

# admin.py
from django.contrib import admin
from django.contrib.auth.models import User, Permission
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

# Пример для стандартной модели User
class UserAdmin(BaseUserAdmin):
    # Отображаем поля user_permissions и groups как raw_id_fields
    raw_id_fields: tuple[str, ...] = ('user_permissions', 'groups',)

admin.site.unregister(User)
admin.site.register(User, UserAdmin)

# Для кастомной модели
# @admin.register(LargeDatasetModel)
# class LargeDatasetModelAdmin(admin.ModelAdmin):
#     raw_id_fields = ('related_items',)

Когда ‘raw_id_fields’ является хорошим выбором (большое количество связанных объектов)

raw_id_fields идеально подходит в следующих случаях:

Связанная модель содержит тысячи или миллионы записей.

Необходимо минимизировать время загрузки страницы администратора.

Пользователю достаточно видеть ID связанных объектов или выбирать их через всплывающее окно поиска.

Пример использования ‘raw_id_fields’ для оптимизации интерфейса

Вместо отображения списка всех объектов, raw_id_fields показывает текстовое поле, содержащее ID выбранных связанных объектов (через запятую для M2M). Рядом с полем находится иконка лупы, которая открывает всплывающее окно со списком связанных объектов, поддерживающее поиск и пагинацию. Это значительно ускоряет загрузку и работу со страницей, содержащей M2M-связи с огромным числом потенциальных опций, например, привязка пользователя к специфическим разрешениям (Permission) из сотен доступных.

Реклама

Кастомизация формы администратора

Для более тонкой настройки и использования сторонних виджетов можно кастомизировать форму, используемую в ModelAdmin.

Создание собственной формы ModelForm

Сначала создается класс формы, наследуемый от django.forms.ModelForm.

# forms.py
from typing import Any
from django import forms
from .models import AdCampaign, Keyword

class AdCampaignForm(forms.ModelForm):
    class Meta:
        model = AdCampaign
        fields = '__all__'
        # Здесь можно будет переопределить виджет для 'keywords'

Переопределение поля формы для связей многие-ко-многим

В классе формы можно напрямую переопределить поле, отвечающее за M2M-связь, указав другой виджет или логику.

Использование виджетов для более удобного выбора связанных объектов (Select2, Chosen)

Библиотеки вроде django-select2 или django-autocomplete-light предоставляют продвинутые виджеты с автодополнением, поиском на стороне сервера (AJAX) и улучшенным UX для выбора M2M-связей.

Пример: Интеграция виджета Select2 для связей многие-ко-многим

Предположим, установлен пакет django-select2.

# forms.py
from typing import Any
from django import forms
from django_select2 import forms as s2forms # Импорт из django-select2
from .models import AdCampaign, Keyword

class KeywordSelect2MultipleWidget(s2forms.ModelSelect2MultipleWidget):
    # Указываем модель и поле для поиска
    search_fields = [
        'name__icontains',
    ]

class AdCampaignForm(forms.ModelForm):
    # Переопределяем поле keywords, используя виджет Select2
    keywords = forms.ModelMultipleChoiceField(
        queryset=Keyword.objects.all(),
        widget=KeywordSelect2MultipleWidget,
        required=False,
        label='Ключевые слова (Select2)'
    )

    class Meta:
        model = AdCampaign
        fields = '__all__'

# admin.py
from django.contrib import admin
from .models import AdCampaign, Keyword
from .forms import AdCampaignForm # Импортируем нашу кастомную форму

@admin.register(Keyword)
class KeywordAdmin(admin.ModelAdmin):
    search_fields = ('name',)

@admin.register(AdCampaign)
class AdCampaignAdmin(admin.ModelAdmin):
    list_display = ('name',)
    search_fields = ('name',)
    # Указываем админке использовать нашу кастомную форму
    form = AdCampaignForm

Теперь поле keywords будет использовать виджет Select2, который обеспечивает поиск по мере ввода и загружает опции через AJAX, что эффективно даже для очень больших наборов ключевых слов.

Inline модели для отображения связей многие-ко-многим (через промежуточную модель)

Если M2M-связь содержит дополнительные данные (например, дату добавления тега к статье, порядок показа баннеров в группе), необходимо использовать явную промежуточную модель (through). Django Admin позволяет редактировать такие связи с помощью инлайнов.

Создание промежуточной модели для связи многие-ко-многим

Определим модели для Статей, Тегов и промежуточную модель ArticleTag, хранящую порядок.

# models.py
from django.db import models

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self) -> str:
        return self.name

class Article(models.Model):
    title = models.CharField(max_length=200)
    # Связь M2M через модель ArticleTag
    tags = models.ManyToManyField(Tag, through='ArticleTag', blank=True)

    def __str__(self) -> str:
        return self.title

class ArticleTag(models.Model):
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
    # Дополнительное поле в промежуточной модели
    order = models.PositiveIntegerField(default=0)

    class Meta:
        unique_together = ('article', 'tag') # Гарантируем уникальность связи
        ordering = ('order',) # Сортировка по умолчанию

Использование InlineModelAdmin для отображения и редактирования связей через промежуточную модель

Создаем класс инлайна (TabularInline или StackedInline) для промежуточной модели ArticleTag.

# admin.py
from django.contrib import admin
from .models import Article, Tag, ArticleTag

# Инлайн для управления связями Article-Tag
class ArticleTagInline(admin.TabularInline):
    model = ArticleTag
    # Указываем, какие поля из ArticleTag отображать/редактировать
    fields = ('tag', 'order')
    # raw_id_fields можно использовать и внутри инлайна!
    raw_id_fields = ('tag',)
    # Количество пустых форм для добавления новых связей
    extra = 1

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    search_fields = ('name',)

@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ('title',)
    search_fields = ('title',)
    # Добавляем инлайн в админку статьи
    inlines = [ArticleTagInline]

Настройка InlineModelAdmin для управления отображением полей

В классе инлайна (ArticleTagInline) можно настроить:

model: Промежуточная модель.

fields или fieldsets: Какие поля промежуточной модели отображать.

extra: Количество пустых форм.

raw_id_fields: Применить raw_id_fields к полям ForeignKey внутри инлайна (например, для tag).

autocomplete_fields: Использовать виджеты с автодополнением для полей ForeignKey.

classes: Добавить CSS классы (например, 'collapse').

Пример: Отображение связей многие-ко-многим через промежуточную модель в виде inline

В примере выше, при редактировании объекта Article, под основной формой появится таблица (TabularInline), где каждая строка представляет собой связь с одним Tag. В строке можно выбрать тег (используя raw_id_fields для удобства, если тегов много) и указать значение поля order. Это позволяет управлять как самими связями, так и дополнительными данными этих связей непосредственно на странице основной модели.


Добавить комментарий