Шаг 05. Сериализаторы DRF

📁 Серия: Капстоун C ⏱️ ~50 мин 🎯 Сложность: Средняя
#ModelSerializer #validate #nested #read_only_fields #DRF

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

Цель: Написать DRF-сериализаторы для всех моделей. ModelSerializer автоматически берёт поля из модели. Добавляем валидацию, вложенные сериализаторы для read (детали пользователя), read_only_fields для автозаполняемых полей.

  • Файлы: apps/tasks/serializers.py, apps/projects/serializers.py, apps/users/serializers.py
  • Результат: сериализаторы проходят тесты в shell; корректная валидация входных данных

🎯 Цель этапа

Сериализаторы DRF — слой между HTTP-запросом и моделями Django. Они решают две задачи: десериализация (JSON → Python-объект с валидацией) и сериализация (Python-объект → JSON для ответа). ModelSerializer делает большую часть работы автоматически.

💡 Закрытие callout-verify уроков 26/29: В основных уроках по DRF сериализаторы были показаны кратко. Здесь мы демонстрируем полный паттерн: read/write сериализаторы, вложенные поля, метод validate_field() для одного поля и validate() для кросс-полевой валидации.

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

  • Сериализаторы для User (регистрация + профиль)
  • Сериализаторы для Project (list + detail с вложенными участниками)
  • Сериализаторы для Task (list + create/update с валидацией)
  • Сериализатор для Comment

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

ФайлДействиеОписание
apps/users/serializers.pyСоздатьUserProfileSerializer, UserRegisterSerializer
apps/projects/serializers.pyСоздатьProjectSerializer (list + detail)
apps/tasks/serializers.pyСоздатьTaskSerializer, CommentSerializer

🔨 Шаги

1. Сериализатор пользователя

📄 apps/users/serializers.py
# apps/users/serializers.py
from django.contrib.auth import get_user_model
from rest_framework import serializers

User = get_user_model()


class UserProfileSerializer(serializers.ModelSerializer):
    """
    Публичный профиль пользователя — только для чтения.
    Используется как вложенный сериализатор в Task/Comment.
    """

    class Meta:
        model = User
        fields = ("id", "email", "username", "bio", "avatar")
        read_only_fields = ("id", "email", "username")


class UserRegisterSerializer(serializers.ModelSerializer):
    """
    Регистрация нового пользователя.
    password — write_only, не возвращается в ответе.
    password2 — только для проверки совпадения.
    """

    password = serializers.CharField(write_only=True, min_length=8)
    password2 = serializers.CharField(write_only=True)

    class Meta:
        model = User
        fields = ("email", "username", "password", "password2", "bio", "avatar")

    def validate(self, attrs: dict) -> dict:
        """Кросс-полевая валидация: пароли должны совпадать."""
        if attrs["password"] != attrs["password2"]:
            raise serializers.ValidationError(
                {"password2": "Пароли не совпадают."}
            )
        return attrs

    def create(self, validated_data: dict) -> User:
        """
        Удаляем password2 перед созданием.
        Используем create_user(), чтобы пароль был захеширован.
        """
        validated_data.pop("password2")
        password = validated_data.pop("password")
        user = User(**validated_data)
        user.set_password(password)
        user.save()
        return user
💡 write_only=True для password: Поле с write_only=True принимается при входящих данных (POST/PATCH), но не включается в исходящий JSON-ответ. Никогда не возвращайте хеши паролей в API!

2. Сериализатор проекта

📄 apps/projects/serializers.py
# apps/projects/serializers.py
from rest_framework import serializers
from .models import Project
from apps.users.serializers import UserProfileSerializer


class ProjectListSerializer(serializers.ModelSerializer):
    """
    Компактное представление проекта — для списка (GET /api/projects/).
    owner — вложенный сериализатор: возвращает объект, не ID.
    """

    owner = UserProfileSerializer(read_only=True)
    members_count = serializers.SerializerMethodField()

    class Meta:
        model = Project
        fields = (
            "id", "name", "description", "owner",
            "members_count", "is_active", "created_at",
        )
        read_only_fields = ("id", "owner", "created_at")

    def get_members_count(self, obj: Project) -> int:
        return obj.members.count()


