KNOTTA research & development

Подсистема уведомлений

Архитектура модуля 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_startedemailtrue
ContactPersonwork_completedemailtrue
ContactPersonreport_readyemailtrue
User (admin)work_startedemailtrue
User (admin)work_completedemailtrue
User (admin)report_submittedemailtrue
User (staff)work_startedemailtrue
User (staff)work_completedemailtrue
User (staff)report_submittedemailtrue
User (brigadier)report_approvedemailtrue
User (brigadier)report_rejectedemailtrue

Любую запись можно переопределить через 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-конверт, который бизнес-код передаёт роутеру. Содержит:

  • topic
  • report (опционально — для 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:

ПараметрЗначение
BrokerRedis DB 3 (CELERY_BROKER_URL)
Result backendRedis DB 4 (CELERY_RESULT_BACKEND)
СериализацияJSON
ТаймзонаUTC
CELERY_TASK_TIME_LIMIT120 секунд (hard kill)
CELERY_TASK_SOFT_TIME_LIMIT90 секунд (SoftTimeLimitExceeded)
CELERY_TASK_ACKS_LATETrue — подтверждение только после успешного выполнения
CELERY_TASK_REJECT_ON_WORKER_LOSTTrue — задача переотдаётся, если воркер умер
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, бот сам подхватит. Жизненный цикл:

  1. Найти TelegramLink(user=subscriber, telegram_id__isnull=False, blocked_at__isnull=True).
  2. Создать ReportDelivery(channel=telegram, telegram_chat_id=...) со статусом pending.
  3. Сформировать payload (тип сообщения, переменные, локаль).
  4. enqueue_message(payload) → Redis Stream.
  5. Вернуть delivery. Финальный статус (sent / failed) придёт от бота через webhook.

is_available(subscriber) — есть ли активная привязка TelegramLink. Для ContactPerson — через cp.user.

EmailBackend

Отправляет напрямую через Resend API (без Edge Function-посредника):

  1. Достать email подписчика.
  2. Создать ReportDelivery(channel=email, recipient_email=...) со статусом pending.
  3. Зарендерить MJML-шаблон по топику и языку (templates/notification/email/<topic>/body_<lang>.mjml) → HTML.
  4. Вызвать resend.Emails.send(...) с custom-header X-Delivery-Id.
  5. На ответе:
    • HTTP 200 → mark_sent().
    • HTTP 4xx/5xx → mark_failed(error_code, error_message).

Дальше — 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, отправка ещё не начата
sendingBackend начал отправку
sentКанал принял сообщение (Telegram-бот → ok, Resend → 200)
failedОтправка не удалась
expiredTTL истёк до отправки (актуально для очередей)
cancelledОтменено вручную
deliveredEmail доставлен на сервер получателя (Resend webhook)
openedПолучатель открыл письмо (tracking pixel Resend)
bouncedHard / 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/ResendSvix-подпись
POST /api/report-deliveries/<delivery_id>/webhook(старая сборка бота)HMAC-SHA256

Резендовский приёмник маппит события:

Resend eventDeliveryStatus
email.sentSENT
email.deliveredDELIVERED
email.openedOPENED
email.bouncedBOUNCED
email.complainedCOMPLAINED
email.delivery_delayed(игнорируется — не финальный статус)

Идемпотентность

Каждое уведомление имеет ключ идемпотентности (topic, anchor, subscriber, channel[, nonce]):

  • anchorreport.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.

Расширение: новый канал

  1. Добавить значение в DeliveryChannel (models/choices.py).
  2. Реализовать класс наследника BaseNotificationBackend в backends/.
  3. Зарегистрировать в NotificationConfig.ready().
  4. (Опционально) добавить умолчания в DEFAULT_PREFERENCES.
  5. (Опционально) добавить webhook-приёмник, делегирующий DeliveryStatusService.

Расширение: новый топик

  1. Добавить значение в NotificationTopic.
  2. Зарегистрировать резолвер в RESOLVERS (services/recipient_resolvers.py).
  3. Добавить умолчания в DEFAULT_PREFERENCES.
  4. Положить шаблоны в templates/notification/email/<topic>/:
    • subject_ru.txt, subject_en.txt.
    • body_ru.mjml, body_en.mjml (расширяют _base.mjml).
  5. Если поддерживается Telegram — расширить _TOPIC_TO_MESSAGE_TYPE в backends/telegram.py и научить бот распознавать новый тип сообщения.
  6. Вызвать NotificationRouter().notify(topic, context) из бизнес-кода в нужный момент.
На странице