KNOTTA research & development

Модификация апрель 2026

Подсистема уведомлений, поимённый учёт человеко-часов, email-канал, асинхронная доставка через Celery, расширенный жизненный цикл.

Модификация апрель 2026

Самый крупный релиз с момента запуска платформы. Затрагивает уведомления, отчёты, расчёт человеко-часов, инфраструктуру доставки и инфраструктурный слой (добавлен асинхронный воркер).

Бизнес-цели

ЗадачаЧто меняется
Email для контактных лиц клиентовДополнительный канал к Telegram. Письмо с датой, объектом, адресом и ссылкой на публичную страницу отчёта.
Настраиваемые уведомления для сотрудниковadmin, staff, brigadier могут выбирать топики и каналы — через единую модель NotificationPreference.
Поимённый учёт человеко-часовЗамена слайдеров «N человек × M часов» на динамическую таблицу выбора работников и их часов.
Новый алгоритм расчётаСумма часов по работникам вместо произведения. Обратная совместимость с отчётами по старой схеме.
Асинхронная отправка уведомленийВведён Celery-воркер. HTTP-запросы больше не блокируются на отправке email/Telegram — это вынесено в фоновую очередь.

Архитектурные изменения

Новый Django-модуль modules/notification

Подсистема уведомлений выделена в отдельный модуль с pluggable-архитектурой. Подробности — в Архитектура → Уведомления.

Ключевые компоненты:

  • NotificationRouter — единственная точка входа для отправки.
  • NotificationContext — иммутабельный конверт для передачи данных.
  • BaseNotificationBackend + BackendRegistry — pluggable-транспорты.
  • recipient_resolvers — карта «топик → подписчики».
  • NotificationPreference — единая модель подписок (заменяет boolean-поля на ContactPerson).
  • DEFAULT_PREFERENCES — ролевые умолчания.
  • DeliveryStatusService — машина состояний доставки.

Расширение DeliveryChannel и NotificationTopic

class DeliveryChannel(models.TextChoices):
    TELEGRAM = "telegram"
    EMAIL    = "email"           # ← новое


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"

Расширение DeliveryStatus

pending → sending → {sent, failed, expired, cancelled}

                 {delivered, opened, bounced, complained}    ← новое

Новые статусы заполняются на основе webhook-событий от Resend.

Расширение ReportDelivery

ПолеЧто
recipient_emailНовое — для канала email
topicНовое — какой топик
service_visitНовое FK — для топиков work_*, где Report ещё нет

report стал nullable (раньше — обязательный). CHECK-ограничение требует, чтобы было заполнено ХОТЯ БЫ одно из report / service_visit.

Email-канал — Resend + MJML

EmailBackend отправляет письма напрямую из Django через Resend API. Шаблоны — на MJML, рендерятся локально через django-mjml.

Почему не React Email через Edge Function:

  • Edge Function send-email заточена под Auth-события (приглашение / сброс пароля / смена email). Бизнес-уведомления туда — нарушение SRP.
  • Сетевой вызов Django → Edge Function → Resend = два прыжка вместо одного.
  • MJML-шаблоны воспроизводят визуальный стиль React Email-писем — клиент получает всё в едином дизайне.

Webhook-приёмники — per-channel

Вместо одного универсального — разделены:

URLИсточникПодпись
POST /api/webhooks/telegram/<delivery_id>Telegram-ботHMAC-SHA256
POST /api/webhooks/resend/ResendSvix
POST /api/report-deliveries/<delivery_id>/webhookСтарая сборка ботаHMAC-SHA256

Все приёмники делегируют единому DeliveryStatusService, который валидирует переход и обновляет ReportDelivery.

Поимённый учёт работников

Новый эндпоинт GET /api/workers/:

  • Query-параметры: skip, take, search (по full_name), exclude (исключить ID), ids (batch).
  • Формат ответа совместим с SurveyJS choicesLazyLoadEnabled: { results: [{value, text}], count }.
  • Доступ: авторизованные с ролями admin/staff/brigadier.

Шаблон отчёта обновлён:

  • Удалены: routine-care-person-count, routine-care-hours-count, topiary-cutting-person-count, topiary-cutting-hours-count.
  • Добавлены matrixdynamic: routine-care-workers, topiary-cutting-workers. Колонки: worker_id (dropdown с lazy-load) и hours (number).

Идемпотентная синхронизация sync_visit_members_from_survey(report) запускается при Report.status → approved. Приводит ServiceVisitMember(role=worker) к составу из survey_data.

Алгоритм расчёта человеко-часов

def calculate_man_hours(survey_data, work_type):
    workers_key = f"{work_type}-workers"
    if workers_key in survey_data:
        # Новый формат — сумма часов
        workers = survey_data[workers_key]
        return len(workers), sum(w.get("hours", 0) for w in workers)
    # Старый формат — обратная совместимость
    p = survey_data.get(f"{work_type}-person-count", 0)
    h = survey_data.get(f"{work_type}-hours-count", 0)
    return p, p * h

