Шаг 03. Модели и связи

📁 Серия: Капстоун B ⏱️ ~45 мин 🎯 Сложность: Средняя
#models #foreignkey #m2m #validators #full_clean

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

Цель: Написать модели Author, Genre, Publisher, Book, BookCopy — с ForeignKey, ManyToMany, валидаторами, full_clean(). Закрывает callout-verify урока 15.

  • Файлы: catalog/models.py
  • Команды: python manage.py makemigrations, python manage.py migrate
  • Результат: таблицы созданы в БД, python manage.py check — OK

🎯 Цель этапа

На этом шаге мы создаём всю предметную область библиотечного каталога в виде Django-моделей. Закрываем callout-verify из урока 15: показываем правильное использование валидаторов полей, вызов full_clean() в save(), корректный M2M-паттерн.

💡 Схема данных:
  • Author — автор книги (имя, биография, дата рождения)
  • Genre — жанр (название, slug)
  • Publisher — издательство (название, сайт)
  • Book — книга: FK → Publisher, M2M → Author, M2M → Genre
  • BookCopy — физический экземпляр книги: FK → Book, статус (доступен/выдан/ремонт)

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

  • Полная схема БД для библиотечного каталога
  • Валидаторы на поля (ISBN, год издания, slug)
  • Вызов full_clean() из save() — валидация при любом сохранении
  • Правильный __str__ для Admin и shell
  • Миграция применена, таблицы в БД созданы

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

ФайлДействиеОписание
catalog/models.pyЗаполнитьВсе модели предметной области
catalog/migrations/0001_initial.pyСоздаётся makemigrationsПервая миграция catalog

🔨 Шаги

1. Пишем модели

📄 catalog/models.py
# catalog/models.py
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator
from django.core.exceptions import ValidationError
from django.utils import timezone


class Author(models.Model):
    """Автор книги."""

    first_name = models.CharField("Имя", max_length=100)
    last_name = models.CharField("Фамилия", max_length=100)
    birth_date = models.DateField("Дата рождения", null=True, blank=True)
    biography = models.TextField("Биография", blank=True)

    class Meta:
        verbose_name = "Автор"
        verbose_name_plural = "Авторы"
        ordering = ["last_name", "first_name"]

    def __str__(self) -> str:
        return f"{self.last_name} {self.first_name}"

    def clean(self) -> None:
        """Валидация: дата рождения не должна быть в будущем."""
        if self.birth_date and self.birth_date > timezone.now().date():
            raise ValidationError(
                {"birth_date": "Дата рождения не может быть в будущем."}
            )

    def save(self, *args, **kwargs) -> None:
        # Вызываем full_clean() чтобы валидаторы и clean() работали
        # при любом способе сохранения (не только через формы)
        self.full_clean()
        super().save(*args, **kwargs)


class Genre(models.Model):
    """Жанр книги."""

    name = models.CharField("Название", max_length=100, unique=True)
    slug = models.SlugField(
        "Slug",
        max_length=120,
        unique=True,
        validators=[
            RegexValidator(
                r"^[a-z0-9-]+$",
                "Slug может содержать только строчные буквы, цифры и дефис."
            )
        ],
    )

    class Meta:
        verbose_name = "Жанр"
        verbose_name_plural = "Жанры"
        ordering = ["name"]

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


class Publisher(models.Model):
    """Издательство."""

    name = models.CharField("Название", max_length=200, unique=True)
    website = models.URLField("Сайт", blank=True)
    founded_year = models.PositiveIntegerField(
        "Год основания",
        null=True,
        blank=True,
        validators=[
            MinValueValidator(1400, "Год основания не раньше 1400"),
            MaxValueValidator(2100, "Год основания не позже 2100"),
        ],
    )

    class Meta:
        verbose_name = "Издательство"
        verbose_name_plural = "Издательства"
        ordering = ["name"]

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


