KNOTTA research & development

Подписки на уведомления

Модель NotificationPreference — единая точка хранения подписок для пользователей и контактных лиц.

Подписки на уведомления

NotificationPreference

Префикс: ntfp_ Таблица: notification_preference Файл: garden/modules/notification/models/preference.py

Одна запись — это «подписчик X хочет/не хочет получать топик Y по каналу Z». Подписчиком может быть либо сотрудник (User), либо контактное лицо (ContactPerson) — но не оба сразу.

ПолеТипОписание
idKSUID, ntfp_*
userFK → User, nullableЕсли подписчик — сотрудник
contact_personFK → ContactPerson, nullableЕсли подписчик — контактное лицо
topicCharField(32)NotificationTopicЧто подписать
channelCharField(16)DeliveryChannelЧерез какой канал
enabledBooleanFieldАктивна ли подписка

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),
]

Зачем дубль (запись в БД + умолчание)? Это даёт два преимущества:

  1. Видимость в Django Admin. Сотрудник в админке видит все актуальные подписки в инлайне — включая дефолтные.
  2. Простая отписка. Один клик в письме переключает 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 — не показываются.

Доступность канала

Канал считается доступным для подписчика при следующих условиях:

КаналУсловие
emailsubscriber.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. Пользователь больше не получает уведомления этого топика по этому каналу.

Подробнее о механике маршрутизации, бэкендах и расширении подсистемы — в Архитектура → Уведомления.

На странице