class ProjectDetailSerializer(serializers.ModelSerializer):
    """
    Детальное представление проекта — для GET /api/projects/{id}/.
    Включает список участников (вложенный).
    """

    owner = UserProfileSerializer(read_only=True)
    members = UserProfileSerializer(many=True, read_only=True)
    # Поле только для записи: принимаем список ID участников
    members_ids = serializers.PrimaryKeyRelatedField(
        many=True,
        write_only=True,
        queryset=__import__('django.contrib.auth', fromlist=['get_user_model']).get_user_model().objects.all(),
        source="members",
        required=False,
    )

    class Meta:
        model = Project
        fields = (
            "id", "name", "description", "owner",
            "members", "members_ids",
            "is_active", "created_at", "updated_at",
        )
        read_only_fields = ("id", "owner", "created_at", "updated_at")

    def validate_name(self, value: str) -> str:
        """Валидация одного поля: имя проекта не должно быть пустым."""
        if not value.strip():
            raise serializers.ValidationError("Имя проекта не может быть пустым.")
        return value.strip()

    def create(self, validated_data: dict) -> Project:
        """
        При создании автоматически устанавливаем owner из request.user.
        request доступен через self.context["request"].
        """
        members = validated_data.pop("members", [])
        request = self.context.get("request")
        project = Project.objects.create(
            owner=request.user,
            **validated_data,
        )
        if members:
            project.members.set(members)
        return project

3. Сериализаторы задачи и комментария

📄 apps/tasks/serializers.py
# apps/tasks/serializers.py
from rest_framework import serializers
from .models import Task, Comment
from apps.users.serializers import UserProfileSerializer


class CommentSerializer(serializers.ModelSerializer):
    """Комментарий к задаче."""

    author = UserProfileSerializer(read_only=True)

    class Meta:
        model = Comment
        fields = ("id", "author", "text", "created_at", "updated_at")
        read_only_fields = ("id", "author", "created_at", "updated_at")

    def create(self, validated_data: dict) -> Comment:
        """Автор — текущий пользователь из request."""
        validated_data["author"] = self.context["request"].user
        return super().create(validated_data)


class TaskListSerializer(serializers.ModelSerializer):
    """
    Компактное представление задачи — для GET /api/tasks/.
    Статус и приоритет возвращаем в виде display-значений (читаемых).
    """

    assignee = UserProfileSerializer(read_only=True)
    status_display = serializers.CharField(
        source="get_status_display", read_only=True
    )
    priority_display = serializers.CharField(
        source="get_priority_display", read_only=True
    )

    class Meta:
        model = Task
        fields = (
            "id", "title", "project", "assignee",
            "status", "status_display",
            "priority", "priority_display",
            "due_date", "is_deleted", "created_at",
        )
        read_only_fields = ("id", "status_display", "priority_display", "created_at")


class TaskDetailSerializer(serializers.ModelSerializer):
    """
    Полное представление задачи с комментариями.
    Используется для GET /api/tasks/{id}/ и для создания/обновления.
    """

    assignee = UserProfileSerializer(read_only=True)
    assignee_id = serializers.PrimaryKeyRelatedField(
        write_only=True,
        queryset=__import__('django.contrib.auth', fromlist=['get_user_model']).get_user_model().objects.all(),
        source="assignee",
        required=False,
        allow_null=True,
    )
    comments = CommentSerializer(many=True, read_only=True)
    comments_count = serializers.SerializerMethodField()

    class Meta:
        model = Task
        fields = (
            "id", "title", "description", "project",
            "assignee", "assignee_id",
            "status", "priority", "due_date",
            "is_deleted", "comments", "comments_count",
            "created_at", "updated_at",
        )
        read_only_fields = (
            "id", "assignee", "comments",
            "comments_count", "created_at", "updated_at",
        )

    def get_comments_count(self, obj: Task) -> int:
        return obj.comments.count()

    def validate_due_date(self, value):
        """Срок выполнения не может быть в прошлом при создании."""
        from django.utils import timezone
        if value and value < timezone.now().date():
            raise serializers.ValidationError(
                "Срок выполнения не может быть в прошлом."
            )
        return value

    def validate(self, attrs: dict) -> dict:
        """
        Кросс-полевая валидация: задача не может быть 'done' без исполнителя.
        Срабатывает после validate_<field>() для всех полей.
        """
        status = attrs.get("status", getattr(self.instance, "status", None))
        assignee = attrs.get("assignee", getattr(self.instance, "assignee", None))
        if status == Task.Status.DONE and assignee is None:
            raise serializers.ValidationError(
                "Нельзя закрыть задачу без назначенного исполнителя."
            )
        return attrs
