Шаг 05. Inline-формы и admin actions
⚡ Кратко: что делаем на этом шаге
Цель: Добавить 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 — экономный горизонтальный формат (одна строка = один объект).
StackedInline — вертикальный, каждое поле на отдельной строке (как форма).
Для BookCopy с несколькими простыми полями — TabularInline лучше.
После этого шага у нас будет
- Страница редактирования книги содержит таблицу экземпляров
- Из списка экземпляров можно выбрать несколько и применить action
- Action «Отметить доступными» меняет статус выбранных экземпляров
- Понимание, когда queryset.update() опасен (обходит сигналы и full_clean)
📄 Затрагиваемые файлы
| Файл | Действие | Описание |
|---|---|---|
catalog/admin.py | Изменить | Добавить BookCopyInline и admin actions |
🔨 Шаги
1. Добавляем TabularInline
# 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 добавляем inlines
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
# ... (всё что было на шаге 04) ...
inlines = [BookCopyInline] # ← добавляем эту строку
3. Пишем admin actions
# 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
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 запросов сгруппировано, но всё ещё не вызывает сигналы
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:» выберите «Отметить выбранные экземпляры как доступные» → «Выполнить».
Диагностика
- 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