Используется в report-summary endpoint и при синхронизации ServiceVisitService.

Асинхронная отправка уведомлений через Celery

Не было заявлено в исходном ТЗ, но добавлено по ходу реализации — чтобы HTTP-запросы (старт наряда, утверждение отчёта и т. п.) не блокировались на отправке email через Resend и публикации в Redis Stream.

Загрузка диаграммы…

Двухуровневый fan-out:

  1. dispatch_notification — на одно событие. Резолвит подписчиков, разворачивает каналы по NotificationPreference, ставит per-subscriber-задачи.
  2. deliver_to_channel — на одну пару (подписчик, канал). Вызывает соответствующий Backend.send(). Идемпотентность гарантирует _get_or_create_delivery(make_idempotency_key(context, subscriber, channel)) — повторные попытки после краша воркера или сетевой ошибки не приводят к дубликатам.

Запуск через transaction.on_commit — задачи планируются только после успешного коммита транзакции. Это исключает гонку «отправили уведомление, но БД откатилась».

Конфигурация:

ЧтоЗначение / переменная
БрокерRedis DB 3 (CELERY_BROKER_URL)
Result backendRedis DB 4 (CELERY_RESULT_BACKEND)
СериализацияJSON (CELERY_TASK_SERIALIZER, CELERY_RESULT_SERIALIZER)
ТаймзонаUTC
Жёсткий таймаутCELERY_TASK_TIME_LIMIT = 120 секунд
Soft-таймаутCELERY_TASK_SOFT_TIME_LIMIT = 90 секунд
Acks lateCELERY_TASK_ACKS_LATE = True (ack только после успешного выполнения)
Reject on worker lostCELERY_TASK_REJECT_ON_WORKER_LOST = True
Eager (dev)CELERY_TASK_ALWAYS_EAGER = True по умолчанию в development.py — задачи выполняются синхронно, без воркера

Файлы Garden:

  • garden/ng_metrics/celery.py — приложение Celery; конфиг через namespace="CELERY" из Django-настроек; autodiscover_tasks().
  • garden/ng_metrics/__init__.py — импорт celery_app, чтобы @shared_task подхватывались при старте Django.
  • garden/modules/notification/tasks.pydispatch_notification, deliver_to_channel.

Инфраструктура (Ansible):

  • В ansible/roles/ng-metrics-backend/templates/docker-compose.yaml.j2 добавлен второй контейнер ng-metrics-backend-worker. Использует тот же образ, что и web-сервис, но с очищенным entrypoint: [] (чтобы не запускать миграции и gunicorn) и командой celery -A ng_metrics worker -l INFO --concurrency={{ celery_worker_concurrency }}.
  • Переменная celery_worker_concurrency в ansible/vars/default.yaml — по умолчанию 2.

Redis-распределение пересмотрено:

DBКому
0Garden (кэш)
1Chatwoot
2Telegram-бот
3Celery broker (новое)
4Celery result backend (новое)

Миграция данных

Boolean-поля ContactPerson.notify_* помечены как deprecated и подлежат удалению в следующем релизе. Data-миграция автоматически конвертирует их значения в записи NotificationPreference:

СтароеНовое
notify_work_start=TrueNotificationPreference(contact_person=cp, topic=work_started, channel=email, enabled=True)
notify_report_ready=TrueNotificationPreference(contact_person=cp, topic=report_ready, channel=email, enabled=True)
notify_*=FalseЗапись с enabled=False
Если есть TelegramLinkДополнительно создаётся запись с channel=telegram, enabled=True

Сотрудники: для каждого User с ролью admin/staff создаются дефолтные подписки.

Бригадиры: для каждого User(role=brigadier) создаётся NotificationPreference(topic=report_rejected, channel=email, enabled=True) и NotificationPreference(topic=report_approved, channel=email, enabled=True). Если есть TelegramLink — добавляются дублирующие записи с каналом telegram.

Зеркалирование в Telegram-боте

В соответствии с архитектурным принципом «Garden — единственный владелец схемы БД», все новые модели (NotificationPreference, расширенные DeliveryChannel / NotificationTopic) зеркалируются в SQLAlchemy-моделях бота (bot/app/models/sql/) в режиме read-only.

Совместимость

Полная обратная совместимость с существующими данными:

  • Старые отчёты (со слайдерами) продолжают работать. Расчёт автоматически определяет формат.
  • Существующие уведомления через Telegram продолжают работать без изменений на стороне бота.
  • Старый webhook URL /api/report-deliveries/<id>/webhook сохранён как алиас.

Где смотреть детали

На странице