Kittygram 2: новые возможности

Kittygram серьёзно подрос, обзавёлся новыми возможностями и стал удобнее. А тут поступила новая задача: новые пользователи хотят самостоятельно регистрироваться в нашем сервисе. Мало того: нужно настроить проект так, чтобы добавлять, обновлять и удалять информацию о котиках могли только их хозяева.

Судя во всему, нового релиза не избежать. Ну что ж, пусть будет Kittygram v2.0.

Релиз Kittygram v2.0

Склонируйте новую версию проекта, выполните миграции, запустите веб-сервер, создайте через API несколько пользователей и получите для них токены.

Проверьте, что пользователи появились в БД и котиков у них пока нет: сделайте несколько запросов через Postman.

Каждый релиз принято сопровождать обзором нововведений. Релиз второй версии Kittygram тоже заслуживает такого обзора.

Что новенького?

Начнём с моделей: Owner больше не понадобится, вместо неё будем использовать встроенную модель User.

#cats/models.py
from django.contrib.auth import get_user_model
from django.db import models

CHOICES = (
    ('Gray', 'Серый'),
    ('Black', 'Чёрный'),
    ('White', 'Белый'),
    ('Ginger', 'Рыжий'),
    ('Mixed', 'Смешанный'),
)

User = get_user_model()


class Achievement(models.Model):
    name = models.CharField(max_length=64)

    def __str__(self):
        return self.name


class Cat(models.Model):
    name = models.CharField(max_length=16)
    color = models.CharField(max_length=16, choices=CHOICES)
    birth_year = models.IntegerField()
    owner = models.ForeignKey(
        User, related_name='cats', on_delete=models.CASCADE)
    achievements = models.ManyToManyField(Achievement, through='AchievementCat')

    def __str__(self):
        return self.name


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}' 

В новой версии проекта работать будем исключительно с вьюсетами, так удобнее.

Аутентифицированные пользователи не должны иметь возможность удалять или изменять данные друг друга, поэтому вьюсет, отвечающий за работу с пользователями, унаследован от ReadOnlyModelViewSet.

# cats/views.py
from rest_framework import viewsets

from .models import Achievement, Cat, User
from .serializers import AchievementSerializer, CatSerializer, UserSerializer


class CatViewSet(viewsets.ModelViewSet):
    queryset = Cat.objects.all()
    serializer_class = CatSerializer


class UserViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer


class AchievementViewSet(viewsets.ModelViewSet):
    queryset = Achievement.objects.all()
    serializer_class = AchievementSerializer 

А чтобы дать возможность пользователям самостоятельно регистрироваться через API и обеспечить доступ по токену, воспользуемся связкой JWT+Djoser.

Добавление данных через API

Самое время наполнить базу данных котиками. Теперь это могут сделать только аутентифицированные пользователи.

Выполните POST-запрос на эндпоинт cats/; анонимные запросы к проекту теперь запрещены, так что передайте с запросом токен одного из пользователей; в теле запроса передайте всю необходимую информацию о домашнем питомце:

{
    "name": "Барсик",
    "color": "White",
    "birth_year": 2011
} 


Запрос не обработался, ведь мы не передали в запросе id владельца котика, а это обязательное поле в сериализаторе CatSerializer. Исправить это не трудно, можно просто добавить в тело запроса id текущего пользователя.

{
    "name": "Барсик",
    "color": "Black",
    "birth_year": 2020,
    "owner": 1
} 

Если указать id другого пользователя (из списка существующих), то такой запрос тоже пройдёт. А вот это уже не очень хорошо, ведь добавлять, удалять и изменять информацию о котике может только хозяин. А пока что пользователь с id=3 может добавить котика от имени пользователя с id=1. Выглядит как подлог.

Самый простой и очевидный способ исправить ситуацию — получать владельца котика не из поля owner в теле запроса, а из объекта request: там доступен экземпляр пользователя, которому принадлежит токен. Нечто подобное вы делали при работе с формами. А сериализатору надо сказать, чтобы он игнорировал это поле, если оно будет указано в теле POST-запроса.

Начнём с последнего пункта: переопределим поле owner и укажем параметр read_only.

