Шаг 05. Сериализаторы 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 делает большую часть работы автоматически.
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
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 принимается при входящих данных (POST/PATCH),
но не включается в исходящий JSON-ответ. Никогда не возвращайте
хеши паролей в API!
2. Сериализатор проекта
# 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
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
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) и любых вычислений
поверх объекта.
assignee = UserProfileSerializer(read_only=True))
делают отдельный SQL-запрос для каждого объекта в списке, если не применить
select_related("assignee") в queryset ViewSet.
На шаге 06 мы добавим оптимизацию в ViewSet.
✅ Проверка
Тест сериализаторов в 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 без аутентификации)