Шаг 11. Логирование, обработка ошибок и транзакции

📁 Серия: Капстоун C ⏱️ ~50 мин 🎯 Сложность: Выше средней
#LOGGING #exception_handler #SoftDeleteManager #transaction.atomic #QuerySet

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

Цель: Добавить структурированное логирование (LOGGING в settings), кастомный exception handler DRF (единый формат ошибок), soft-delete менеджер (objects/all_objects), транзакции в критичных операциях.

  • Файлы: config/settings.py (LOGGING), config/exceptions.py (новый), apps/tasks/managers.py (новый)
  • Ключевое: все ошибки API теперь в формате {"error": "...", "code": "...", "details": {...}}
  • Проверка: запрос к несуществующему URL → структурированный JSON

🎯 Цель этапа

Хорошее production-приложение имеет три свойства: логирует события, отвечает единообразными ошибками и защищает данные транзакциями. На этом шаге добавим все три.

Три задачи шага

  1. Логирование (LOGGING) — структурированный вывод событий. Django использует стандартный модуль Python logging и настраивается через словарь LOGGING в settings.
  2. Кастомный exception handler — DRF по умолчанию возвращает разные форматы ошибок (иногда {"detail": "..."}, иногда {"field": ["..."]}). Наш handler приводит всё к единому виду.
  3. Soft-delete менеджер — удобный способ работать с «удалёнными» задачами: Task.objects.all() — только активные, Task.all_objects.all() — включая удалённые.

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

ФайлДействиеОписание
config/settings.pyОбновитьLOGGING + EXCEPTION_HANDLER
config/exceptions.pyСоздатьcustom_exception_handler
apps/tasks/managers.pyСоздатьSoftDeleteQuerySet, SoftDeleteManager, AllObjectsManager
apps/tasks/models.pyОбновитьПодключить менеджеры к Task

🔨 Шаги

1. Настройка LOGGING в settings.py

📄 config/settings.py
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,

    "formatters": {
        # Подробный формат для разработки: время, уровень, модуль, сообщение
        "verbose": {
            "format": "{asctime} {levelname} {name} {message}",
            "style": "{",
        },
        # Краткий формат для простых случаев
        "simple": {
            "format": "{levelname} {message}",
            "style": "{",
        },
    },

    "handlers": {
        # Вывод в консоль (разработка)
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
        # Запись в файл (опционально для разработки, обязательно для production)
        # "file": {
        #     "class": "logging.FileHandler",
        #     "filename": BASE_DIR / "logs" / "django.log",
        #     "formatter": "verbose",
        # },
    },

    "loggers": {
        # Корневой логгер Django
        "django": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": True,
        },
        # Логгер SQL-запросов (DEBUG уровень, очень многословен)
        "django.db.backends": {
            "handlers": ["console"],
            "level": "WARNING",  # Сменить на DEBUG для просмотра SQL
            "propagate": False,
        },
        # Наше приложение — все логгеры из apps.*
        "apps": {
            "handlers": ["console"],
            "level": "DEBUG",
            "propagate": False,
        },
    },
}

# Подключаем кастомный exception handler DRF
REST_FRAMEWORK = {
    # ... существующие настройки ...
    "EXCEPTION_HANDLER": "config.exceptions.custom_exception_handler",
}

2. Кастомный exception handler

📄 config/exceptions.py
# config/exceptions.py
"""
Кастомный обработчик исключений DRF.

Стандартный DRF возвращает непоследовательные форматы:
  - {"detail": "Not found."} для 404
  - {"field": ["error message"]} для валидации
  - {"detail": "Authentication credentials were not provided."} для 401

Наш handler унифицирует всё в формат:
  {
    "error": "Краткое описание ошибки",
    "code": "machine_readable_code",
    "details": { ... }  // опционально, для ошибок валидации
  }
"""
import logging
from rest_framework.views import exception_handler
from rest_framework.response import Response
from rest_framework import status

logger = logging.getLogger(__name__)


