Шаг 10. Сигналы Django и email-уведомления
⚡ Кратко: что делаем на этом шаге
Цель: Подключить сигнал post_save на модель Task. При назначении исполнителя — отправить email через send_mail(). В разработке использовать console email backend (письма в stdout терминала).
- Файлы:
apps/tasks/signals.py(новый),apps/tasks/apps.py(обновитьready()),config/settings.py(EMAIL_BACKEND) - Защита от рекурсии: проверять
createdи изменение поляassignee - Проверка: назначить задачу через PATCH → увидеть письмо в консоли runserver
🎯 Цель этапа
Сигналы Django — механизм «pub/sub» внутри приложения. Когда происходит событие (сохранение объекта, удаление), Django рассылает сигнал. Обработчики (receivers) реагируют на этот сигнал, не зная о том, кто его отправил.
На этом шаге мы отправляем email исполнителю, когда ему назначают задачу — без изменения кода ViewSet. Это «чистая» реакция на событие.
- Задача СОЗДАНА с assignee → уведомить assignee
- Задача ОБНОВЛЕНА и assignee ИЗМЕНИЛСЯ → уведомить нового assignee
- Задача обновлена, но assignee НЕ изменился → НЕ отправлять (нет смысла)
После этого шага
- При создании или обновлении задачи с assignee — email в консоли (dev-режим)
- Сигналы подключены через
AppConfig.ready()— правильный паттерн Django - Защита от рекурсии и лишних писем
📄 Затрагиваемые файлы
| Файл | Действие | Описание |
|---|---|---|
apps/tasks/signals.py | Создать | Обработчик post_save для Task |
apps/tasks/apps.py | Обновить | Регистрация сигналов в ready() |
config/settings.py | Обновить | EMAIL_BACKEND = console |
🔨 Шаги
1. Настройка email backend в settings.py
# config/settings.py
# Email — для разработки: письма выводятся в консоль runserver
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Для production заменить на реальный SMTP:
# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# EMAIL_HOST = env("EMAIL_HOST", default="smtp.gmail.com")
# EMAIL_PORT = env.int("EMAIL_PORT", default=587)
# EMAIL_USE_TLS = True
# EMAIL_HOST_USER = env("EMAIL_HOST_USER")
# EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
# DEFAULT_FROM_EMAIL = "noreply@taskmanager.example.com"
DEFAULT_FROM_EMAIL = "noreply@taskmanager.local"
2. Файл сигналов
# apps/tasks/signals.py
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.mail import send_mail
from django.conf import settings
logger = logging.getLogger(__name__)
@receiver(post_save, sender="tasks.Task")
def notify_assignee_on_task_save(sender, instance, created, **kwargs):
"""
Сигнал post_save на модель Task.
Отправляет email исполнителю при:
- Создании задачи с указанным assignee
- Обновлении задачи с изменением assignee
Защита от лишних писем:
- Пропускаем, если assignee не задан
- При обновлении отправляем только если assignee изменился
(используем update_fields для отслеживания)
"""
assignee = instance.assignee
if not assignee or not assignee.email:
# Нет исполнителя — нечего отправлять
return
# При обновлении: проверяем, изменился ли assignee
# Если update_fields задан и "assignee" в нём — значит, assignee изменился
# Если update_fields не задан (полное сохранение) — отправляем при created
if not created:
update_fields = kwargs.get("update_fields")
if update_fields is not None and "assignee" not in update_fields:
# Сохранение не затрагивало поле assignee — пропускаем
return
if update_fields is None:
# Полное save() при обновлении — пытаемся определить изменение через БД
# В production лучше использовать django-model-utils FieldTracker
# Здесь используем простой вариант: отправляем при любом update без update_fields
# ⚠️ Это может привести к лишним письмам при bulk-обновлениях
pass
subject = f"Вам назначена задача: {instance.title}"
body = (
f"Здравствуйте, {assignee.username}!\n\n"
f"Вам назначена задача в проекте «{instance.project.name}»:\n\n"
f" Задача: {instance.title}\n"
f" Приоритет: {instance.get_priority_display()}\n"
f" Статус: {instance.get_status_display()}\n"
f" Срок: {instance.due_date or 'не указан'}\n\n"
f"Зайдите в систему, чтобы просмотреть детали.\n"
)
try:
send_mail(
subject=subject,
message=body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[assignee.email],
fail_silently=False,
)
logger.info(
"Email sent to assignee %s for task %d",
assignee.email,
instance.pk,
)
except Exception as exc:
# Не даём ошибке email сломать основной flow
logger.error(
"Failed to send email to %s for task %d: %s",
assignee.email,
instance.pk,
exc,
)
@receiver(post_save, sender="tasks.Task") используется строковая
ссылка вместо прямого импорта класса модели. Это позволяет избежать проблем
с циклическими импортами. Django резолвит строку при старте приложения.
Формат: "app_label.ModelName".
3. Регистрация сигналов через AppConfig.ready()
# apps/tasks/apps.py
from django.apps import AppConfig
class TasksConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.tasks"
verbose_name = "Задачи"
def ready(self):
"""
ready() вызывается Django один раз при старте приложения.
Здесь регистрируем сигналы через импорт модуля signals.
ВАЖНО: импорт внутри ready() — это стандартный паттерн Django.
Не импортируй signals на уровне модуля (вне ready()) — это вызовет
циклические импорты при инициализации.
"""
import apps.tasks.signals # noqa: F401
ready() вызывается после того, как все модели уже загружены.
Если импортировать signals.py на уровне модуля — возможны
циклические импорты, так как signals.py обычно импортирует модели,
а модели могут не быть загружены в момент импорта.
4. Убедитесь, что apps.py подключён
# apps/tasks/__init__.py
# Указываем Django использовать TasksConfig как конфигурацию приложения
default_app_config = "apps.tasks.apps.TasksConfig"
Или в INSTALLED_APPS укажите полный путь к AppConfig:
INSTALLED_APPS = [
# ...
"apps.tasks.apps.TasksConfig", # полный путь → Django использует TasksConfig.ready()
# ...
]
🧠 Объяснение логики
Жизненный цикл сигнала post_save
- Вызывается
instance.save()(или ORM-обновление) - Django выполняет INSERT/UPDATE в БД
- Django рассылает сигнал
post_saveс аргументами:sender,instance,created,update_fields - Вызываются все зарегистрированные receivers — в порядке регистрации
- Управление возвращается в ViewSet
update_fields и эффективность
В perform_destroy() (soft-delete) мы используем
save(update_fields=["is_deleted", "updated_at"]).
Это гарантирует, что сигнал post_save будет содержать
update_fields=frozenset({"is_deleted", "updated_at"}).
Наш обработчик увидит, что "assignee" не в update_fields,
и пропустит отправку email — что правильно.
fail_silently=False
fail_silently=False означает, что при ошибке отправки будет брошено
исключение. Мы оборачиваем вызов в try/except и логируем ошибку,
не позволяя ей сломать HTTP-ответ клиенту. Это «безопасный сбой» на уровне
приложения: API работает, email просто не отправлен, ошибка залогирована.
Console backend — не для production
django.core.mail.backends.console.EmailBackend выводит всё содержимое
писем в stdout процесса runserver. Это идеально для разработки — не нужен
настоящий SMTP. Для production замените на smtp.EmailBackend.
✅ Проверка
1. Запуск сервера (письма будут в этом терминале)
python manage.py runserver
2. Создание задачи с assignee
ACCESS=$(curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "Str0ngPass!"}' \
| python -c "import sys,json; print(json.load(sys.stdin)['access'])")
curl -s -X POST http://127.0.0.1:8000/api/tasks/ \
-H "Authorization: Bearer $ACCESS" \
-H "Content-Type: application/json" \
-d '{
"title": "Написать тесты",
"project": 1,
"assignee": 2,
"priority": "high"
}' | python -m json.tool
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: Вам назначена задача: Написать тесты
From: noreply@taskmanager.local
To: user@example.com
Date: Tue, 09 Jun 2026 10:00:00 -0000
Message-ID: <...>
Здравствуйте, testuser!
Вам назначена задача в проекте «Мой проект»:
Задача: Написать тесты
Приоритет: Высокий
Статус: К выполнению
Срок: не указан
Зайдите в систему, чтобы просмотреть детали.
3. Обновление задачи без смены assignee (email НЕ должен отправляться)
curl -s -X PATCH http://127.0.0.1:8000/api/tasks/1/ \
-H "Authorization: Bearer $ACCESS" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}' \
| python -m json.tool
# В консоли runserver НЕ должен появиться новый email
Диагностика ошибок
- Сигнал не вызывается — убедитесь, что
apps.pyзарегистрирован через полный путь в INSTALLED_APPS или__init__.py ImproperlyConfigured: App doesn't have a ready()— проверьте, чтоTasksConfigнаследует отAppConfigи имеетdef ready(self)- Email не появляется в консоли — проверьте
EMAIL_BACKENDв settings.py - Дублирующиеся письма — сигнал зарегистрирован несколько раз; используйте
dispatch_uid="notify_assignee"в@receiver
➡️ Что дальше
Сигналы работают. Следующий шаг — добавить структурированное логирование, кастомный обработчик ошибок DRF и продемонстрировать soft-delete через менеджер объектов.
- Готово: post_save сигнал, email-уведомления, console backend
- Шаг 11: LOGGING в settings, кастомный exception handler, soft-delete менеджер, transaction.atomic