Шаг 03. Модели и связи
⚡ Кратко: что делаем на этом шаге
Цель: Написать модели 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
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)
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.
self.full_clean() в save(),
не используйте exclude-параметр без необходимости.
Также помните: full_clean() вызывает validate_unique(),
что делает дополнительный запрос к БД. Для высоконагруженных систем
рассмотрите вынос валидации в сервисный слой.
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
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 станет удобным инструментом управления каталогом