class CatSerializer(serializers.ModelSerializer):
    achievements = AchievementSerializer(read_only=True, many=True)
    color = serializers.ChoiceField(choices=CHOICES)
    owner = serializers.PrimaryKeyRelatedField(read_only=True)
    age = serializers.SerializerMethodField()
    
    class Meta:
        model = Cat
        fields = ('id', 'name', 'color', 'birth_year','achievements', 'owner', 
                  'age')

    ... 

Параметр read_only_fields можно указать не в описании поля, а во внутреннем классе Meta: так удобнее — это позволяет перечислить все поля для чтения в одном месте, не переопределяя их.

class CatSerializer(serializers.ModelSerializer):
    achievements = AchievementSerializer(read_only=True, many=True)
    color = serializers.ChoiceField(choices=CHOICES)
    age = serializers.SerializerMethodField()
    
    class Meta:
        model = Cat
        fields = ('id', 'name', 'color', 'birth_year', 'achievements', 'owner',
                  'age')
        read_only_fields = ('owner',)

    ... 

Теперь сериализатор не ждёт в теле POST-запроса поле owner (а если оно придёт, то будет проигнорировано).

Передача собственного значения в метод save()

Проблему с подделкой id пользователя решили, но легче не стало.

Теперь вьюсет не может создать новую запись в БД, ведь поле owner в модели Cat обязательное, а данные из сериализатора в него не приходят: при POST-запросах сериализатор игнорирует поле owner.

Задача состоит в том, чтобы при создании новой записи в БД поле owner не осталось пустым, а в него была записана информация о пользователе, отправившем запрос. Эта информация у нас есть — она доступна в request.user, осталось в нужный момент подсунуть её в базу.

Новая запись в БД создаётся при вызове метода save() сериализатора, а этот метод вызывается из метода вьюсета perform_create().

Чтобы передать новое значение для какого-то поля в метод save(), нужно переопределить метод perform_create().

В метод save() в полe owner передадим объект пользователя, отправившего запрос.

class CatViewSet(viewsets.ModelViewSet):
    queryset = Cat.objects.all()
    serializer_class = CatSerializer

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user) 

Вот теперь всё работает как надо: сериализатор не ожидает id пользователя в POST-запросе (или игнорирует его при получении), а при создании записи о новом котике в БД информация о пользователе берётся из объекта request.user.

При подобных операциях с PUT- и PATCH-запросами следует переопределить метод perform_update(), а в остальном всё работает так же.

Валидаторы

По умолчанию валидация в сериализаторе происходит на базовом уровне. Если в поле ожидается целочисленное значение, а пришло число с плавающей точкой, то сериализатор вернёт ошибку.

А вот если в запросе придёт значение, которое по типу соответствует ожиданиям, но не удовлетворяет бизнес-логике или здравому смыслу (например, год рождения — в будущем), то ошибка не возникнет.

Валидация на уровне поля

Допустим, к проекту Kittygram отправлен POST-запрос, в котором переданы некорректные данные: например, год рождения котика указан в будущем или, наоборот, в далёком прошлом (а котики, к сожалению, не живут более сорока лет). Такие данные принимать нельзя, значит, надо провести валидацию полученных значений.

Проверку можно провести на уровне отдельно взятого поля. Сделаем это на примере поля birth_year.

Метод для валидации определённого поля должен называться validate_<имя поля>, он будет автоматически вызываться при получении данных.

Опишите в CatSerializer новый метод validate_birth_year().

class CatSerializer(serializers.ModelSerializer):
    achievements = AchievementSerializer(many=True, required=False)
    color = serializers.ChoiceField(choices=CHOICES)
    age = serializers.SerializerMethodField()
    owner = serializers.PrimaryKeyRelatedField(read_only=True)
    
    class Meta:
        model = Cat
        fields = ('id', 'name', 'color', 'birth_year',
                      'achievements', 'owner', 'age')
    
    def validate_birth_year(self, value):
        year = dt.date.today().year
        if not (year - 40 < value <= year):
            raise serializers.ValidationError('Проверьте год рождения!')
        return value 

Проверьте результат: если всё сделано верно, то добавить котика, родившегося в двадцатом году нашей эры, будет невозможно:

Если год указан в нужном диапазоне, проблем не возникнет:

Ещё один важный момент: этот метод не только проверяет данные, но и возвращает значение.

