Шаг 05. Inline-формы и admin actions

📁 Серия: Капстоун B ⏱️ ~40 мин 🎯 Сложность: Средняя
#tabularinline #admin-action #queryset.update #signals

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

Цель: Добавить TabularInline для экземпляров книг, написать @admin.action для массового обновления статуса. Разобрать разницу queryset.update() vs сигналы.

  • Файлы: catalog/admin.py (расширяем)
  • Результат: на странице книги — таблица экземпляров; action «Отметить доступными» работает
  • Закрывает: callout-verify урока 24 (inline, actions, queryset.update vs сигналы)

🎯 Цель этапа

На этом шаге мы добавляем два мощных инструмента Django Admin:

  • Inline — редактирование связанных объектов прямо на странице родителя (экземпляры книги — на странице редактирования книги)
  • Admin actions — массовые операции над выбранными записями из списка

Закрываем callout-verify из урока 24 — разбираем принципиальную разницу между queryset.update() и поштучным сохранением через save(): signals, full_clean, side effects.

💡 TabularInline vs StackedInline: TabularInline — экономный горизонтальный формат (одна строка = один объект). StackedInline — вертикальный, каждое поле на отдельной строке (как форма). Для BookCopy с несколькими простыми полями — TabularInline лучше.

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

  • Страница редактирования книги содержит таблицу экземпляров
  • Из списка экземпляров можно выбрать несколько и применить action
  • Action «Отметить доступными» меняет статус выбранных экземпляров
  • Понимание, когда queryset.update() опасен (обходит сигналы и full_clean)

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

ФайлДействиеОписание
catalog/admin.pyИзменитьДобавить BookCopyInline и admin actions

🔨 Шаги

1. Добавляем TabularInline

📄 catalog/admin.py
# catalog/admin.py
# Добавить ПЕРЕД классом BookAdmin (он ссылается на этот Inline)

class BookCopyInline(admin.TabularInline):
    """
    Inline для управления экземплярами книги прямо на странице книги.
    TabularInline — горизонтальный формат, компактнее StackedInline.
    """
    model = BookCopy
    fields = ["inventory_number", "status", "due_back"]
    extra = 1           # сколько пустых форм показывать для добавления
    min_num = 0
    max_num = 20
    show_change_link = True  # ссылка на полную форму редактирования экземпляра

2. Подключаем Inline к BookAdmin

📄 catalog/admin.py — класс BookAdmin
# catalog/admin.py — в класс BookAdmin добавляем inlines

@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    # ... (всё что было на шаге 04) ...
    inlines = [BookCopyInline]   # ← добавляем эту строку

3. Пишем admin actions

📄 catalog/admin.py
# catalog/admin.py — добавляем actions к BookCopyAdmin

# ─── Admin Actions ────────────────────────────────────────────────────────────

@admin.action(description="Отметить выбранные экземпляры как доступные")
def mark_available(
    modeladmin: admin.ModelAdmin,
    request: HttpRequest,
    queryset: QuerySet,
) -> None:
    """
    Action: меняет статус выбранных экземпляров на AVAILABLE.

    ВАЖНО — queryset.update() vs save():
    queryset.update() — один SQL UPDATE, НЕ вызывает:
      - Model.save()
      - full_clean() / clean()
      - pre_save / post_save сигналы
      - auto_now поля не обновляются

    Используем queryset.update() здесь намеренно: это простое изменение
    статуса без бизнес-логики, N записей одним запросом — быстро и правильно.
    Если бы была бизнес-логика в save()/сигналах — нужен был бы for-цикл с save().
    """
    updated = queryset.update(
        status=BookCopy.Status.AVAILABLE,
        due_back=None,          # сбрасываем дату возврата
    )
    modeladmin.message_user(
        request,
        f"Статус обновлён: {updated} экземпляр(ов) отмечены как доступные.",
        level="success",
    )


@admin.action(description="Отправить на обслуживание")
def mark_maintenance(
    modeladmin: admin.ModelAdmin,
    request: HttpRequest,
    queryset: QuerySet,
) -> None:
    """Action: отправить на техническое обслуживание."""
    updated = queryset.update(
        status=BookCopy.Status.MAINTENANCE,
        due_back=None,
    )
    modeladmin.message_user(
        request,
        f"{updated} экземпляр(ов) отправлены на обслуживание.",
        level="warning",
    )


