Шаг 07. Фикстуры, management-команды, финал

📁 Серия: Капстоун B ⏱️ ~35 мин 🎯 Сложность: Начальная
#fixtures #dumpdata #management-commands #checklist

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

Цель: Сохранить тестовые данные в фикстуры (dumpdata), создать management-команду для импорта книг, пройти финальный чеклист проекта.

  • Команды: python manage.py dumpdata catalog --indent 2 -o fixtures/catalog.json
  • Команды: python manage.py loaddata fixtures/catalog.json
  • Файлы: catalog/management/commands/import_books.py, fixtures/catalog.json

🎯 Цель этапа

Финальный шаг: инструменты для работы с данными и подведение итогов. Фикстуры позволяют воспроизвести начальное состояние БД на любой машине. Management-команды — расширение возможностей manage.py без создания отдельных скриптов.

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

  • Фикстуры с тестовыми данными для каталога
  • Management-команда import_books с аргументами
  • Пройденный финальный чеклист проекта
  • Понимание, как развивать проект дальше

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

ФайлДействиеОписание
fixtures/catalog.jsonСоздать (dumpdata)Фикстура с данными каталога
catalog/management/__init__.pyСоздатьПакет management
catalog/management/commands/__init__.pyСоздатьПакет commands
catalog/management/commands/import_books.pyСоздатьКоманда manage.py import_books

🔨 Шаги

1. Создаём фикстуры

Убедитесь, что через Admin добавили хотя бы несколько записей (жанры, авторы, книги).

💻 Терминал
# Создаём папку для фикстур
mkdir fixtures

# Экспортируем данные приложения catalog
python manage.py dumpdata catalog --indent 2 -o fixtures/catalog.json

# Проверяем (должен содержать Author, Genre, Publisher, Book, BookCopy)
python -c "import json; data=json.load(open('fixtures/catalog.json')); print(f'{len(data)} записей')"
💡 Флаги dumpdata:
  • catalog — экспортируем только приложение catalog (без auth, admin и пр.)
  • --indent 2 — форматируем JSON с отступами (читаемо)
  • -o fixtures/catalog.json — записываем в файл (без -o — выводит в stdout)

2. Проверяем загрузку фикстуры

💻 Терминал
# Очищаем данные (осторожно! только для теста)
# python manage.py flush --no-input

# Загружаем фикстуру
python manage.py loaddata fixtures/catalog.json
Ожидаемый вывод:
Installed N object(s) from 1 fixture(s)

3. Создаём management-команду

💻 Терминал
mkdir catalog\management
type nul > catalog\management\__init__.py
mkdir catalog\management\commands
type nul > catalog\management\commands\__init__.py
📄 catalog/management/commands/import_books.py
# catalog/management/commands/import_books.py
"""
Management-команда для импорта книг из JSON-файла.
Запуск: python manage.py import_books books.json [--dry-run]

Пример books.json:
[
  {
    "title": "Мастер и Маргарита",
    "authors": ["Булгаков Михаил"],
    "genres": ["классика"],
    "publish_year": 1967
  }
]
"""
import json
from pathlib import Path

from django.core.management.base import BaseCommand, CommandError
from django.db import transaction

from catalog.models import Author, Book, Genre