Дело здесь в том, что DRF позволяет не только проверять данные, но и нормализовать их, приводить к нужному виду — например, к верхнему или нижнему регистру. Здесь эта возможность не используется, но DRF требует вернуть значение, и поэтому функция возвращает то, что сама получила на вход, — аргумент value.

Валидация на уровне объекта

Метод validate_birth_year проверяет одно-единственное поле birth_year и ничего не знает об остальных данных.

Однако значения отдельных полей могут по отдельности успешно проходить все валидации, а вот их совокупность не всегда бывает валидной.Например, если в POST-запросе значения полей name и color совпали — это почти наверняка ошибка: редко бывает так, что имя котика совпадает с его цветом. Чтобы выявить эту ошибку, понадобится сравнить значения этих полей.

Чтобы выполнить проверку, требующую доступа к нескольким полям, добавьте в сериализатор метод validate(). Этот метод принимает один аргумент — словарь со значениями полей.

class CatSerializer(serializers.ModelSerializer):
    achievements = AchievementSerializer(many=True, required=False)
    color = serializers.ChoiceField(choices=CHOICES)
    age = serializers.SerializerMethodField()
    owner = serializers.PrimaryKeyRelatedField(read_only=True)
    
    class Meta:
        model = Cat
        fields = ('id', 'name', 'color', 'birth_year',
            'achievements', 'owner', 'age')
    
    ...
    
    def validate(self, data):
        if data['color'] == data['name']:
            raise serializers.ValidationError(
                'Имя не может совпадать с цветом!')
        return data 

Теперь ошибочные записи создаваться не будут, а владелец белого кота по кличке White получит сообщение об ошибке.

Встроенные валидаторы для сериализаторов

Для сериализаторов в DRF есть несколько встроенных классов-валидаторов, среди них есть UniqueValidator и UniqueTogetherValidator.

  • UniqueValidator обеспечивает проверку уникальности значения поля.В моделях такое ограничение описывается параметром unique=True для поля.
  • UniqueTogetherValidator обеспечивает проверку уникальности комбинации (например, если проверка уникальности идёт по полям name и color, в базе никогда не окажется несколько записей с name='Мурзик' и color='Black', пусть даже остальные поля будут у них различаться).

Разработчики Kittygram 2.0 не хотят, чтобы забывчивые хозяева котиков дублировали записи о своих любимцах в БД. Поэтому реализовали проверку уникальности записей о котиках на примере двух полей: owner и name. В базе данных не должно быть двух или более записей, у которых имя котика и хозяин совпадают.

Эти ограничения реализуются на уровне модели: они описываются в классе Meta. Например, через атрибут unique_together:

class Cat(models.Model):
    name = models.CharField(max_length=16)
    color = models.CharField(max_length=16, choices=CHOICES)
    birth_year = models.IntegerField()
    owner = models.ForeignKey(
        User, related_name='cats', on_delete=models.CASCADE)
    achievements = models.ManyToManyField(Achievement, through='AchievementCat')

    class Meta:
        unique_together = ('name', 'owner')

    def __str__(self):
        return self.name 

Но документация Django рекомендует вместо unique_together использовать UniqueConstraint: этот способ обеспечивает большую функциональность, а unique_together может быть признан устаревшим в будущем.

class Cat(models.Model):
    name = models.CharField(max_length=16)
    color = models.CharField(max_length=16, choices=CHOICES)
    birth_year = models.IntegerField()
    owner = models.ForeignKey(
        User, related_name='cats', on_delete=models.CASCADE)
    achievements = models.ManyToManyField(Achievement, through='AchievementCat')

    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=['name', 'owner'],
                name='unique_name_owner'
            )
        ]

    def __str__(self):
        return self.name 

Такую же проверку нужно реализовать и на уровне сериализатора.

В классе Meta сериализатора нужно указать опциональное поле validators, значением которого будет список валидаторов. Сейчас нужен только один валидатор: UniqueTogetherValidator.

У него есть два обязательных аргумента и один необязательный:

  • queryset, обязательный: это queryset, для которого должна быть проверена уникальность.
  • fields, обязательный: список или кортеж имён полей сериализатора, которые должны составлять уникальный набор.
  • message: сообщение об ошибке на случай, если данные не прошли валидацию.