class Book(models.Model):
    """Книга в каталоге."""

    title = models.CharField("Название", max_length=300)
    authors = models.ManyToManyField(
        Author,
        verbose_name="Авторы",
        related_name="books",
        blank=True,
    )
    publisher = models.ForeignKey(
        Publisher,
        verbose_name="Издательство",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="books",
    )
    genres = models.ManyToManyField(
        Genre,
        verbose_name="Жанры",
        related_name="books",
        blank=True,
    )
    isbn = models.CharField(
        "ISBN",
        max_length=17,
        unique=True,
        null=True,
        blank=True,
        validators=[
            RegexValidator(
                r"^(?:\d{9}[\dX]|\d{13}|\d{3}-\d{1,5}-\d{1,7}-\d{1,6}-[\dX])$",
                "Укажите корректный ISBN-10 или ISBN-13."
            )
        ],
    )
    publish_year = models.PositiveIntegerField(
        "Год издания",
        null=True,
        blank=True,
        validators=[
            MinValueValidator(1400),
            MaxValueValidator(2100),
        ],
    )
    description = models.TextField("Описание", blank=True)

    class Meta:
        verbose_name = "Книга"
        verbose_name_plural = "Книги"
        ordering = ["title"]

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

    # Примечание: Book содержит M2M-поля (authors, genres).
    # full_clean() для M2M вызывается ПОСЛЕ save(), поэтому
    # переопределять save() здесь не обязательно — M2M не валидируется через clean().
    # Для полей CharField/IntegerField/URLField валидация происходит в форме Admin.


class BookCopy(models.Model):
    """Физический экземпляр книги."""

    class Status(models.TextChoices):
        AVAILABLE = "a", "Доступен"
        ON_LOAN = "l", "Выдан"
        MAINTENANCE = "m", "На обслуживании"
        RESERVED = "r", "Зарезервирован"

    book = models.ForeignKey(
        Book,
        verbose_name="Книга",
        on_delete=models.CASCADE,
        related_name="copies",
    )
    inventory_number = models.CharField(
        "Инвентарный номер",
        max_length=50,
        unique=True,
    )
    status = models.CharField(
        "Статус",
        max_length=1,
        choices=Status.choices,
        default=Status.AVAILABLE,
    )
    due_back = models.DateField(
        "Дата возврата",
        null=True,
        blank=True,
        help_text="Заполнять только если экземпляр выдан.",
    )

    class Meta:
        verbose_name = "Экземпляр книги"
        verbose_name_plural = "Экземпляры книг"
        ordering = ["book", "inventory_number"]

    def __str__(self) -> str:
        return f"{self.inventory_number} — {self.book.title}"

    def clean(self) -> None:
        """
        Валидация: если статус 'Выдан' — должна быть указана дата возврата.
        Это callout-verify из урока 15: кросс-полевая валидация через clean().
        """
        if self.status == self.Status.ON_LOAN and not self.due_back:
            raise ValidationError(
                {"due_back": "Укажите дату возврата для выданного экземпляра."}
            )
        if self.due_back and self.status != self.Status.ON_LOAN:
            raise ValidationError(
                {"due_back": "Дата возврата указывается только для выданных экземпляров."}
            )

    def save(self, *args, **kwargs) -> None:
        # full_clean() обеспечивает валидацию через clean() при любом сохранении,
        # не только через Admin-форму. Без этого BookCopy.objects.create(status='l')
        # сохранится без due_back — нарушение бизнес-правила.
        self.full_clean()
        super().save(*args, **kwargs)
💡 Закрытие callout-verify урока 15 — full_clean() в save(): Django-формы вызывают валидацию автоматически. Но если вы создаёте объект через Model.objects.create(), bulk_create() или ORM-напрямую — clean() не вызывается. Чтобы бизнес-правила работали везде, переопределяем save() и вызываем self.full_clean(). Делаем это для моделей с нетривиальной кросс-полевой логикой (Author, BookCopy).

