Модификация апрель 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/ | Resend | Svix |
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:
dispatch_notification— на одно событие. Резолвит подписчиков, разворачивает каналы поNotificationPreference, ставит per-subscriber-задачи.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 backend | Redis 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 late | CELERY_TASK_ACKS_LATE = True (ack только после успешного выполнения) |
| Reject on worker lost | CELERY_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.py—dispatch_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 | Кому |
|---|---|
| 0 | Garden (кэш) |
| 1 | Chatwoot |
| 2 | Telegram-бот |
| 3 | Celery broker (новое) |
| 4 | Celery result backend (новое) |
Миграция данных
Boolean-поля ContactPerson.notify_* помечены как deprecated и подлежат удалению в следующем релизе. Data-миграция автоматически конвертирует их значения в записи NotificationPreference:
| Старое | Новое |
|---|---|
notify_work_start=True | NotificationPreference(contact_person=cp, topic=work_started, channel=email, enabled=True) |
notify_report_ready=True | NotificationPreference(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сохранён как алиас.
Где смотреть детали
- ТЗ — функциональные требования.
- System design — архитектурные решения и trade-offs.
- Sprint plan по garden — пошаговая реализация.
- Архитектура → Уведомления — финальная архитектура подсистемы.