class CatSerializer(serializers.ModelSerializer):
    achievements = AchievementSerializer(many=True, required=False)
    color = serializers.ChoiceField(choices=CHOICES)
    age = serializers.SerializerMethodField()
    owner = serializers.PrimaryKeyRelatedField(read_only=True)

    class Meta:
        model = Cat
        fields = ('id', 'name', 'color', 'birth_year', 'achievements', 'owner',
                  'age')

        validators = [
            UniqueTogetherValidator(
                queryset=Cat.objects.all(),
                fields=('name', 'owner')
            )
        ]

    ... 

Если при работе с ModelSerializer в целевой модели указан параметр unique_together, то валидатор UniqueTogetherValidator в сериализаторе можно не прописывать — он будет применён автоматически.

Если же требуется осуществлять подобную проверку только на уровне сериализатора, не устанавливая ограничения в модели, — в сериализаторе нужно явно указывать необходимые валидаторы.

После изменений в модели создадим и применим миграции — и сделаем запрос на добавление нового котика. Начнём с такого, которого точно нет в БД.

Ошибка! Странное дело: DRF сообщает, что поле owner — is required, обязательное. Как так, мы же только что назначили его необязательным!

Фокус ещё и в том, что если в перечне полей валидатора вместо поля owner указать какое-либо другое поле из модели, то всё будет работать: валидатор всё проверит, записи создадутся. А вот с полем owner — нет.

Давайте разбираться что тут происходит.

Поле owner — необязательное, оно может отсутствовать в запросе (либо будет игнорироваться, если присутствует). Мы переопределили метод save(), теперь туда явным образом передаются данные для поля owner. Это происходит на этапе создания нового экземпляра класса.

Но этому этапу предшествует запуск валидаторов, и в этот момент сериализатор ещё не знает о новом содержимом поля owner: оно там просто отсутствует. Но валидатору это поле нужно: без него он не может выполнить проверку.

Выйти из сложившейся ситуации помогут дополнительные опции сериализатора: скрытые поля и значения по умолчанию.

Скрытые поля и значения по умолчанию

Для полей сериализатора можно указать значения по умолчанию. В этом случае, если в запросе будет отсутствовать требуемое поле, будет использовано заранее указанное значение.

Для поля owner можно указать значение по умолчанию, передав в него объект пользователя, отправившего запрос. Для определения пользователя есть встроенный класс CurrentUserDefault — именно его и нужно указать в качестве значения по умолчанию для поля owner.

Существует два варианта использования значений по умолчанию в сериализаторе для решения возникшей задачи.

  1. Можно переопределить поле owner, указав ему тип HiddenField (скрытое поле), и передать ему значение по умолчанию CurrentUserDefault.HiddenField не принимает передаваемые в запросе данные и всегда возвращает значение по умолчанию в словарь validated_data в сериализаторе.
class CatSerializer(serializers.ModelSerializer):
    ...
    owner = serializers.HiddenField(default=serializers.CurrentUserDefault())
    ... 

В этом случае поле owner всегда будет присутствовать в словаре validated_data (и больше нет необходимости передавать его значение в метод save() во вьюсете), но его нельзя будет использовать в ответах сериализатора, даже если это поле будет явно указано в списке необходимых полей: оно скрытое, такие поля не попадают в ответ.

  1. Можно переопределить поле owner, указав ему любой стандартный тип поля, но обязательно передать параметры read_only=True и default=<значение_по_умолчанию> (для поля owner это будет default=serializers.CurrentUserDefault()).
class CatSerializer(serializers.ModelSerializer):
    ...
    owner = serializers.PrimaryKeyRelatedField(
        read_only=True, default=serializers.CurrentUserDefault())
    ... 

В этом варианте поле owner может быть использовано на выходе сериализатора, но без передачи его значения в метод save() во вьюсете тут уже не обойтись.

В обоих случаях проблема будет решена, новые записи начнут добавляться в БД после обработки запросов и валидация UniqueTogetherValidator будет работать корректно.

Попробуем дважды добавить одного и того же котика; при попытке добавить его второй раз получим сообщение об ошибке:

Важно: класс UniqueTogetherValidator всегда накладывает неявное ограничение: все поля сериализатора, к которым применён этот валидатор, обрабатываются как обязательные. Поля со значением default — исключение: они всегда предоставляют значение, даже если пользователь не передал их в запросе.





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

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