Шаг 06. ViewSets, Router и CRUD-эндпоинты

📁 Серия: Капстоун C ⏱️ ~50 мин 🎯 Сложность: Средняя
#ModelViewSet #DefaultRouter #get_serializer_class #get_queryset #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.

💡 Закрытие callout-verify уроков 33/36: ViewSets и Router — именно те темы, которые в основном курсе требовали «проверить по документации». Здесь мы показываем production-паттерны: 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
# 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
# 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
# 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
# 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
# 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
]
💡 DefaultRouter автогенерация URL: После router.register(r"tasks", TaskViewSet, basename="task") роутер создаёт следующие маршруты:
URLМетодДействие ViewSet
/api/tasks/GETlist
/api/tasks/POSTcreate
/api/tasks/{id}/GETretrieve
/api/tasks/{id}/PUT/PATCHupdate / partial_update
/api/tasks/{id}/DELETEdestroy

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

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 в router.register(): Параметр basename определяет имена URL (task-list, task-detail и т.д.). Если ViewSet не имеет атрибута queryset (использует get_queryset()), Django не может автоматически определить basename — его нужно указать явно.

✅ Проверка

1. Запускаем сервер

💻 Терминал
python manage.py runserver

2. Проверка списка задач

💻 curl
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 (POST)
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
Ожидаемый ответ: JSON с "id": 2, "status": "todo" и остальными полями новой задачи.

4. Проверка browsable API

Откройте в браузере http://127.0.0.1:8000/api/ — DRF покажет browsable API с навигацией по всем эндпоинтам.

5. Проверка soft-delete

💻 curl (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, Router
  • apps/tasks/: модели Task (FK→Project, FK→User assignee, soft-delete, TextChoices) и Comment (FK→Task, FK→User), ViewSets, Router
  • config/urls.py: /api/projects/, /api/tasks/, /api/comments/