Шаг 10. Сигналы Django и email-уведомления

📁 Серия: Капстоун C ⏱️ ~40 мин 🎯 Сложность: Средняя
#post_save #signals #send_mail #AppConfig #EMAIL_BACKEND

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

Цель: Подключить сигнал 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
# 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
# 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,
        )
⚠️ Строчка sender="tasks.Task" (строка, не класс): В @receiver(post_save, sender="tasks.Task") используется строковая ссылка вместо прямого импорта класса модели. Это позволяет избежать проблем с циклическими импортами. Django резолвит строку при старте приложения. Формат: "app_label.ModelName".

3. Регистрация сигналов через AppConfig.ready()

📄 apps/tasks/apps.py
# 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()?

ready() вызывается после того, как все модели уже загружены. Если импортировать signals.py на уровне модуля — возможны циклические импорты, так как signals.py обычно импортирует модели, а модели могут не быть загружены в момент импорта.

4. Убедитесь, что apps.py подключён

📄 apps/tasks/__init__.py
# apps/tasks/__init__.py
# Указываем Django использовать TasksConfig как конфигурацию приложения
default_app_config = "apps.tasks.apps.TasksConfig"

Или в INSTALLED_APPS укажите полный путь к AppConfig:

📄 config/settings.py
INSTALLED_APPS = [
    # ...
    "apps.tasks.apps.TasksConfig",  # полный путь → Django использует TasksConfig.ready()
    # ...
]

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

Жизненный цикл сигнала post_save

  1. Вызывается instance.save() (или ORM-обновление)
  2. Django выполняет INSERT/UPDATE в БД
  3. Django рассылает сигнал post_save с аргументами: sender, instance, created, update_fields
  4. Вызываются все зарегистрированные receivers — в порядке регистрации
  5. Управление возвращается в 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.

💡 Асинхронная отправка email в production: Отправка через SMTP в теле сигнала синхронна и может замедлить HTTP-ответ. В production используйте Celery (или другую очередь задач) для асинхронной отправки. Паттерн: сигнал кладёт задачу в очередь → Celery-воркер отправляет email. Это выходит за рамки данного капстоуна, но важно знать на будущее.

✅ Проверка

1. Запуск сервера (письма будут в этом терминале)

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

2. Создание задачи с assignee

💻 curl (другой терминал)
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
Ожидаемый вывод в консоли runserver:
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
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