class Command(BaseCommand):
    help = "Импортировать книги из JSON-файла"

    def add_arguments(self, parser) -> None:
        parser.add_argument(
            "file",
            type=str,
            help="Путь к JSON-файлу с книгами",
        )
        parser.add_argument(
            "--dry-run",
            action="store_true",
            help="Режим проверки без реального сохранения",
        )

    def handle(self, *args, **options) -> None:
        file_path = Path(options["file"])
        dry_run: bool = options["dry_run"]

        if not file_path.exists():
            raise CommandError(f"Файл не найден: {file_path}")

        try:
            data = json.loads(file_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError as e:
            raise CommandError(f"Ошибка чтения JSON: {e}")

        if not isinstance(data, list):
            raise CommandError("JSON должен содержать список книг.")

        created = 0
        skipped = 0

        # Используем транзакцию: если что-то пошло не так — откатываем всё
        with transaction.atomic():
            for item in data:
                title = item.get("title", "").strip()
                if not title:
                    self.stdout.write(self.style.WARNING("  Пропущена книга без названия."))
                    skipped += 1
                    continue

                # get_or_create: не дублируем если уже есть
                book, book_created = Book.objects.get_or_create(
                    title=title,
                    defaults={"publish_year": item.get("publish_year")},
                )

                if not book_created:
                    self.stdout.write(f"  Пропущено (уже есть): {title}")
                    skipped += 1
                    continue

                # Привязываем авторов (создаём если нет)
                for author_str in item.get("authors", []):
                    parts = author_str.strip().split(maxsplit=1)
                    last = parts[0] if parts else author_str
                    first = parts[1] if len(parts) > 1 else ""
                    author, _ = Author.objects.get_or_create(
                        last_name=last,
                        first_name=first,
                    )
                    book.authors.add(author)

                # Привязываем жанры
                for genre_name in item.get("genres", []):
                    genre, _ = Genre.objects.get_or_create(
                        name=genre_name.capitalize(),
                        defaults={"slug": genre_name.lower().replace(" ", "-")},
                    )
                    book.genres.add(genre)

                created += 1
                self.stdout.write(self.style.SUCCESS(f"  Создана: {title}"))

            if dry_run:
                # Откатываем всё — транзакция не сохраняется
                transaction.set_rollback(True)
                self.stdout.write(self.style.WARNING(
                    f"\n--dry-run: изменения НЕ сохранены. "
                    f"Было бы создано: {created}, пропущено: {skipped}"
                ))
            else:
                self.stdout.write(self.style.SUCCESS(
                    f"\nГотово: создано {created}, пропущено {skipped}."
                ))
💡 Ключевые практики management-команд:
  • BaseCommand.style.SUCCESS/WARNING/ERROR — цветной вывод в терминале
  • add_arguments — аргументы CLI через argparse
  • transaction.atomic() — всё или ничего при ошибке
  • get_or_create — идемпотентность (повторный запуск не дублирует)
  • --dry-run + transaction.set_rollback(True) — режим проверки

4. Тестируем management-команду

💻 Создаём тестовый JSON
Создайте файл test_books.json в корне проекта:
📄 test_books.json
[
  {
    "title": "Преступление и наказание",
    "authors": ["Достоевский Фёдор"],
    "genres": ["классика", "детектив"],
    "publish_year": 1866
  },
  {
    "title": "1984",
    "authors": ["Оруэлл Джордж"],
    "genres": ["антиутопия"],
    "publish_year": 1949
  }
]
💻 Терминал
# Сначала dry-run (проверка без сохранения)
python manage.py import_books test_books.json --dry-run

# Реальный импорт
python manage.py import_books test_books.json

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

Фикстуры vs management-команды

Фикстуры (dumpdata/loaddata) — для воспроизводимых начальных данных. Хранят конкретные PK, поэтому порядок загрузки важен (зависимости по FK). Удобно для тестовых данных разработки и staging-окружений.

Management-команды — для сложной логики импорта/экспорта. Idempotent (get_or_create), с валидацией, --dry-run, транзакциями. Подходят для ETL, периодических задач, CI/CD-пайплайнов.

Почему transaction.atomic() в management-команде

Если в середине импорта файл заканчивается ошибкой (невалидный элемент, constraint violation) — без транзакции в БД будет неполный импорт. С transaction.atomic() — всё или ничего. Пользователь видит чёткую ошибку и может исправить файл.

✅ Финальный чеклист проекта

Функциональность

ПунктПроверка
Django-проект стартует без ошибокpython manage.py check — 0 issues
Миграции примененыpython manage.py showmigrations — все [X]
Admin открываетсяhttp://127.0.0.1:8000/admin/ — форма входа
Все 5 моделей в AdminAuthor, Genre, Publisher, Book, BookCopy
Inline работаетНа странице книги — таблица экземпляров
Actions работают«Отметить доступными» — зелёное сообщение после
Список книг доступенhttp://127.0.0.1:8000/catalog/ — список
Фикстура загружаетсяpython manage.py loaddata fixtures/catalog.json — OK
import_books работаетpython manage.py import_books test_books.json --dry-run — OK
Валидация работаетBookCopy со статусом ON_LOAN без due_back → ValidationError
Если все пункты отмечены — Капстоун B завершён! Вы построили полноценное Django 5 приложение с богатой админкой и оптимизированными ORM-запросами.

Что закрыто из callout-verify

  • Урок 15 — full_clean() в save(): реализовано в Author и BookCopy
  • Урок 15 — валидаторы полей: RegexValidator для ISBN и slug, MinValueValidator для годов
  • Урок 19 — ModelAdmin: @admin.register, list_display, search_fields, list_filter, ordering, get_queryset
  • Урок 24 — Inline: TabularInline для BookCopy в BookAdmin
  • Урок 24 — Admin actions: @admin.action с queryset.update() и сообщением
  • Урок 24 — queryset.update() vs сигналы: таблица сравнения, явное объяснение в коде
  • Урок 22 — N+1: select_related для FK, prefetch_related для M2M, annotate для агрегатов
  • Урок 22 — Q/F-объекты: демонстрация в shell и view

➡️ Что дальше

Возможные пути развития проекта

  • Добавить аутентификацию читателей — кастомная модель User, регистрация, выдача книг конкретным пользователям
  • REST API — Django REST Framework для мобильного/frontend-клиента (Капстоун C закрывает это)
  • Поиск — django.contrib.postgres полнотекстовый поиск или интеграция с Elasticsearch
  • Celery-задача — автоматические email-напоминания о просроченных книгах через сигналы
  • Deploy — Gunicorn + Nginx + PostgreSQL вместо SQLite + DEBUG=False
Следующий капстоун: Капстоун C — Task Manager API (Django + DRF + JWT) — флагманский проект, закрывающий callout-verify по DRF, сериализаторам, ViewSets, JWT-аутентификации, permissions и сигналам. ← К оглавлению курса