Шаг 04. ModelAdmin: список и фильтры

📁 Серия: Капстоун B ⏱️ ~35 мин 🎯 Сложность: Средняя
#modeladmin #list_display #list_filter #search_fields #admin.register

⚡ Кратко: что делаем на этом шаге

Цель: Зарегистрировать все модели в Django Admin через @admin.register, настроить list_display, search_fields, list_filter, ordering. Закрывает callout-verify урока 19.

  • Файлы: catalog/admin.py
  • Результат: Admin показывает все модели с удобными колонками, фильтрами и поиском

🎯 Цель этапа

На этом шаге мы превращаем Django Admin из простого CRUD-интерфейса в удобный инструмент управления каталогом. Закрываем callout-verify из урока 19: показываем полный набор атрибутов ModelAdmin с реальными примерами.

💡 Ключевая идея — @admin.register: Современный способ регистрации моделей в Admin. Вместо двух строк: class BookAdmin(ModelAdmin): ... и admin.site.register(Book, BookAdmin) — используем один декоратор: @admin.register(Book) над классом. Читабельнее, лаконичнее, не нужно помнить про вторую строку.

После этого шага у нас будет

  • Все 5 моделей зарегистрированы в Admin
  • Список книг показывает название, авторов, издательство, год, количество экземпляров
  • Поиск по названию, автору, ISBN
  • Фильтры по жанрам, издательству, году издания
  • Кастомный вычисляемый столбец (количество экземпляров)

📄 Затрагиваемые файлы

ФайлДействиеОписание
catalog/admin.pyЗаполнитьModelAdmin для всех моделей

🔨 Шаги

1. Пишем ModelAdmin для всех моделей

📄 catalog/admin.py
# catalog/admin.py
from django.contrib import admin
from django.db.models import QuerySet, Count
from django.http import HttpRequest

from .models import Author, Genre, Publisher, Book, BookCopy


# ─── Author ─────────────────────────────────────────────────────────────────

@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    list_display = ["last_name", "first_name", "birth_date", "books_count"]
    search_fields = ["last_name", "first_name"]
    ordering = ["last_name", "first_name"]
    list_per_page = 25

    @admin.display(description="Книг в каталоге", ordering="books_count")
    def books_count(self, obj: Author) -> int:
        """Количество книг автора — вычисляемый столбец."""
        return obj.books.count()

    def get_queryset(self, request: HttpRequest) -> QuerySet:
        """Аннотируем queryset для сортировки по books_count."""
        return super().get_queryset(request).annotate(
            books_count=Count("books")
        )


# ─── Genre ──────────────────────────────────────────────────────────────────

@admin.register(Genre)
class GenreAdmin(admin.ModelAdmin):
    list_display = ["name", "slug"]
    search_fields = ["name", "slug"]
    ordering = ["name"]
    prepopulated_fields = {"slug": ("name",)}  # автозаполнение slug из name


# ─── Publisher ──────────────────────────────────────────────────────────────

@admin.register(Publisher)
class PublisherAdmin(admin.ModelAdmin):
    list_display = ["name", "website", "founded_year", "books_count"]
    search_fields = ["name"]
    ordering = ["name"]
    list_filter = ["founded_year"]

    @admin.display(description="Книг", ordering="books_count")
    def books_count(self, obj: Publisher) -> int:
        return obj.books.count()

    def get_queryset(self, request: HttpRequest) -> QuerySet:
        return super().get_queryset(request).annotate(
            books_count=Count("books")
        )


# ─── Book ───────────────────────────────────────────────────────────────────

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = [
        "title",
        "get_authors",
        "publisher",
        "publish_year",
        "isbn",
        "copies_count",
    ]
    search_fields = [
        "title",
        "isbn",
        "authors__last_name",
        "authors__first_name",
    ]
    list_filter = ["genres", "publisher", "publish_year"]
    filter_horizontal = ["authors", "genres"]   # виджет двойного списка для M2M
    ordering = ["title"]
    list_per_page = 20
    date_hierarchy = None  # у нас нет DateField на Book

    @admin.display(description="Авторы")
    def get_authors(self, obj: Book) -> str:
        """Список авторов через запятую."""
        return ", ".join(str(a) for a in obj.authors.all())

    @admin.display(description="Экземпляров", ordering="copies_count")
    def copies_count(self, obj: Book) -> int:
        return obj.copies.count()

    def get_queryset(self, request: HttpRequest) -> QuerySet:
        return super().get_queryset(request).prefetch_related(
            "authors", "genres"
        ).annotate(
            copies_count=Count("copies")
        )


