Сериализаторы для связанных моделей

Дорабатываем 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 

Вот теперь всё работает как надо:



Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: