Шаг 11. Логирование, обработка ошибок и транзакции
⚡ Кратко: что делаем на этом шаге
Цель: Добавить структурированное логирование (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-приложение имеет три свойства: логирует события, отвечает единообразными ошибками и защищает данные транзакциями. На этом шаге добавим все три.
Три задачи шага
-
Логирование (LOGGING) — структурированный вывод событий.
Django использует стандартный модуль Python
loggingи настраивается через словарьLOGGINGв settings. -
Кастомный exception handler — DRF по умолчанию возвращает
разные форматы ошибок (иногда
{"detail": "..."}, иногда{"field": ["..."]}). Наш handler приводит всё к единому виду. -
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
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
"""
Кастомный обработчик исключений 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
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
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()}]"
objects = SoftDeleteManager(), Django Admin
будет видеть только активные задачи (потому что Admin использует objects).
Чтобы видеть удалённые в Admin — переопределите get_queryset()
в TaskAdmin, используя Task.all_objects.all().
5. transaction.atomic в критичных операциях
# 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 оборачивает весь метод в транзакцию.
Если в процессе выполнения метода будет брошено исключение,
все изменения БД внутри метода будут откатаны.
Альтернатива — контекстный менеджер:
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 -s http://127.0.0.1:8000/api/tasks/9999/ \
-H "Authorization: Bearer $ACCESS" \
| python -m json.tool
{
"error": "Not found.",
"code": "not_found"
}
2. Ошибка валидации — 400
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
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-чеклист, деплой-заметки