def custom_exception_handler(exc, context):
    """
    Кастомный exception handler для DRF.

    Оборачивает стандартные ответы DRF в единый формат.
    Логирует 5xx ошибки (серверные).
    """
    # Вызываем стандартный handler — он обрабатывает APIException
    response = exception_handler(exc, context)

    if response is None:
        # Исключение не обработано DRF (например, непойманное Python-исключение)
        # Логируем и возвращаем 500
        logger.exception(
            "Unhandled exception in view %s: %s",
            context.get("view"),
            exc,
        )
        return Response(
            {
                "error": "Внутренняя ошибка сервера.",
                "code": "internal_server_error",
            },
            status=status.HTTP_500_INTERNAL_SERVER_ERROR,
        )

    # Логируем 5xx ошибки
    if response.status_code >= 500:
        logger.error(
            "5xx error in view %s: %s — %s",
            context.get("view"),
            exc,
            response.data,
        )

    # Нормализуем формат ответа
    original_data = response.data

    if isinstance(original_data, dict):
        # Стандартная ошибка типа {"detail": "..."}
        detail = original_data.get("detail", "")
        code = getattr(getattr(detail, "code", None), "__str__", lambda: "error")()

        if "detail" in original_data and len(original_data) == 1:
            # Простая ошибка с одним полем detail
            response.data = {
                "error": str(detail),
                "code": code or "error",
            }
        else:
            # Ошибка валидации с несколькими полями
            response.data = {
                "error": "Ошибка валидации данных.",
                "code": "validation_error",
                "details": original_data,
            }
    elif isinstance(original_data, list):
        # Список ошибок (редко)
        response.data = {
            "error": "Ошибка валидации данных.",
            "code": "validation_error",
            "details": original_data,
        }

    return response

3. Soft-delete менеджер для Task

📄 apps/tasks/managers.py
# apps/tasks/managers.py
from django.db import models


class SoftDeleteQuerySet(models.QuerySet):
    """
    QuerySet с методами для работы с soft-deleted объектами.
    """

    def active(self):
        """Только не удалённые задачи (is_deleted=False)."""
        return self.filter(is_deleted=False)

    def deleted(self):
        """Только удалённые задачи (is_deleted=True)."""
        return self.filter(is_deleted=True)

    def soft_delete(self):
        """Пометить все объекты в QuerySet как удалённые."""
        return self.update(is_deleted=True)

    def restore(self):
        """Восстановить все помеченные объекты."""
        return self.update(is_deleted=False)


class SoftDeleteManager(models.Manager):
    """
    Менеджер по умолчанию: возвращает только активные (не удалённые) задачи.
    Используется как Task.objects.
    """

    def get_queryset(self):
        return SoftDeleteQuerySet(self.model, using=self._db).active()


class AllObjectsManager(models.Manager):
    """
    Менеджер для доступа ко всем задачам, включая удалённые.
    Используется как Task.all_objects.
    """

    def get_queryset(self):
        return SoftDeleteQuerySet(self.model, using=self._db)

4. Подключение менеджеров к модели Task

📄 apps/tasks/models.py (обновление)
# apps/tasks/models.py
from django.db import models
from django.conf import settings
from .managers import SoftDeleteManager, AllObjectsManager


class Task(models.Model):

    class Status(models.TextChoices):
        TODO = "todo", "К выполнению"
        IN_PROGRESS = "in_progress", "В работе"
        REVIEW = "review", "На проверке"
        DONE = "done", "Выполнено"
        CANCELLED = "cancelled", "Отменено"

    class Priority(models.TextChoices):
        LOW = "low", "Низкий"
        MEDIUM = "medium", "Средний"
        HIGH = "high", "Высокий"
        CRITICAL = "critical", "Критический"

    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    project = models.ForeignKey(
        "projects.Project",
        on_delete=models.CASCADE,
        related_name="tasks",
    )
    assignee = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True, blank=True,
        related_name="assigned_tasks",
    )
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.TODO,
    )
    priority = models.CharField(
        max_length=10,
        choices=Priority.choices,
        default=Priority.MEDIUM,
    )
    due_date = models.DateField(null=True, blank=True)
    is_deleted = models.BooleanField(default=False, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    # Менеджеры: objects — только активные, all_objects — все включая удалённые
    objects = SoftDeleteManager()
    all_objects = AllObjectsManager()

    class Meta:
        verbose_name = "Задача"
        verbose_name_plural = "Задачи"
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["project", "status"]),
            models.Index(fields=["assignee", "status"]),
        ]

    def __str__(self):
        return f"{self.title} [{self.get_status_display()}]"
⚠️ SoftDeleteManager и Django Admin: Если задать objects = SoftDeleteManager(), Django Admin будет видеть только активные задачи (потому что Admin использует objects). Чтобы видеть удалённые в Admin — переопределите get_queryset() в TaskAdmin, используя Task.all_objects.all().

5. transaction.atomic в критичных операциях

📄 apps/projects/views.py (добавление)
# apps/projects/views.py — метод добавления участника
from django.db import transaction
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status as drf_status


