Шаг 06. ViewSets, Router и CRUD-эндпоинты
⚡ Кратко: что делаем на этом шаге
Цель: Реализовать ModelViewSet для Project, Task, Comment. Подключить DefaultRouter. Реализовать get_serializer_class() для list/detail. Подключить маршруты в config/urls.py. Проверить все CRUD через curl.
- Файлы:
apps/tasks/views.py,apps/tasks/urls.py,apps/projects/views.py,apps/projects/urls.py,config/urls.py - Команды:
curl http://127.0.0.1:8000/api/tasks/ - Результат: GET /api/projects/, /api/tasks/, /api/comments/ возвращают JSON
🎯 Цель этапа
ModelViewSet — самый мощный класс DRF: реализует полный CRUD
(list, create, retrieve, update,
partial_update, destroy) через единый класс.
DefaultRouter автоматически генерирует URL-паттерны из ViewSet.
get_serializer_class(), get_queryset() с
select_related, переопределение perform_create().
После этого шага у нас будет
- Рабочий CRUD API для Project, Task, Comment
- Роутер автоматически создаёт все URL-маршруты
- Разные сериализаторы для list и detail (через
get_serializer_class) - Оптимизированные queryset с
select_related
📄 Затрагиваемые файлы
| Файл | Действие | Описание |
|---|---|---|
apps/tasks/views.py | Заполнить | TaskViewSet, CommentViewSet |
apps/tasks/urls.py | Создать | Router для tasks и comments |
apps/projects/views.py | Заполнить | ProjectViewSet |
apps/projects/urls.py | Создать | Router для projects |
config/urls.py | Изменить | Подключить api/ маршруты |
🔨 Шаги
1. ViewSet для проектов
# apps/projects/views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Project
from .serializers import ProjectListSerializer, ProjectDetailSerializer
class ProjectViewSet(viewsets.ModelViewSet):
"""
CRUD для проектов.
list/create → GET/POST /api/projects/
retrieve → GET /api/projects/{id}/
update → PUT/PATCH /api/projects/{id}/
destroy → DELETE /api/projects/{id}/
"""
permission_classes = [IsAuthenticatedOrReadOnly]
def get_queryset(self):
"""
Возвращаем только активные проекты.
select_related("owner") — избегаем N+1 при сериализации owner.
prefetch_related("members") — для вложенного списка участников.
"""
return (
Project.objects
.filter(is_active=True)
.select_related("owner")
.prefetch_related("members")
)
def get_serializer_class(self):
"""
Разные сериализаторы для list и detail:
- list/create: компактный ProjectListSerializer
- retrieve/update/destroy: полный ProjectDetailSerializer
"""
if self.action in ("list", "create"):
return ProjectListSerializer
return ProjectDetailSerializer
2. URL-маршруты для проектов
# apps/projects/urls.py
from rest_framework.routers import DefaultRouter
from .views import ProjectViewSet
router = DefaultRouter()
router.register(r"projects", ProjectViewSet, basename="project")
urlpatterns = router.urls
3. ViewSet для задач и комментариев
# apps/tasks/views.py
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from .models import Task, Comment
from .serializers import (
TaskListSerializer, TaskDetailSerializer, CommentSerializer,
)
class TaskViewSet(viewsets.ModelViewSet):
"""
CRUD для задач.
Скрываем soft-deleted задачи (is_deleted=True) от обычных запросов.
Оптимизируем queryset через select_related.
"""
permission_classes = [IsAuthenticatedOrReadOnly]
def get_queryset(self):
return (
Task.objects
.filter(is_deleted=False)
.select_related("project", "assignee")
.prefetch_related("comments")
)
def get_serializer_class(self):
if self.action == "list":
return TaskListSerializer
return TaskDetailSerializer
def perform_create(self, serializer):
"""
perform_create вызывается при POST /api/tasks/.
Здесь можно добавить автоматические поля (например, created_by).
Пока просто сохраняем — permissions добавим на шаге 09.
"""
serializer.save()
def perform_destroy(self, instance: Task):
"""
Soft-delete: вместо физического удаления ставим is_deleted=True.
DELETE /api/tasks/{id}/ → задача помечается удалённой, не стирается из БД.
"""
instance.is_deleted = True
instance.save(update_fields=["is_deleted", "updated_at"])
class CommentViewSet(viewsets.ModelViewSet):
"""
CRUD для комментариев.
Комментарии фильтруем по задаче (task_id из URL или query_param).
"""
serializer_class = CommentSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
def get_queryset(self):
qs = Comment.objects.select_related("author", "task")
# Фильтрация по задаче через query-параметр: /api/comments/?task=5
task_id = self.request.query_params.get("task")
if task_id:
qs = qs.filter(task_id=task_id)
return qs
4. URL-маршруты для задач
# apps/tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet, CommentViewSet
router = DefaultRouter()
router.register(r"tasks", TaskViewSet, basename="task")
router.register(r"comments", CommentViewSet, basename="comment")
urlpatterns = router.urls
5. Корневые URL — подключаем API
# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
# Все API-маршруты под префиксом /api/
path("api/", include("apps.projects.urls")),
path("api/", include("apps.tasks.urls")),
# Маршруты аутентификации добавим на шаге 08
]
router.register(r"tasks", TaskViewSet, basename="task")
роутер создаёт следующие маршруты:
| URL | Метод | Действие ViewSet |
|---|---|---|
/api/tasks/ | GET | list |
/api/tasks/ | POST | create |
/api/tasks/{id}/ | GET | retrieve |
/api/tasks/{id}/ | PUT/PATCH | update / partial_update |
/api/tasks/{id}/ | DELETE | destroy |
🧠 Объяснение логики
get_serializer_class() — разные сериализаторы для разных действий
self.action содержит имя текущего действия: "list",
"create", "retrieve", "update", и т.д.
Метод get_serializer_class() позволяет вернуть разный сериализатор
в зависимости от действия. Типичный паттерн:
list→ компактный сериализатор (без тяжёлых вложенных данных)retrieve→ полный сериализатор (с комментариями, участниками)create/update→ write-сериализатор
perform_destroy — soft-delete
По умолчанию ModelViewSet.destroy() вызывает instance.delete()
(физическое удаление). Переопределяя perform_destroy(), мы делаем
soft-delete: устанавливаем is_deleted=True и сохраняем.
update_fields ограничивает UPDATE только нужными полями — эффективнее.
get_queryset и N+1
Всегда добавляйте select_related для FK-полей и
prefetch_related для M2M и reverse FK, если сериализатор их включает.
Иначе для каждого объекта в списке из 100 элементов будет 100 дополнительных
SQL-запросов.
basename определяет имена URL (task-list,
task-detail и т.д.). Если ViewSet не имеет атрибута
queryset (использует get_queryset()), Django не может
автоматически определить basename — его нужно указать явно.
✅ Проверка
1. Запускаем сервер
python manage.py runserver
2. Проверка списка задач
curl -s http://127.0.0.1:8000/api/tasks/ | python -m json.tool
[
{
"id": 1,
"title": "Первая задача",
"project": 1,
"assignee": {
"id": 1,
"email": "admin@example.com",
"username": "admin",
"bio": "",
"avatar": ""
},
"status": "todo",
"status_display": "К выполнению",
"priority": "medium",
"priority_display": "Средний",
"due_date": null,
"is_deleted": false,
"created_at": "2026-06-09T10:00:00Z"
}
]
3. Создание задачи через curl
curl -s -X POST http://127.0.0.1:8000/api/tasks/ \
-H "Content-Type: application/json" \
-d '{
"title": "Новая задача через API",
"project": 1,
"priority": "high"
}' | python -m json.tool
"id": 2, "status": "todo"
и остальными полями новой задачи.
4. Проверка browsable API
Откройте в браузере http://127.0.0.1:8000/api/ — DRF покажет browsable API с навигацией по всем эндпоинтам.
5. Проверка soft-delete
# Удаляем задачу с ID=2
curl -s -X DELETE http://127.0.0.1:8000/api/tasks/2/
# Ответ: 204 No Content
# Проверяем: задача исчезла из API
curl -s http://127.0.0.1:8000/api/tasks/2/
# Ответ: 404 Not Found
# Но в БД она есть! (is_deleted=True)
# python manage.py shell -c "from apps.tasks.models import Task; print(Task.objects.get(pk=2).is_deleted)"
# True
Диагностика: если что-то пошло не так
AssertionError: basename argument not specified— добавьтеbasename="task"вrouter.register()404 /api/tasks/— проверьте, чтоconfig/urls.pyвключаетapps.tasks.urls- 500 при GET списка — скорее всего проблема с
get_queryset(); проверьте import сериализаторов и modelей
➡️ Что дальше
Этапы 01–06 завершены: у нас есть полноценный CRUD API. Часть 2 серии (этапы 07–12) добавит фильтрацию, JWT-аутентификацию, permissions, сигналы, логирование и Swagger.
- Готово: полный CRUD API для Project, Task, Comment; soft-delete для Task; browsable API доступен
- Часть 2 (этапы 07–12): фильтрация → JWT → permissions → сигналы → логирование → Swagger
- Текущее состояние проекта: API работает без аутентификации (исправим на шаге 08)
apps/users/: кастомный User (AbstractUser, email=USERNAME_FIELD)apps/projects/: модель Project (FK→User owner, M2M→User members), ViewSet, Routerapps/tasks/: модели Task (FK→Project, FK→User assignee, soft-delete, TextChoices) и Comment (FK→Task, FK→User), ViewSets, Routerconfig/urls.py: /api/projects/, /api/tasks/, /api/comments/