refactor(documents): реструктуризация notifications в модульную архитектуру

Проблема:
- Монолитный notification_signals.py (~520 строк) смешивал разные concerns
- Сложная логика с множественными сигналами и обработчиками в одном файле
- Логирование и бизнес-логика переплетены с signal receivers

Решение:
Разделен на модульную структуру documents/notifications/:
- config.py - NotificationVerbs, MessageTemplates, ActionURLs, get_uploader_name(),
  get_bulk_threshold()
- handlers.py - notify_all_employees(), notify_specific_users(),
  notify_department_employees(), notify_all_acknowledged(), notify_document_comment(),
  notify_comment_reply()
- signals.py - Django signal receivers (@receiver) для Document, DocumentAcknowledgement,
  DocumentComment
- __init__.py - публичный API модуля

Изменения:
- Создан: documents/notifications/ модуль с 4 файлами
- Переименован: notification_signals.py → notification_signals.py.old
- Изменен: apps.py - обновлен импорт на documents.notifications

Логика уведомлений:
1. Новый документ → все сотрудники/отделы/конкретные получатели
2. Все ознакомились → уведомление загрузившему
3. Комментарий → автор документа
4. Ответ на комментарий → автор родительского комментария

Преимущества:
- ✅ Единообразие с requests_app, procurement, feed, communications
- ✅ Четкое разделение ответственности (SRP)
- ✅ Централизованное логирование с контекстом
- ✅ Поддержка bulk режима для массовых рассылок
- ✅ Легче тестировать компоненты
- ✅ Публичный API для переиспользования

Верификация:
- python manage.py check - успешно
This commit is contained in:
lizerk 2026-03-10 13:36:04 +03:00
parent ca80321034
commit cc24a073f4
6 changed files with 745 additions and 2 deletions

View File

@ -6,6 +6,6 @@ class DocumentsConfig(AppConfig):
name = "documents"
def ready(self):
# импортируем сигналы, чтобы они подхватились
import documents.notification_signals # noqa
# Модуль уведомлений (signals регистрируются автоматически)
import documents.notifications # noqa
import documents.rules # django-rules: регистрация предикатов и правил доступа

View File

@ -0,0 +1,35 @@
"""
Модуль уведомлений для Documents приложения.
Публичный API:
- NotificationVerbs - константы глаголов уведомлений
- MessageTemplates - шаблоны сообщений
- ActionURLs - генераторы URL-адресов
- notify_document_ready - функция для ручного уведомления о документе
Использование:
from documents.notifications import notify_document_ready
# В коде:
notify_document_ready(document, user)
"""
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
get_uploader_name,
)
from .handlers import notify_document_ready
# Импортируем handlers и signals для регистрации
from . import handlers # noqa: F401
from . import signals # noqa: F401
__all__ = [
'NotificationVerbs',
'MessageTemplates',
'ActionURLs',
'notify_document_ready',
'get_uploader_name',
]

View File

@ -0,0 +1,114 @@
"""
Конфигурация уведомлений для модуля Documents.
Содержит:
- NotificationVerbs - глаголы для уведомлений
- MessageTemplates - шаблоны сообщений
- ActionURLs - URL-адреса действий
"""
from django.conf import settings
class NotificationVerbs:
"""Глаголы (verbs) для уведомлений documents."""
DOCUMENT_READY = 'document_ready'
DOCUMENT_SIGNED_ALL = 'document_signed_all'
DOCUMENT_COMMENT = 'document_comment'
DOCUMENT_COMMENT_REPLY = 'document_comment_reply'
class MessageTemplates:
"""Шаблоны сообщений для уведомлений documents."""
@staticmethod
def document_ready(uploader_name: str, document_title: str) -> str:
"""Шаблон для нового документа на ознакомление."""
return (
f'{uploader_name} загрузил документ "{document_title}". '
f'Требуется ознакомление.'
)
@staticmethod
def document_ready_title() -> str:
"""Заголовок для нового документа."""
return 'Новый документ на ознакомление'
@staticmethod
def all_acknowledged(document_title: str) -> str:
"""Шаблон для завершения ознакомления всеми."""
return f'Все сотрудники ознакомились с документом "{document_title}"'
@staticmethod
def all_acknowledged_title() -> str:
"""Заголовок для завершения ознакомления."""
return 'Все ознакомились с документом'
@staticmethod
def comment(author_name: str, document_title: str) -> str:
"""Шаблон для нового комментария к документу."""
return (
f'{author_name} оставил комментарий '
f'к вашему документу "{document_title}"'
)
@staticmethod
def comment_title() -> str:
"""Заголовок для комментария к документу."""
return 'Новый комментарий к документу'
@staticmethod
def comment_reply(author_name: str, document_title: str) -> str:
"""Шаблон для ответа на комментарий."""
return (
f'{author_name} ответил на ваш '
f'комментарий к документу "{document_title}"'
)
@staticmethod
def comment_reply_title() -> str:
"""Заголовок для ответа на комментарий."""
return 'Ответ на ваш комментарий'
class ActionURLs:
"""URL-адреса для действий с документами."""
DOCUMENTS = '/documents'
@staticmethod
def document_detail(document_id: int) -> str:
"""
Возвращает URL-адрес документа.
TODO: реализовать прямые ссылки на конкретные документы
"""
return f'/documents?id={document_id}'
# ===== Константы =====
def get_bulk_threshold() -> int:
"""
Возвращает порог для определения массовой рассылки.
Returns:
int: Количество получателей, начиная с которого используется bulk режим
"""
return getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10)
def get_uploader_name(uploaded_by) -> str:
"""
Возвращает имя загрузившего документ пользователя.
Args:
uploaded_by: Объект Employee или None
Returns:
str: Полное имя или 'Администратор'
"""
if uploaded_by:
return uploaded_by.get_full_name() or uploaded_by.username
return 'Администратор'