class ProjectViewSet(viewsets.ModelViewSet):
    # ... существующий код ...

    @action(detail=True, methods=["post"], url_path="add-member")
    @transaction.atomic
    def add_member(self, request, pk=None):
        """
        POST /api/projects/{id}/add-member/
        Добавить участника в проект.

        transaction.atomic гарантирует: если что-то пойдёт не так,
        все изменения будут отменены.
        """
        project = self.get_object()
        user_id = request.data.get("user_id")

        if not user_id:
            return Response(
                {"error": "user_id обязателен.", "code": "missing_field"},
                status=drf_status.HTTP_400_BAD_REQUEST,
            )

        from django.contrib.auth import get_user_model
        User = get_user_model()

        try:
            user = User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return Response(
                {"error": "Пользователь не найден.", "code": "not_found"},
                status=drf_status.HTTP_404_NOT_FOUND,
            )

        project.members.add(user)
        return Response(
            {"message": f"{user.email} добавлен в проект."},
            status=drf_status.HTTP_200_OK,
        )
💡 @transaction.atomic как декоратор: @transaction.atomic оборачивает весь метод в транзакцию. Если в процессе выполнения метода будет брошено исключение, все изменения БД внутри метода будут откатаны. Альтернатива — контекстный менеджер: with transaction.atomic(): ... (более гибкий, для части метода).

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

Иерархия логгеров

Python logging работает по иерархии точечной нотации. Логгер apps.tasks.signals наследует настройки от apps.tasks, который наследует от apps, который от корневого root. В нашем LOGGING настроен логгер apps — все logger.info() из любого модуля внутри apps/ пойдут через него.

disable_existing_loggers: False

По умолчанию при использовании словаря LOGGING Django отключает стандартные логгеры Python. "disable_existing_loggers": False сохраняет стандартное поведение сторонних библиотек (requests, simplejwt и т.д.).

Почему два менеджера?

Django ORM использует первый определённый Manager как менеджер по умолчанию (для Admin, select_related и т.д.). Если мы хотим, чтобы Task.objects всегда возвращал только активные — делаем SoftDeleteManager первым. all_objects нужен для административных задач (восстановление, аудит).

Exception handler и logging

Важно: exception handler логирует 5xx ошибки. 4xx ошибки (Not Found, Forbidden, Validation Error) — ожидаемые события, их не логируем как ошибки. 5xx — неожиданные сбои сервера, которые требуют внимания разработчика.

✅ Проверка

1. Единый формат ошибок — 404

💻 curl
curl -s http://127.0.0.1:8000/api/tasks/9999/ \
  -H "Authorization: Bearer $ACCESS" \
  | python -m json.tool
Ожидаемый ответ (с кастомным handler):
{
  "error": "Not found.",
  "code": "not_found"
}

2. Ошибка валидации — 400

💻 curl
curl -s -X POST http://127.0.0.1:8000/api/tasks/ \
  -H "Authorization: Bearer $ACCESS" \
  -H "Content-Type: application/json" \
  -d '{"title": ""}' \
  | python -m json.tool
Ожидаемый ответ (структурированная ошибка валидации):
{
  "error": "Ошибка валидации данных.",
  "code": "validation_error",
  "details": {
    "title": ["Это поле не может быть пустым."],
    "project": ["Обязательное поле."]
  }
}

3. Soft-delete менеджер — проверка в shell

💻 Django shell
python manage.py shell -c "
from apps.tasks.models import Task

# Удаляем задачу через API (is_deleted=True)
task = Task.objects.first()
if task:
    task.is_deleted = True
    task.save(update_fields=['is_deleted'])

# objects — только активные
print('Active count:', Task.objects.count())
# all_objects — все, включая удалённые
print('All count:', Task.all_objects.count())
print('Deleted count:', Task.all_objects.deleted().count())
"

4. Логирование — проверить вывод в консоли

Запустите сервер python manage.py runserver и выполните любой запрос. В консоли должны появляться строки с временем, уровнем и именем модуля:

2026-06-09 10:00:00,000 INFO apps.tasks.signals Email sent to assignee user@example.com for task 1

Диагностика ошибок

  • Exception handler не работает — проверьте REST_FRAMEWORK["EXCEPTION_HANDLER"] в settings.py
  • Логи не появляются — убедитесь, что уровень логгера не выше, чем используемый в коде (DEBUG < INFO < WARNING < ERROR)
  • Task.all_objects — AttributeError — проверьте, что AllObjectsManager добавлен в модель

➡️ Что дальше

Проект практически готов к production. Последний шаг — автоматическая документация API через drf-spectacular (Swagger UI), финальная проверка happy-path и чеклист перед деплоем.

  • Готово: LOGGING, кастомный exception handler, soft-delete менеджер, transaction.atomic
  • Шаг 12: drf-spectacular, Swagger UI, production-чеклист, деплой-заметки