# ─── BookCopy Admin (обновляем) ───────────────────────────────────────────────

@admin.register(BookCopy)
class BookCopyAdmin(admin.ModelAdmin):
    list_display = [
        "inventory_number",
        "book",
        "status",
        "due_back",
        "is_overdue",
    ]
    list_filter = ["status"]
    search_fields = ["inventory_number", "book__title"]
    ordering = ["book", "inventory_number"]
    list_per_page = 30
    actions = [mark_available, mark_maintenance]   # ← регистрируем actions

    @admin.display(description="Просрочен", boolean=True)
    def is_overdue(self, obj: BookCopy) -> bool:
        """Показывает иконку True/False — просрочен ли возврат."""
        from django.utils import timezone
        if obj.due_back and obj.status == BookCopy.Status.ON_LOAN:
            return obj.due_back < timezone.now().date()
        return False
💡 @admin.display(boolean=True): Когда метод возвращает bool, Django Admin показывает иконку-галочку вместо текста True/False. Гораздо нагляднее.

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

queryset.update() против цикла с save() — когда что выбирать

Это один из ключевых вопросов Django-разработки, закрытый в callout-verify урока 24.

Критерий queryset.update() Цикл с .save()
SQL-запросов 1 (один UPDATE) N (по одному на объект)
Скорость при N=1000 Быстро Медленно (1000 запросов)
Вызывает Model.save() НЕТ ДА
Вызывает full_clean() НЕТ ДА (если в save())
pre_save / post_save сигналы НЕТ ДА
auto_now поля (updated_at) НЕ обновляются Обновляются
Правило выбора:
  • queryset.update() — когда нет бизнес-логики в save()/clean()/сигналах, нужна скорость, обновляем только статусные/флаговые поля
  • Цикл с save() — когда есть custom clean(), сигналы, auto_now, или любая логика в save()
  • bulk_update() — компромисс: N запросов сгруппировано, но всё ещё не вызывает сигналы
⚠️ Ловушка с queryset.update() и нашим full_clean(): В нашем BookCopy.save() мы вызываем self.full_clean(), которая проверяет: если статус ON_LOAN — должен быть due_back. Но если вы сделаете BookCopy.objects.filter(...).update(status='l') без указания due_back — валидация НЕ запустится, и данные окажутся в невалидном состоянии. Именно поэтому наш action mark_available одновременно обнуляет due_back — он знает об этом ограничении.

✅ Проверка

1. Проверяем Inline

💻 Браузер
http://127.0.0.1:8000/admin/catalog/book/1/change/
Успех: Внизу страницы книги — таблица «Экземпляры книг» с колонками «Инвентарный номер», «Статус», «Дата возврата» и кнопкой «+» для добавления.

2. Проверяем actions

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

Выберите несколько экземпляров → в выпадающем «Action:» выберите «Отметить выбранные экземпляры как доступные» → «Выполнить».

Успех: Вверху страницы появляется зелёное сообщение «Статус обновлён: N экземпляр(ов) отмечены как доступные.» Статус выбранных записей изменился на «Доступен», поле «Дата возврата» — пустое.

Диагностика

  • Inline не показывается — убедитесь, что inlines = [BookCopyInline] добавлен в BookAdmin, а класс BookCopyInline объявлен ВЫШЕ BookAdmin в файле
  • Actions не отображаются в выпадающем — проверьте, что actions = [mark_available, mark_maintenance] в BookCopyAdmin; функции должны быть объявлены до класса
  • NameError: BookCopy не определён в action-функции — импортируйте модель в начале файла или обращайтесь через queryset.model.Status.AVAILABLE

➡️ Что дальше

На следующем шаге мы работаем с Django ORM напрямую: изучаем filter/ exclude/Q/F, аннотации и агрегации, и главное — устраняем N+1 через select_related/prefetch_related. Закрываем callout-verify из урока 22.

  • Готово: Inline для экземпляров, actions с queryset.update(), разница update vs save
  • Далее (шаг 06): Django ORM в shell и во views — правильные запросы без N+1