Подписки на уведомления
Модель NotificationPreference — единая точка хранения подписок для пользователей и контактных лиц.
Подписки на уведомления
NotificationPreference
Префикс: ntfp_
Таблица: notification_preference
Файл: garden/modules/notification/models/preference.py
Одна запись — это «подписчик X хочет/не хочет получать топик Y по каналу Z». Подписчиком может быть либо сотрудник (User), либо контактное лицо (ContactPerson) — но не оба сразу.
| Поле | Тип | Описание |
|---|---|---|
id | KSUID, ntfp_* | |
user | FK → User, nullable | Если подписчик — сотрудник |
contact_person | FK → ContactPerson, nullable | Если подписчик — контактное лицо |
topic | CharField(32) — NotificationTopic | Что подписать |
channel | CharField(16) — DeliveryChannel | Через какой канал |
enabled | BooleanField | Активна ли подписка |
Constraints
constraints = [
UniqueConstraint(
fields=["user", "topic", "channel"],
condition=Q(user__isnull=False),
name="uq_ntfp_user_topic_channel",
),
UniqueConstraint(
fields=["contact_person", "topic", "channel"],
condition=Q(contact_person__isnull=False),
name="uq_ntfp_cp_topic_channel",
),
CheckConstraint(
check=(
(Q(user__isnull=False) & Q(contact_person__isnull=True)) |
(Q(user__isnull=True) & Q(contact_person__isnull=False))
),
name="ntfp_exactly_one_subscriber",
),
]Уникальность — не более одной записи на пару (subscriber, topic, channel). Это два partial unique-constraint'а — один для user, другой для contact_person.
Целостность — ровно одно из полей user / contact_person заполнено, никогда оба сразу.
Топики и каналы
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"Что отсутствует — то применяется по умолчанию
Если для подписчика нет записи NotificationPreference по конкретной паре (topic, channel) — модуль уведомлений применяет ролевые умолчания из services/defaults.py:
DEFAULT_PREFERENCES = [
# ContactPerson — независимо от роли
("contact_person", None, "work_started", "email", True),
("contact_person", None, "work_completed", "email", True),
("contact_person", None, "report_ready", "email", True),
# User по ролям
("user", "admin", "work_started", "email", True),
("user", "admin", "work_completed", "email", True),
("user", "admin", "report_submitted", "email", True),
("user", "staff", "work_started", "email", True),
("user", "staff", "work_completed", "email", True),
("user", "staff", "report_submitted", "email", True),
("user", "brigadier", "report_approved", "email", True),
("user", "brigadier", "report_rejected", "email", True),
]Зачем дубль (запись в БД + умолчание)? Это даёт два преимущества:
- Видимость в Django Admin. Сотрудник в админке видит все актуальные подписки в инлайне — включая дефолтные.
- Простая отписка. Один клик в письме переключает
enabled=Falseбез необходимости сначала «материализовать» подписку.
Поэтому при создании подписчика signal post_save создаёт записи NotificationPreference по умолчаниям сразу — у каждого нового пользователя/контакта появляется готовый набор подписок.
Inline в Django Admin
В карточке ContactPerson (modules/client/admin.py) и в карточке User (modules/account/admin.py) подключён общий inline NotificationPreferenceInline из modules/notification/admin.py.
Сотрудник видит таблицу подписок и может:
- Поставить или снять галку
enabled. - Добавить новую подписку (другой канал — например,
telegram, если у пользователя естьTelegramLink). - Удалить запись.
Inline в карточке User отфильтрован: показывается только для ролей admin, staff, brigadier. Для роли client подписки настраиваются в карточке связанного ContactPerson. Для worker — не показываются.
Доступность канала
Канал считается доступным для подписчика при следующих условиях:
| Канал | Условие |
|---|---|
email | subscriber.email непустой |
telegram | Есть активная TelegramLink (telegram_id IS NOT NULL, blocked_at IS NULL). Для ContactPerson — через связанного User (cp.user). |
Если enabled=True, но канал недоступен — отправка пропускается (с warning в лог), ReportDelivery не создаётся.
Отписка одним кликом
Каждое email-уведомление содержит ссылку «Отписаться». Внутри ссылки — подписанный токен с зашитыми subscriber_id, topic, channel. Сервис unsubscribe.py находит соответствующий NotificationPreference (или создаёт его, если работало умолчание) и выставляет enabled=False. Пользователь больше не получает уведомления этого топика по этому каналу.
Подробнее о механике маршрутизации, бэкендах и расширении подсистемы — в Архитектура → Уведомления.