2. Создаём миграцию

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

Ожидаемый вывод:

Migrations for 'catalog':
  catalog/migrations/0001_initial.py
    - Create model Author
    - Create model Genre
    - Create model Publisher
    - Create model Book
    - Create model BookCopy
    - Add field authors to book
    - Add field genres to book

3. Применяем миграцию

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

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

ForeignKey с on_delete

on_delete=models.CASCADE — при удалении книги удаляются все её экземпляры. on_delete=models.SET_NULL — при удалении издательства у книг поле станет null (книга не удаляется). Выбор зависит от бизнес-правил: экземпляр без книги не имеет смысла, книга без издательства — может существовать.

ManyToMany: blank=True

M2M-поля у Book имеют blank=True: книга может не иметь авторов или жанров в базе (хотя это семантически странно — это для Admin-форм). В реальном проекте стоит добавить clean(), проверяющий наличие хотя бы одного автора. Здесь оставляем blank=True для удобства заполнения через Admin.

TextChoices вместо кортежей

Внутренний класс Status(models.TextChoices) — современный Django-способ (с версии 3.0) определять choices. Он даёт автодополнение IDE, читаемые имена (BookCopy.Status.AVAILABLE) и безопасную проверку: copy.status == BookCopy.Status.ON_LOAN.

⚠️ Частая ошибка с full_clean() в save(): Если вы вызываете self.full_clean() в save(), не используйте exclude-параметр без необходимости. Также помните: full_clean() вызывает validate_unique(), что делает дополнительный запрос к БД. Для высоконагруженных систем рассмотрите вынос валидации в сервисный слой.
⚠️ Проверить по документации: Django-валидаторы в полях модели (validators=[...]) вызываются через field.run_validators(), что происходит при full_clean(). Если вы создаёте объекты через .objects.create() без вызова full_clean() — валидаторы не вызываются. Это поведение описано в документации Django: Validating objects.

✅ Проверка

1. Проверяем миграции

💻 Терминал
python manage.py showmigrations catalog
Успех:
catalog
 [X] 0001_initial

2. Проверяем через Django shell

💻 Терминал
python manage.py shell
📄 Django shell
from catalog.models import Author, Genre, Publisher, Book, BookCopy

# Создаём автора
author = Author.objects.create(first_name="Лев", last_name="Толстой")
print(author)  # Толстой Лев

# Создаём жанр
genre = Genre.objects.create(name="Классика", slug="classika")
print(genre)  # Классика

# Создаём книгу
book = Book.objects.create(title="Война и мир")
book.authors.add(author)
book.genres.add(genre)
book.save()
print(book)  # Война и мир

# Тестируем валидацию BookCopy — ожидаем ValidationError
from django.core.exceptions import ValidationError
try:
    copy = BookCopy(
        book=book,
        inventory_number="INV-001",
        status=BookCopy.Status.ON_LOAN,  # выдан, но due_back не указан
    )
    copy.save()  # должен поднять ValidationError через full_clean()
except ValidationError as e:
    print("Валидация работает:", e.message_dict)
Успех: ValidationError поднимается — валидация через full_clean() в save() работает корректно.

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

  • NameError: name 'Author' is not defined — не импортировали модели в shell: from catalog.models import Author, ...
  • Миграция [X] 0001_initial не появляется — проверьте, что "catalog" в INSTALLED_APPS
  • IntegrityError при создании Genre — slug должен быть уникальным и соответствовать RegexValidator

➡️ Что дальше

На следующем шаге мы зарегистрируем все модели в Django Admin с помощью @admin.register и настроим отображение: list_display, search_fields, list_filter, ordering. Это первая «фича Admin» — закрывает callout-verify из урока 19.

  • Готово: все модели, связи, валидаторы, миграция применена
  • Далее (шаг 04): catalog/admin.py — ModelAdmin с фильтрами и поиском
  • После шага 04 Admin станет удобным инструментом управления каталогом