View File

@ -0,0 +1,404 @@
"""
Бизнес-логика отправки уведомлений для модуля Documents.
Функции:
- notify_document_ready - уведомление о новом документе
- notify_all_employees - уведомление всем сотрудникам
- notify_specific_users - уведомление конкретным пользователям
- notify_department_employees - уведомление сотрудникам отделов
- notify_all_acknowledged - уведомление о завершении ознакомления
- notify_document_comment - уведомление о комментарии к документу
"""
import logging
from django.contrib.auth import get_user_model
from notifications.signals import notify
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
get_bulk_threshold,
get_uploader_name,
)
logger = logging.getLogger(__name__)
Employee = get_user_model()
def notify_document_ready(document, recipient):
"""
Отправляет уведомление конкретному получателю о новом документе.
Args:
document: Объект Document
recipient: Объект Employee
"""
logger.info(
f"[handlers] create_document_ready_notification "
f"doc={document.id} recipient={recipient.id}"
)
uploader_name = get_uploader_name(document.uploaded_by)
notify.send(
sender=document.uploaded_by,
recipient=recipient,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': document.uploaded_by.id if document.uploaded_by else None,
'sent_to_all': document.sent_to_all,
},
)
def notify_all_employees(document):
"""
Отправляет уведомление о новом документе всем активным сотрудникам.
Args:
document: Объект Document
"""
logger.info(
f"[handlers] notify_all_employees doc={document.id}"
)
active_employees = Employee.objects.filter(is_active=True)
# Исключаем загрузившего документ
if document.uploaded_by:
active_employees = active_employees.exclude(id=document.uploaded_by.id)
count = active_employees.count()
logger.info(
f"[handlers] Creating {count} notifications "
f"(web immediately, email/telegram deferred)"
)
# Создаём уведомления
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
for employee in active_employees:
try:
notify.send(
sender=document.uploaded_by,
recipient=employee,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Exception as e:
logger.error(
f"[handlers] Error creating notification for user {employee.id}: {e}"
)
logger.info(
f"[handlers] Created {created_count}/{count} notifications "
f"for doc={document.id}. Web sent immediately, email/telegram pending."
)
# Запускаем асинхронную отправку email/telegram
try:
from django.core.management import call_command
import threading
def send_pending():
try:
call_command('send_pending_notifications', '--batch-size=100')
except Exception as e:
logger.error(f"[handlers] Error in background send: {e}")
# Запускаем в отдельном потоке
thread = threading.Thread(target=send_pending, daemon=True)
thread.start()
logger.info("[handlers] Started background email/telegram send")
except Exception as e:
logger.error(f"[handlers] Error starting background send: {e}")
def notify_specific_users(document, user_ids):
"""
Уведомляет конкретных получателей о документе.
Args:
document: Объект Document
user_ids: Набор ID пользователей
"""
recipient_count = len(user_ids or [])
logger.info(
f"[handlers] Processing {recipient_count} specific recipients"
)
# Определяем, это массовая рассылка или нет
is_bulk = recipient_count >= get_bulk_threshold()
if is_bulk:
logger.info(
f"[handlers] Bulk mode activated "
f"({recipient_count} >= {get_bulk_threshold()})"
)
# Уведомляем получателей
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
for user_id in user_ids:
try:
user = Employee.objects.get(id=user_id)
logger.debug(
f"[handlers] Creating notification for user={user_id}"
)
notify.send(
sender=document.uploaded_by,
recipient=user,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Employee.DoesNotExist:
logger.warning(f"[handlers] User {user_id} not found, skipping")
except Exception as e:
logger.error(f"[handlers] Error creating notification for user {user_id}: {e}")
logger.info(f"[handlers] Created {created_count} notifications")
def notify_department_employees(document, department_ids):
"""
Уведомляет всех сотрудников выбранных отделов о документе.
Args:
document: Объект Document
department_ids: Набор ID отделов
"""
logger.info(
f"[handlers] Processing {len(department_ids)} departments"
)
if not department_ids:
logger.warning("[handlers] No departments provided")
return
# Получаем всех активных сотрудников выбранных отделов
from employees.models import Department
all_employees = set()
for dept_id in department_ids:
try:
department = Department.objects.get(id=dept_id)
dept_employees = department.active_employees
logger.info(
f"[handlers] Department '{department.name}' "
f"has {len(dept_employees)} active employees: "
f"{[e.id for e in dept_employees]}"
)
all_employees.update(dept_employees)
logger.info(
f"[handlers] After update, all_employees has: "
f"{[e.id for e in all_employees]}"
)
except Department.DoesNotExist:
logger.warning(
f"[handlers] Department {dept_id} not found"
)
continue
# Исключаем загрузившего
if document.uploaded_by and document.uploaded_by in all_employees:
all_employees.remove(document.uploaded_by)
recipient_count = len(all_employees)
logger.info(
f"[handlers] Processing {recipient_count} employees "
f"from {len(department_ids)} departments"
)
# Определяем режим рассылки
is_bulk = recipient_count >= get_bulk_threshold()
if is_bulk:
logger.info(
f"[handlers] Bulk mode activated "
f"({recipient_count} >= {get_bulk_threshold()})"
)
# Создаём уведомления
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
for employee in all_employees:
try:
notify.send(
sender=document.uploaded_by,
recipient=employee,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Exception as e:
logger.error(f"[handlers] Error creating notification for employee {employee.id}: {e}")
logger.info(f"[handlers] Created {created_count} notifications")
def notify_all_acknowledged(document, total_recipients, acknowledged_count):
"""
Уведомляет загрузившего о том, что все ознакомились с документом.
Args:
document: Объект Document
total_recipients: Общее количество получателей
acknowledged_count: Количество ознакомившихся
"""
if not document.uploaded_by:
logger.info(
"[handlers] Document has no uploader, skipping acknowledgement notification"
)
return
logger.info(
f"[handlers] Notifying uploader about full acknowledgement "
f"({acknowledged_count}/{total_recipients})"
)
notify.send(
sender=None,
recipient=document.uploaded_by,
verb=NotificationVerbs.DOCUMENT_SIGNED_ALL,
action_object=document,
description=MessageTemplates.all_acknowledged(document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.all_acknowledged_title(),
'document_id': document.id,
'total_recipients': total_recipients,
'acknowledged_count': acknowledged_count,
}
)
def notify_document_comment(document, comment):
"""
Уведомляет автора документа о новом комментарии.
Args:
document: Объект Document
comment: Объект DocumentComment
"""
author = comment.author
if not document.uploaded_by:
logger.info(
"[handlers] Document has no author, skipping"
)
return
doc_author = document.uploaded_by
# Не уведомляем самого себя
if doc_author.id == author.id:
logger.info(
"[handlers] Skipping self-comment notification"
)
return
logger.info(
f"[handlers] Notifying document author={doc_author.pk} "
f"about new comment"
)
author_name = author.get_full_name() or author.username
notify.send(
sender=author,
recipient=doc_author,
verb=NotificationVerbs.DOCUMENT_COMMENT,
action_object=comment,
description=MessageTemplates.comment(author_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.comment_title(),
'document_id': document.id,
'comment_id': comment.id,
'author_id': author.id,
},
)
def notify_comment_reply(parent_comment, reply_comment):
"""
Уведомляет автора комментария об ответе на него.
Args:
parent_comment: Объект DocumentComment (родительский)
reply_comment: Объект DocumentComment (ответ)
"""
author = reply_comment.author
parent_author = parent_comment.author
document = reply_comment.document
# Не уведомляем самого себя
if parent_author.id == author.id:
logger.info(
"[handlers] Skipping self-reply notification"
)
return
logger.info(
f"[handlers] Notifying parent author={parent_author.pk} about reply"
)
author_name = author.get_full_name() or author.username
notify.send(
sender=author,
recipient=parent_author,
verb=NotificationVerbs.DOCUMENT_COMMENT_REPLY,
action_object=reply_comment,
description=MessageTemplates.comment_reply(author_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.comment_reply_title(),
'document_id': document.id,
'comment_id': reply_comment.id,
'parent_comment_id': parent_comment.id,
'author_id': author.id,
},
)

View File

@ -0,0 +1,190 @@
"""
Django signals для автоматической генерации уведомлений в модуле Documents.
Обрабатывает события:
- post_save для Document - создание нового документа
- m2m_changed для Document.recipients - добавление конкретных получателей
- m2m_changed для Document.departments - добавление отделов
- post_save для DocumentAcknowledgement - проверка полного ознакомления
- post_save для DocumentComment - новый комментарий или ответ
"""
import logging
from django.contrib.auth import get_user_model
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from ..models import Document, DocumentAcknowledgement, DocumentComment
from .handlers import (
notify_all_employees,
notify_specific_users,
notify_department_employees,
notify_all_acknowledged,
notify_document_comment,
notify_comment_reply,
)
logger = logging.getLogger(__name__)
Employee = get_user_model()
@receiver(post_save, sender=Document)
def create_document_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при создании нового документа.
Уведомления отправляются:
- Всем активным сотрудникам (если sent_to_all=True)
- Сотрудникам выбранных отделов (через m2m_changed signal)
- Выбранным получателям (через m2m_changed signal)
"""
logger.info(
f"[signals] post_save Document id={instance.pk} "
f"created={created} sent_to_all={instance.sent_to_all}"
)
if not created:
return
document = instance
# Если документ отправляется всем - создаем уведомления напрямую
# channels.py автоматически отправит через Celery
if document.sent_to_all:
logger.info(
f"[signals] Creating notifications for doc={document.pk} (sent_to_all=True)"
)
notify_all_employees(document)
else:
logger.info(
f"[signals] Skipping notifications (sent_to_all=False), "
f"waiting for m2m_changed"
)
@receiver(m2m_changed, sender=Document.recipients.through)
def notify_specific_recipients(sender, instance, action, pk_set, **kwargs):
"""
Уведомляет конкретных получателей при добавлении в список.
Использует быструю обработку для большого количества получателей.
"""
logger.info(
f"[signals] m2m_changed Document id={instance.pk} "
f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}"
)
if action != 'post_add':
logger.info(
f"[signals] Skipping (action={action} != 'post_add')"
)
return
document = instance
# Не отправляем индивидуальные уведомления если документ для всех
if document.sent_to_all:
logger.info(
"[signals] Skipping (sent_to_all=True)"
)
return
# Отправляем уведомления через handlers
notify_specific_users(document, pk_set)
@receiver(m2m_changed, sender=Document.departments.through)
def notify_department_employees_signal(sender, instance, action, pk_set, **kwargs):
"""
Уведомляет всех сотрудников выбранных отделов при добавлении отделов.
Уведомления получат ВСЕ текущие и будущие сотрудники этих отделов.
"""
logger.info(
f"[signals] m2m_changed Document.departments "
f"id={instance.pk} action={action} pk_set={pk_set}"
)
if action != 'post_add':
logger.info(
f"[signals] Skipping (action={action} != 'post_add')"
)
return
document = instance
# Не отправляем если документ для всех
if document.sent_to_all:
logger.info(
"[signals] Skipping (sent_to_all=True)"
)
return
# Отправляем уведомления через handlers
notify_department_employees(document, pk_set)
@receiver(post_save, sender=DocumentAcknowledgement)
def check_all_acknowledged(sender, instance, created, **kwargs):
"""
Проверяет, все ли получатели ознакомились с документом.
Если да - уведомляет загрузившего.
"""
if not created:
return
acknowledgement = instance
document = acknowledgement.document
# Определяем общее количество получателей
if document.sent_to_all:
total_recipients = Employee.objects.filter(is_active=True).count()
else:
# Считаем получателей из recipients + departments
recipients_count = document.recipients.count()
# Добавляем сотрудников из отделов
from employees.models import Department
department_employees = set()
for department in document.departments.all():
department_employees.update(department.active_employees)
# Исключаем дубликаты с recipients
total_recipients = recipients_count + len(department_employees)
# Проверяем количество ознакомившихся
acknowledged_count = document.acknowledgements.count()
# Если все ознакомились - уведомляем загрузившего
if (acknowledged_count >= total_recipients and
total_recipients > 0 and
document.uploaded_by):
notify_all_acknowledged(document, total_recipients, acknowledged_count)
@receiver(post_save, sender=DocumentComment)
def notify_on_new_comment(sender, instance, created, **kwargs):
"""
Отправка уведомлений при создании нового комментария.
- Если это ответ на комментарий (parent != None): уведомить автора родительского комментария
- Если это обычный комментарий: уведомить автора документа (если он не автор комментария)
"""
if not created:
return
comment = instance
document = comment.document
author = comment.author
logger.info(
f"[signals] New comment on document={document.pk} "
f"by user={author.pk} parent={comment.parent_id}"
)
# Если это ответ на комментарий
if comment.parent:
notify_comment_reply(comment.parent, comment)
else:
# Обычный комментарий - уведомляем автора документа
notify_document_comment(document, comment)