# ─── BookCopy ────────────────────────────────────────────────────────────────

@admin.register(BookCopy)
class BookCopyAdmin(admin.ModelAdmin):
    list_display = [
        "inventory_number",
        "book",
        "status",
        "due_back",
    ]
    list_filter = ["status"]
    search_fields = ["inventory_number", "book__title"]
    ordering = ["book", "inventory_number"]
    list_per_page = 30
    # Выделяем просроченные экземпляры цветом через CSS-класс
    # (см. объяснение ниже)
    list_display_links = ["inventory_number"]
💡 filter_horizontal для M2M: filter_horizontal = ["authors", "genres"] заменяет стандартный <select multiple> на виджет с двумя списками и кнопками →/←. Это стандартный способ работы с M2M в Admin — значительно удобнее при большом количестве вариантов. Альтернатива — filter_vertical.
💡 @admin.display и ordering: Параметр ordering в @admin.display указывает, по какому полю (или аннотации) сортировать при клике на заголовок колонки в Admin. Без него — клик на заголовок не сортирует. Поэтому в get_queryset() мы аннотируем queryset именем аннотации, совпадающим с ordering.

🧠 Объяснение логики

Кастомные методы в list_display

Django Admin позволяет добавлять в list_display не только поля модели, но и методы класса AdminModel. Метод получает объект модели (obj) и возвращает строку (или HTML при mark_safe). Декоратор @admin.display(description="...", ordering="...") задаёт заголовок колонки и поле для сортировки.

get_queryset и N+1 в Admin

По умолчанию Django Admin для каждого объекта в списке делает отдельный запрос для связанных данных — это N+1. Переопределяя get_queryset(), мы добавляем prefetch_related("authors", "genres") — один запрос на все связанные объекты вместо N. Аннотация Count("copies") считается в том же запросе.

prepopulated_fields для slug

prepopulated_fields = {"slug": ("name",)} включает JavaScript-логику в форме Admin: при вводе name поле slug заполняется автоматически (транслитерация + замена пробелов на дефисы). Пользователь может отредактировать slug вручную.

⚠️ Частая ошибка в search_fields: Для поиска по полям связанных моделей используется нотация через двойное подчёркивание: "authors__last_name". Регистр важен: Django делает ILIKE по умолчанию, но при добавлении ^ в начало ("^authors__last_name") — поиск только с начала слова.

✅ Проверка

1. Запускаем сервер

💻 Терминал
python manage.py runserver

2. Открываем Admin

💻 Браузер
http://127.0.0.1:8000/admin/

3. Ожидаемый результат

Успех:
  • В Admin видны разделы: Авторы, Жанры, Издательства, Книги, Экземпляры книг
  • В списке книг — колонки «Авторы», «Издательство», «Год», «ISBN», «Экземпляров»
  • В правой панели — фильтры по жанрам, издательству, году
  • Строка поиска ищет по названию и ISBN
  • Форма редактирования книги — виджет двойного списка для авторов и жанров

4. Добавляем тестовые данные

Через Admin создайте несколько записей для проверки отображения:

  • 2–3 жанра (например: «Классика», slug: classika)
  • 1–2 издательства
  • 2–3 автора
  • 1–2 книги с авторами и жанрами
  • 2–3 экземпляра для одной из книг

Диагностика: если что-то пошло не так

  • Модели не появляются в Admin — проверьте импорты в admin.py и декораторы @admin.register
  • AttributeError в вычисляемом столбце — метод get_queryset не аннотирует queryset; убедитесь, что имя аннотации совпадает с ordering в @admin.display
  • Ошибка при prepopulated_fields — slug в списке prepopulated_fields не должен быть в readonly_fields

➡️ Что дальше

На следующем шаге добавляем «вторую фичу Admin»: inline-формы для экземпляров книг прямо на странице редактирования книги, и admin actions для массового обновления статуса. Разберём принципиальную разницу между queryset.update() и сигналами Django.

  • Готово: все модели в Admin с фильтрами, поиском и кастомными колонками
  • Далее (шаг 05): TabularInline, @admin.action, queryset.update vs сигналы