💡 Паттерн «два сериализатора»: list + detail: TaskListSerializer — компактный, для списка (нет тяжёлых вложенных comments). TaskDetailSerializer — полный, для одной записи (включает комментарии). Во ViewSet выбираем нужный через get_serializer_class() (реализуем на шаге 06).
⚠️ Проверить по документации: Использование __import__ для get_user_model() внутри queryset — это workaround для статических объявлений поля. Лучшая практика: объявить queryset лениво через LazyObject или через переопределение __init__, либо через сериализаторный метод. Проверьте актуальную рекомендацию в документации DRF: PrimaryKeyRelatedField.

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

validate_field() vs validate()

DRF предоставляет два уровня валидации в сериализаторе:

  • validate_<field_name>(self, value) — вызывается для одного поля после его type-coercion. Получает и должен вернуть значение. Пример: validate_due_date, validate_name.
  • validate(self, attrs) — вызывается после всех validate_field, получает словарь всех прошедших валидацию полей. Используется для кросс-полевых проверок.

read_only_fields и context

Поля в read_only_fields игнорируются при десериализации (входящих данных). Это защищает от того, чтобы клиент подделал owner или created_at.

self.context["request"] — сериализатор получает контекст от ViewSet. В нём доступен текущий пользователь (request.user). Это стандартный способ получить «текущего пользователя» внутри сериализатора.

SerializerMethodField

SerializerMethodField — read-only поле, вычисляемое методом get_<field_name>(self, obj). Подходит для агрегатов (members_count, comments_count) и любых вычислений поверх объекта.

⚠️ N+1 и вложенные сериализаторы: Вложенные сериализаторы (assignee = UserProfileSerializer(read_only=True)) делают отдельный SQL-запрос для каждого объекта в списке, если не применить select_related("assignee") в queryset ViewSet. На шаге 06 мы добавим оптимизацию в ViewSet.

✅ Проверка

Тест сериализаторов в shell

💻 Django shell
from apps.tasks.models import Task
from apps.tasks.serializers import TaskListSerializer, TaskDetailSerializer
from apps.projects.models import Project

# Берём задачу из БД (создайте через Admin, если нет)
task = Task.objects.select_related("assignee", "project").first()

# Тест TaskListSerializer
serializer = TaskListSerializer(task)
import json
print(json.dumps(serializer.data, ensure_ascii=False, indent=2, default=str))

# Тест валидации TaskDetailSerializer
bad_data = {
    "title": "Тест",
    "project": task.project_id,
    "status": "done",      # done без assignee — должна быть ошибка
    "assignee_id": None,
}

from unittest.mock import MagicMock
mock_request = MagicMock()
mock_request.user = task.assignee
s = TaskDetailSerializer(data=bad_data, context={"request": mock_request})
print(s.is_valid())        # False
print(s.errors)            # {'non_field_errors': ['Нельзя закрыть задачу...']}
Успех: is_valid() возвращает False, ошибка "Нельзя закрыть задачу без назначенного исполнителя." в s.errors.

➡️ Что дальше

На следующем шаге мы подключаем ViewSets и Router, реализуем CRUD-эндпоинты и проверяем их через curl.

  • Готово: все сериализаторы написаны, валидация работает
  • Далее (шаг 06): apps/tasks/views.py, apps/tasks/urls.py, config/urls.py — ViewSet + DefaultRouter
  • После шага 06 API полностью функционален (CRUD без аутентификации)