Подсистема уведомлений
Архитектура модуля notification — NotificationRouter, бэкенды, маршрутизация, ролевые умолчания, отписка.
Подсистема уведомлений
С апреля 2026 года уведомления централизованы в отдельном Django-модуле modules/notification. Этот модуль — единственная точка входа для всех уведомлений платформы. Бизнес-логика (ServiceVisitViewSet, ReportViewSet) описывает что произошло (топик и контекст), модуль уведомлений решает кому отправить и как.
Зачем выделен в отдельный модуль
До модификации логика «найти контактных лиц → создать ReportDelivery → положить в Redis Stream» была вшита в ReportViewSet.send_to_client. Это работало пока:
- Был ровно один канал — Telegram.
- Был ровно один сценарий — отправка отчёта клиенту.
Когда понадобилось добавить email и расширить набор событий (старт работ, завершение, отклонение отчёта), стало понятно: бизнес-код не должен знать, какие каналы существуют и какие подписки активны. Так появился NotificationRouter — а вокруг него pluggable-архитектура транспортов (channel backends).
Добавление нового канала (например, push) теперь не требует менять бизнес-код. Достаточно реализовать новый бэкенд и зарегистрировать его.
Структура модуля
garden/modules/notification/
├── apps.py # NotificationConfig.ready() регистрирует бэкенды
├── models/
│ ├── choices.py # DeliveryChannel, DeliveryStatus, NotificationTopic
│ └── preference.py # NotificationPreference
├── services/
│ ├── router.py # NotificationRouter, NotificationContext
│ ├── recipient_resolvers.py # resolve_<topic> → list[Subscriber]
│ ├── defaults.py # DEFAULT_PREFERENCES — ролевые умолчания
│ ├── delivery_status.py # DeliveryStatusService — машина состояний
│ ├── idempotency.py # ключ идемпотентности доставки
│ ├── formatters.py # форматтеры для tg/email
│ ├── unsubscribe.py # бизнес-логика отписки
│ └── unsubscribe_tokens.py # подписанные токены отписки
├── backends/
│ ├── base.py # BaseNotificationBackend (ABC)
│ ├── telegram.py # TelegramBackend
│ ├── email.py # EmailBackend (Resend + MJML)
│ └── registry.py # BackendRegistry
├── api/
│ ├── webhooks.py # TelegramWebhookReceiver, ResendWebhookReceiver
│ ├── unsubscribe.py # UnsubscribeExecuteView
│ └── urls.py
├── templates/notification/email/ # MJML-шаблоны по топикам
├── signals.py # post_save → создание дефолтных preferences
├── tasks.py # Celery — dispatch_notification, deliver_to_channel
├── admin.py # inline-формы для preferences
└── tests/NotificationPreference
┌─────────────────────────────────────────────────────────────────┐
│ notification_preference │
├──────────────────┬──────────────────┬───────────────────────────┤
│ id (ntfp_*) │ PK, KSUID │ │
│ user_id │ FK → auth_user │ NULL если contact_person │
│ contact_person_id│ FK → contact_person│ NULL если user │
│ topic │ VARCHAR(32) │ NotificationTopic │
│ channel │ VARCHAR(16) │ DeliveryChannel │
│ enabled │ BOOLEAN │ default=True │
├──────────────────┴──────────────────┴───────────────────────────┤
│ UNIQUE (user, topic, channel) │
│ UNIQUE (contact_person, topic, channel) │
│ CHECK ровно одно из user / contact_person заполнено │
└─────────────────────────────────────────────────────────────────┘Подписчик — либо User (для ролей admin, staff, brigadier), либо ContactPerson (контактное лицо клиента). Никогда оба сразу — это гарантирует CHECK-ограничение.
Принцип умолчаний. Если для подписчика нет записи на пару (topic, channel) — применяется ролевое умолчание из DEFAULT_PREFERENCES. Если запись есть — enabled берётся из неё.
При создании нового пользователя (User) или контактного лица (ContactPerson) signal post_save автоматически создаёт записи NotificationPreference по умолчаниям.
Топики и каналы
class NotificationTopic(models.TextChoices):
WORK_STARTED = "work_started"
WORK_COMPLETED = "work_completed"
REPORT_SUBMITTED = "report_submitted"
REPORT_APPROVED = "report_approved"
REPORT_READY = "report_ready"
REPORT_REJECTED = "report_rejected"
class DeliveryChannel(models.TextChoices):
TELEGRAM = "telegram"
EMAIL = "email"Полная карта «топик → отправитель → получатели» — в Возможности → Подсистема уведомлений. Здесь — про маршрутизацию.
Ролевые умолчания
Лежат в services/defaults.py:
| Подписчик | Топик | Канал | enabled |
|---|---|---|---|
| ContactPerson (любой) | work_started | true | |
| ContactPerson | work_completed | true | |
| ContactPerson | report_ready | true | |
| User (admin) | work_started | true | |
| User (admin) | work_completed | true | |
| User (admin) | report_submitted | true | |
| User (staff) | work_started | true | |
| User (staff) | work_completed | true | |
| User (staff) | report_submitted | true | |
| User (brigadier) | report_approved | true | |
| User (brigadier) | report_rejected | true |
Любую запись можно переопределить через Django Admin (inline в карточке User / ContactPerson) — например, переключить канал на telegram или выключить топик.
NotificationRouter
class NotificationRouter:
def notify(
self,
topic: NotificationTopic,
context: NotificationContext,
) -> None:
"""
Бизнес-код вызывает router.notify(topic, context).
Router планирует Celery-задачу dispatch_notification.delay() через
transaction.on_commit — фактическая работа происходит в воркере
после успешного коммита транзакции.
"""NotificationContext — dataclass-конверт, который бизнес-код передаёт роутеру. Содержит:
topicreport(опционально — дляreport_*топиков)service_visit(опционально — дляwork_*топиков)rejection_comment(дляreport_rejected)nonce(стабильный маркер для идемпотентности — нужен только для топиков, которые могут срабатывать повторно:report_submitted,report_rejected)extra(произвольные данные для шаблонов)
Контекст сериализуется в JSON через to_payload() / from_payload() для передачи в Celery-задачу.
Асинхронная отправка через Celery
Фактическая работа (резолв подписчиков, создание ReportDelivery, вызов Resend / Redis Stream) вынесена из HTTP-цикла в Celery-задачи. HTTP-вью возвращает 200 OK сразу после планирования задачи — отправка происходит в фоне.
Двухуровневый fan-out определён в garden/modules/notification/tasks.py:
| Задача | Что делает |
|---|---|
dispatch_notification(topic, payload) | Восстанавливает NotificationContext из payload, вызывает резолвер, для каждой пары (subscriber, channel) ставит deliver_to_channel.delay(). |
deliver_to_channel(topic, subscriber_ref, channel, payload) | Восстанавливает подписчика и контекст, вызывает BackendRegistry.get(channel).send(...). Идемпотентность гарантирует ключ из make_idempotency_key(context, subscriber, channel). |
Зачем transaction.on_commit. Если поставить задачу до коммита, воркер может подхватить её раньше, чем БД увидит изменения (например, новый статус Report). on_commit гарантирует, что задача планируется только после успешного коммита.
Идемпотентность. Ключ (topic, anchor, subscriber, channel[, nonce]) хранится в ReportDelivery. Перед созданием новой попытки Backend._get_or_create_delivery() проверяет, нет ли активной доставки с тем же ключом — это защищает от дубликатов при ретраях после краша воркера или сетевой ошибки.
Конфигурация Celery:
| Параметр | Значение |
|---|---|
| Broker | Redis DB 3 (CELERY_BROKER_URL) |
| Result backend | Redis DB 4 (CELERY_RESULT_BACKEND) |
| Сериализация | JSON |
| Таймзона | UTC |
CELERY_TASK_TIME_LIMIT | 120 секунд (hard kill) |
CELERY_TASK_SOFT_TIME_LIMIT | 90 секунд (SoftTimeLimitExceeded) |
CELERY_TASK_ACKS_LATE | True — подтверждение только после успешного выполнения |
CELERY_TASK_REJECT_ON_WORKER_LOST | True — задача переотдаётся, если воркер умер |
CELERY_TASK_ALWAYS_EAGER | По умолчанию True в development.py — задачи выполняются синхронно, без воркера |
В проде воркер живёт в отдельном Docker-контейнере (ng-metrics-backend-worker) с тем же образом, что и web-сервис, и командой celery -A ng_metrics worker -l INFO --concurrency={{ celery_worker_concurrency }} (по умолчанию 2).
Recipient Resolvers
Каждый топик имеет свой резолвер в services/recipient_resolvers.py:
RESOLVERS = {
NotificationTopic.WORK_STARTED: resolve_work_started,
NotificationTopic.WORK_COMPLETED: resolve_work_completed,
NotificationTopic.REPORT_SUBMITTED: resolve_report_submitted,
NotificationTopic.REPORT_APPROVED: resolve_report_approved,
NotificationTopic.REPORT_READY: resolve_report_ready,
NotificationTopic.REPORT_REJECTED: resolve_report_rejected,
}Каждая функция возвращает list[User | ContactPerson]. Логика, например:
resolve_work_started(ctx)→ admin/staff + контактные лица клиента изctx.service_visit.object.client.contacts.resolve_report_approved(ctx)→ один элемент:ctx.report.author(бригадир).resolve_report_submitted(ctx)→ пользователи с ролямиadmin/staff.
Транспорты (channel backends)
Все наследуются от BaseNotificationBackend:
class BaseNotificationBackend(ABC):
channel: DeliveryChannel
def is_available(self, subscriber) -> bool: ...
def send(self, subscriber, context) -> ReportDelivery: ...TelegramBackend
Не отправляет в Telegram напрямую — кладёт сообщение в Redis Stream, бот сам подхватит. Жизненный цикл:
- Найти
TelegramLink(user=subscriber, telegram_id__isnull=False, blocked_at__isnull=True). - Создать
ReportDelivery(channel=telegram, telegram_chat_id=...)со статусомpending. - Сформировать payload (тип сообщения, переменные, локаль).
enqueue_message(payload)→ Redis Stream.- Вернуть
delivery. Финальный статус (sent/failed) придёт от бота через webhook.
is_available(subscriber) — есть ли активная привязка TelegramLink. Для ContactPerson — через cp.user.
EmailBackend
Отправляет напрямую через Resend API (без Edge Function-посредника):
- Достать email подписчика.
- Создать
ReportDelivery(channel=email, recipient_email=...)со статусомpending. - Зарендерить MJML-шаблон по топику и языку (
templates/notification/email/<topic>/body_<lang>.mjml) → HTML. - Вызвать
resend.Emails.send(...)с custom-headerX-Delivery-Id. - На ответе:
- HTTP 200 →
mark_sent(). - HTTP 4xx/5xx →
mark_failed(error_code, error_message).
- HTTP 200 →
Дальше — webhook от Resend двигает статус доставки до delivered / opened / bounced / complained.
Почему MJML, а не React Email? React Email рендерится в Node.js. Делать сетевой вызов из Django ради рендеринга email — оверкилл. MJML компилируется в HTML локально (django-mjml, через CLI или Python-binding), что даёт нулевую сетевую зависимость. Визуально шаблоны воспроизводят стиль React Email-писем, чтобы клиент получал транзакционные и бизнес-письма в одном дизайне.
Регистрация бэкендов
В apps.py.ready():
class NotificationConfig(AppConfig):
def ready(self):
from .backends.registry import BackendRegistry
from .backends.telegram import TelegramBackend
from .backends.email import EmailBackend
BackendRegistry.register(TelegramBackend())
BackendRegistry.register(EmailBackend())Жизненный цикл доставки
DeliveryStatus:
pending → sending → {sent, failed, expired, cancelled}
↓
{delivered, opened, bounced, complained}| Статус | Когда выставляется |
|---|---|
pending | Создан ReportDelivery, отправка ещё не начата |
sending | Backend начал отправку |
sent | Канал принял сообщение (Telegram-бот → ok, Resend → 200) |
failed | Отправка не удалась |
expired | TTL истёк до отправки (актуально для очередей) |
cancelled | Отменено вручную |
delivered | Email доставлен на сервер получателя (Resend webhook) |
opened | Получатель открыл письмо (tracking pixel Resend) |
bounced | Hard / soft bounce |
complained | Получатель пометил как спам |
DeliveryStatusService в services/delivery_status.py — единственная точка обновления статуса. Все webhook-приёмники (TelegramWebhookReceiver, ResendWebhookReceiver) делегируют ему. Сервис валидирует переход и обновляет связанные поля (например, Report.sent_to_client после успешной отправки report_ready).
Webhook-приёмники
В api/webhooks.py:
| Endpoint | Кто шлёт | Подпись |
|---|---|---|
POST /api/webhooks/telegram/<delivery_id> | Telegram-бот | HMAC-SHA256 |
POST /api/webhooks/resend/ | Resend | Svix-подпись |
POST /api/report-deliveries/<delivery_id>/webhook | (старая сборка бота) | HMAC-SHA256 |
Резендовский приёмник маппит события:
| Resend event | DeliveryStatus |
|---|---|
email.sent | SENT |
email.delivered | DELIVERED |
email.opened | OPENED |
email.bounced | BOUNCED |
email.complained | COMPLAINED |
email.delivery_delayed | (игнорируется — не финальный статус) |
Идемпотентность
Каждое уведомление имеет ключ идемпотентности (topic, anchor, subscriber, channel[, nonce]):
anchor—report.idилиservice_visit.id.nonce— для топиков, которые могут срабатывать повторно (report_submitted,report_rejected).
Перед отправкой Backend._get_or_create_delivery(...) проверяет, нет ли уже активной доставки с тем же ключом. Это защищает от дубликатов при ретраях бизнес-кода.
Отписка
Каждое email-уведомление содержит ссылку «Отписаться». Ссылка ведёт на /{locale}/unsubscribe?token=<...> на фронте, страница вызывает POST /api/notifications/unsubscribe/execute/.
Токен — подписанный (unsubscribe_tokens.py) и одноразовый. Внутри — subscriber_id, topic, channel. Сервис unsubscribe.py находит соответствующий NotificationPreference и выставляет enabled=False.
Расширение: новый канал
- Добавить значение в
DeliveryChannel(models/choices.py). - Реализовать класс наследника
BaseNotificationBackendвbackends/. - Зарегистрировать в
NotificationConfig.ready(). - (Опционально) добавить умолчания в
DEFAULT_PREFERENCES. - (Опционально) добавить webhook-приёмник, делегирующий
DeliveryStatusService.
Расширение: новый топик
- Добавить значение в
NotificationTopic. - Зарегистрировать резолвер в
RESOLVERS(services/recipient_resolvers.py). - Добавить умолчания в
DEFAULT_PREFERENCES. - Положить шаблоны в
templates/notification/email/<topic>/:subject_ru.txt,subject_en.txt.body_ru.mjml,body_en.mjml(расширяют_base.mjml).
- Если поддерживается Telegram — расширить
_TOPIC_TO_MESSAGE_TYPEвbackends/telegram.pyи научить бот распознавать новый тип сообщения. - Вызвать
NotificationRouter().notify(topic, context)из бизнес-кода в нужный момент.