Дорабатываем Kittygram
В учебном проекте Kittygram описана лишь простая модель Cat; сериализаторы для таких моделей тоже довольно просты.
Но в реальных проектах моделей больше, они сложнее и практически всегда связаны друг с другом. Для таких структур придётся настраивать сериализаторы и вьюсеты более детально, забираясь им «под капот».
Ну что ж, полезли. Начнём с сериализаторов: разберёмся со связанными и вложенными сериализаторами, узнаем, как «на лету» модифицировать данные в ответе.
Приручение котиков
Пора немного «прокачать» проект Kittygram в части бизнес-логики. Пусть у каждого котика будет владелец (owner), который станет его кормить и гладить.
Это практическая работа: вносите изменения, описанные в этом уроке, в проект Kittygram, развёрнутый на вашем компьютере.
Для начала создайте модель, в которой будут храниться данные о хозяевах-котоводах (имени и фамилии владельца будет достаточно):
from django.db import models
class Owner(models.Model):
first_name = models.CharField(max_length=128)
last_name = models.CharField(max_length=128)
def __str__(self):
return f'{self.first_name} {self.last_name}'
class Cat(models.Model):
...
Добавьте в модель Cat поле owner
, оно будет связано с моделью Owner.
Связи в таблицах бывают разного типа:
- один-к-одному (OneToOne);
- один-ко-многим (ForeignKey);
- многие-ко-многим (ManyToMany).
В нашем случае у каждого владельца может быть много котиков, но у каждого котика может быть только один владелец. Такая связь называется «один-ко-многим».
Свяжите модель Cat через поле owner
с моделью Owner:
# cats/models.py
from django.db import models
class Owner(models.Model):
first_name = models.CharField(max_length=128)
last_name = models.CharField(max_length=128)
def __str__(self):
return f'{self.first_name} {self.last_name}'
class Cat(models.Model):
name = models.CharField(max_length=16)
color = models.CharField(max_length=16)
birth_year = models.IntegerField()
# Новое поле в модели:
owner = models.ForeignKey(
Owner, related_name='cats', on_delete=models.CASCADE)
def __str__(self):
return self.name
Миграции с приключениями
После изменения моделей создайте миграции: python3 manage.py makemigrations
. Но не всё так просто — при создании миграций возникнет вопрос:
You are trying to add a non-nullable field 'owner' to cat without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
2) Quit, and let me add a default in models.py
Select an option:
Суть конфликта проста. При миграции в БД будет создано новое поле owner
; если бы в базе были записи — это поле было бы создано и для них. Поле owner
— обязательное, значит, в существующих записях оно должно быть заполнено. «И чем же мы заполним поле owner
, если в базе вдруг уже есть записи?» — спрашивает Django.
В момент создания миграций Django не знает, есть ли в модели Cat записи, и задаёт этот вопрос на всякий случай: если записи есть — что тогда делать?
Выберите первый вариант ответа: «Если в модели Cat существуют записи — заполни в них поле owner одним и тем же значением!» Пусть этим значением будет, например, 1
.
Нам это ничем не грозит: база данных пуста, никакие поля не придётся заполнять. А вот миграции успешно сформируются.
Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>> 1
Migrations for 'cats':
cats/migrations/0002_auto_20210605_2128.py
- Create model Owner
- Add field owner to cat
Вот теперь можно применить миграции: python3 manage.py migrate
.
Продолжаем работу: напишите сериализатор OwnerSerializer
для модели Owner:
# cats/serializers.py
from rest_framework import serializers
from .models import Cat, Owner
class CatSerializer(serializers.ModelSerializer):
class Meta:
model = Cat
fields = ('id', 'name', 'color', 'birth_year')
class OwnerSerializer(serializers.ModelSerializer):
class Meta:
model = Owner
fields = ('first_name', 'last_name')
Напишите вьюсет OwnerViewSet
:
# cats/views.py
from rest_framework import viewsets
from .models import Cat, Owner
from .serializers import CatSerializer, OwnerSerializer
class CatViewSet(viewsets.ModelViewSet):
queryset = Cat.objects.all()
serializer_class = CatSerializer
class OwnerViewSet(viewsets.ModelViewSet):
queryset = Owner.objects.all()
serializer_class = OwnerSerializer
Зарегистрируйте через роутер эндпоинты для нового ресурса:
# kittygram_plus/urls.py
from rest_framework.routers import DefaultRouter
from django.urls import include, path
from cats.views import CatViewSet, OwnerViewSet
router = DefaultRouter()
router.register('cats', CatViewSet)
router.register('owners', OwnerViewSet)
urlpatterns = [
path('', include(router.urls)),
]
Запустите веб-сервер разработки python manage.py runserver
и добавьте через POST-запрос на эндпоинт owners/
хотя бы одного хозяина котиков — он потребуется нам для дальнейших экспериментов.
Кстати, у нас мог бы зарегистрироваться Пабло Пикассо, он, судя по картинам, был неравнодушен к котикам. И Воланда можно пригласить: у него в услужении был большой чёрный кот.
Сериализаторы для связанных моделей
В модели Owner нет поля cats
, но эта модель связана с моделью Cat через related_name 'cats'
. Сериализаторы могут работать с моделями, которые связаны друг с другом: можно указать имя cats
в качестве поля сериализатора. Добавьте это поле в OwnerSerializer
:
class OwnerSerializer(serializers.ModelSerializer):
class Meta:
model = Owner
fields = ('first_name', 'last_name', 'cats')
В модели Cat поле Owner вы уже создали, его надо добавить в сериализатор CatSerializer
:
class CatSerializer(serializers.ModelSerializer):
class Meta:
model = Cat
fields = ('id', 'name', 'color', 'birth_year', 'owner')
Если теперь GET-запросом к эндпоинту owners/ запросить список всех хозяев котиков, то в ответе появится новое поле cats, но оно будет пустым: в базе нет ни одного котика.
POST-запросами на эндпоинт cats/
добавьте в базу данных несколько котиков.
В теле запроса в поле owner передавайте id хозяина: если в базе данных всего один хозяин, его id равен 1.
По умолчанию для связанных полей модели сериализатор будет использовать тип PrimaryKeyRelatedField
; этот тип поля в сериализаторе оперирует первичными ключами (id) связанного объекта.
Поэтому в POST-запросе мы указывали именно id хозяина, а не его first_name
или last_name
. А при GET-запросе к эндпоинту owners/ в поле cats
для каждого хозяина будет возвращаться список id связанных с ним котиков.
[
{
"first_name": "Theodor",
"last_name": "Voland",
"cats": [
1,
2
]
}
]
Для начала неплохо, но совсем не информативно: по id про объект ничего не узнаешь. Нужно изменить дефолтное поведение сериализатора и вернуть вместо id связанного объекта какую-то другую информацию об объекте.В классе Cat описан метод __str__
: для строкового представления объектов модели Cat используется содержимое поля name
. Настроим сериализатор так, чтобы вместо непонятного id возвращалось строковое представление объекта.
Нам не трудно, а пользователю приятно.
Тип поля StringRelatedField
В сериализаторе OwnerSerializer
переопределите тип поля cats
с дефолтного PrimaryKeyRelatedField
на StringRelatedField
.
Роль StringRelatedField
— получить строковые представления связанных объектов и передать их в указанное поле вместо id.
class OwnerSerializer(serializers.ModelSerializer):
cats = serializers.StringRelatedField(many=True, read_only=True)
class Meta:
model = Owner
fields = ('first_name', 'last_name', 'cats')
Обратите внимание, при указании типа поля были переданы аргументы many=True
и read_only=True
.
- Для поля
cats
в модели Owner установлена связь «один-ко-многим» (у одного хозяина может быть много котиков), следовательно, полюcats
в сериализаторе надо разрешить обработку списка объектов. Для этого в нём указан аргументmany=True
. - Поля с типом StringRelatedField не поддерживают операции записи, поэтому для них всегда должен быть указан параметр
read_only=True
.
Снова сделайте GET-запрос к эндпоинту owners/ и посмотрите, как изменился ответ:
Теперь точно так же измените код сериализатора CatSerializer
: переопределите поле owner
. При запросе к эндпоинту cats/ в ответе должен отображаться не id хозяина котика, а строковое представление объекта модели Owner; параметр many=True
в этом поле не нужен, ведь у котика может быть только один хозяин.
class CatSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True)
class Meta:
model = Cat
fields = ('id', 'name', 'color', 'birth_year', 'owner')
Сделайте запрос на эндпоинт cats/. Если вы всё сделали правильно, то в каждом объекте котика в поле owner
будет возвращаться строковое представление связанного с котиком объекта Owner:
Важно помнить, что если у указанной модели изменится строковое представление, то в случае использования StringRelatedField эти изменения отразятся и на ответах API.
Другие типы related-полей
Помимо PrimaryKeyRelatedField
и StringRelatedField
в сериализаторах можно использовать и другие типы связанных полей. Весь список можно традиционно найти в документации.
Новые достижения
Кот не кот, если не натворил что-нибудь за день. Прокачаем проект: привяжем к каждому коту список его достижений.
Создайте модель Achievement (англ. «достижение»). В ней будут храниться описания подвигов, которые так любят совершать все представители кошачьих: «уронил ёлку», «прогрыз провод», «разбил стакан», «напал из-за угла».
В модель Cat добавьте новое поле — achievements
(англ. «достижения»), оно будет связано с моделью Achievement через вспомогательную модель AchievementCat — её тоже опишите в коде.
from django.db import models
class Achievement(models.Model):
name = models.CharField(max_length=64)
def __str__(self):
return self.name
class Owner(models.Model):
first_name = models.CharField(max_length=128)
last_name = models.CharField(max_length=128)
def __str__(self):
return f'{self.first_name} {self.last_name}'
class Cat(models.Model):
name = models.CharField(max_length=16)
color = models.CharField(max_length=16)
birth_year = models.IntegerField()
owner = models.ForeignKey(
Owner, related_name='cats', on_delete=models.CASCADE)
# Связь будет описана через вспомогательную модель AchievementCat
achievements = models.ManyToManyField(Achievement, through='AchievementCat')
def __str__(self):
return self.name
# В этой модели будут связаны id котика и id его достижения
class AchievementCat(models.Model):
achievement = models.ForeignKey(Achievement, on_delete=models.CASCADE)
cat = models.ForeignKey(Cat, on_delete=models.CASCADE)
def __str__(self):
return f'{self.achievement} {self.cat}'
Каждое достижение может принадлежать любому количеству котиков, и каждый котик может обладать любым количеством достижений; это связь «многие-ко-многим».
После изменения моделей создайте и примените миграции.
Вложенный сериализатор
В работе со связанными моделями Cat и Owner мы получали лишь ссылку на связанный объект (это был его id или что-то, замещающее id), но сам объект так и оставался недоступен. Однако список достижений котика — это именно список связанных с котиком объектов из модели Achievement. Надо их как-то добыть.
Задача состоит в том, чтобы при запросе к эндпоинту cats/ вместе с объектом котика вернуть список, состоящий из привязанных к этому зверю объектов.
Чтобы реализовать эту идею в сериализаторе, нужно вложить один сериализатор в другой: определить в сериализаторе поле, типом которого будет другой сериализатор. Таким образом вложенный сериализатор передаст в поле родительского сериализатора список объектов.
В нашем случае родительским сериализатором будет CatSerializer
, а вложенным — AchievementSerializer
(но сперва его надо написать).
Разомните пальцы: напишите сериализатор для модели Achievement:
class AchievementSerializer(serializers.ModelSerializer):
class Meta:
model = Achievement
fields = ('id', 'name')
Теперь перенастроим CatSerializer
: переопределим в нём поле achievements
. По дефолту к этому полю в сериализаторе будет применён тип PrimaryKeyRelatedField
. Но в наших планах было получить не id объектов Achievement, а его объекты целиком.
Назначьте типом поля achievements
сериализатор AchievementSerializer
. Да, так можно!
class AchievementSerializer(serializers.ModelSerializer):
class Meta:
model = Achievement
fields = ('id', 'name')
class CatSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True)
# Переопределяем поле achievements
achievements = AchievementSerializer(read_only=True, many=True)
class Meta:
model = Cat
fields = ('id', 'name', 'color', 'birth_year', 'owner', 'achievements')
Теперь поле achievements
в CatSerializer будет получать объекты Achievement, сериализованные в AchievementSerializer.
Достижений у каждого котика может быть много, значит, полю achievements нужно передать аргумент many=True. И пока ограничим доступ к полю — «только для чтения», с записью разберёмся позже.
Проверьте результат: сделайте GET-запрос к конкретному котику:
В ответе будет доступен список объектов из модели Achievements, привязанных к конкретному котику. Этот список пока что пуст. Пора записать в базу котиков с достижениями.
Операции записи с вложенными сериализаторами
Сейчас для поля owner
модели Cat явным образом указан тип StringRelatedField; запись в него невозможна. Но в это поле потребуется записывать данные, поэтому удалите из кода строку, где это поле переопределяется: это вернёт полю его тип «по умолчанию».
Вложенные сериализаторы по умолчанию доступны только для чтения. Если из описания поля achievements
убрать параметр read_only=True
и отправить POST-запрос, передав данные о новом котике и список его достижений, возникнет ошибка:
В тексте ошибки будет предложено два варианта решения проблемы: либо установить для поля achievements
аргумент read_only=True
, либо явно описать в коде, как и куда следует сохранять полученные данные и отношения между ними — как связать котика с достижением. Установить параметр read_only=True
— не выход: нам нужно записывать достижения в БД. Значит, надо описать, как должны сохраняться данные.
При получении такого POST-запроса
{
"name": "Барсик",
"color": "White",
"birth_year": 2017,
"owner": 1,
"achievements": [
{"name": "поймал мышку"},
{"name": "разбил вазу"}
]
}
порядок работы должен быть таким:
- из списка
serializer.validated_data
извлечь и сохранить в переменную элементachievements
: в нём хранится список достижений котика; - в базе данных создать запись о новом котике — для этого у нас есть вся необходимая информация; достижения котика в этом не участвуют — лежат в стороне, ждут обработки;
- перебрать полученный список достижений котика и сравнить каждое из достижений с имеющимися в базе данных записями:
- если проверяемый элемент уже есть в базе — в таблицу связей AchievementCat добавить связь этого достижения с новым котиком;
- если проверяемого элемента в базе нет — в базе достижений создать новую запись и в таблицу связей AchievementCat добавить связь этого достижения с новым котиком.
- вернуть JSON с объектом свежесозданного котика и списком его достижений.
Чтобы настроить сохранение данных, нужно переопределить метод create()
в сериализаторе. Опишем его и укажем явным образом, какие записи в каких таблицах нужно создать.
from .models import Cat, Owner, Achievement, AchievementCat
...
class CatSerializer(serializers.ModelSerializer):
achievements = AchievementSerializer(many=True) # Убрали read_only=True
# Убрали owner = serializers.StringRelatedField(read_only=True)
class Meta:
model = Cat
fields = ('id', 'name', 'color', 'birth_year', 'owner', 'achievements')
...
def create(self, validated_data):
# Уберем список достижений из словаря validated_data и сохраним его
achievements = validated_data.pop('achievements')
# Создадим нового котика пока без достижений, данных нам достаточно
cat = Cat.objects.create(**validated_data)
# Для каждого достижения из списка достижений
for achievement in achievements:
# Создадим новую запись или получим существующий экземпляр из БД
current_achievement, status = Achievement.objects.get_or_create(
**achievement)
# Поместим ссылку на каждое достижение во вспомогательную таблицу
# Не забыв указать к какому котику оно относится
AchievementCat.objects.create(
achievement=current_achievement, cat=cat)
return cat
Снова сделаем запрос на добавление котика и списка его достижений:
Заработало! Теперь можно не только получать связанные объекты, но и отправлять их через POST-запросы — сериализатор обучен и знает, что с ними делать.
Чтобы использовать операции записи в поле со вложенным сериализатором, в родительском сериализаторе необходимо описать методы create()
или update()
(или сразу оба) и явно указать, как именно следует сохранять данные и связи между ними.
Исходные данные из запроса в сериализаторе
При работе с сериализаторами бывает полезен доступ к тем данным, которые были переданы в сериализатор: например, если нужно проверить, было ли передано в запросе какое-нибудь необязательное поле. Эти данные хранятся в словаре serializer.initial_data
, и прямо сейчас они понадобятся.
Отправьте POST-запрос на добавления котика, но не указывайте список его достижений:
Вернётся ошибка: поле achievements
является обязательным.
Если иное не определено на уровне модели или сериализатора явным образом, то все поля модели, перечисленные в сериализаторе, будут обязательными.
В модели Cat явным образом не указано, что поле achievements
— необязательное. Сериализатор видит, что поле модели не описано как необязательное — и к собственному полю achievements
применяет атрибут required=True
.
Но поле achievements
нельзя назначать обязательным: бывают котики и без достижений, и не должно быть запрета на добавление таких котиков в Kittigram. Модель Cat это позволяет, но сериализатор не даёт этого сделать. Следовательно, нужно явным образом определить в сериализаторе поле achievements
как необязательное.
Установим для поля achievements
в сериализаторе атрибут required
со значением False
:
class CatSerializer(serializers.ModelSerializer):
achievements = AchievementSerializer(many=True, required=False)
...
Теперь сериализатор не будет беспокоиться, если этого поля нет в запросе, но возникнет другая ошибка:
Переопределённый метод create()
в сериализаторе пытается сохранить данные из поля achievements
, но поле теперь необязательное, данные в POST-запросе для сохранения не пришли — и всё сломалось.
Тут и понадобится словарь initial_data
: в методе create()
проверим, пришло в запросе поле achievements
или нет, и, в зависимости от результата, будем сохранять котика с достижениями или без них.
...
def create(self, validated_data):
# Если в исходном запросе не было поля achievements
if 'achievements' not in self.initial_data:
# То создаём запись о котике без его достижений
cat = Cat.objects.create(**validated_data)
return cat
else:
achievements = validated_data.pop('achievements')
# Иначе сначала добавляем котика в БД
cat = Cat.objects.create(**validated_data)
# А потом добавляем его достижения в БД
for achievement in achievements:
current_achievement, status = Achievement.objects.get_or_create(
**achievement)
# И связываем каждое достижение с этим котиком
AchievementCat.objects.create(
achievement=current_achievement, cat=cat)
return cat
Вот теперь всё работает как надо: