From cc24a073f466a6267cee3889adc20fee8c4cc185 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Mar 2026 13:36:04 +0300 Subject: [PATCH] =?UTF-8?q?refactor(documents):=20=D1=80=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20notifications=20=D0=B2=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - Монолитный 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 - успешно --- backend/documents/apps.py | 4 +- ...signals.py => notification_signals.py.old} | 0 backend/documents/notifications/__init__.py | 35 ++ backend/documents/notifications/config.py | 114 +++++ backend/documents/notifications/handlers.py | 404 ++++++++++++++++++ backend/documents/notifications/signals.py | 190 ++++++++ 6 files changed, 745 insertions(+), 2 deletions(-) rename backend/documents/{notification_signals.py => notification_signals.py.old} (100%) create mode 100644 backend/documents/notifications/__init__.py create mode 100644 backend/documents/notifications/config.py create mode 100644 backend/documents/notifications/handlers.py create mode 100644 backend/documents/notifications/signals.py diff --git a/backend/documents/apps.py b/backend/documents/apps.py index 70360190..182d2a07 100644 --- a/backend/documents/apps.py +++ b/backend/documents/apps.py @@ -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: регистрация предикатов и правил доступа diff --git a/backend/documents/notification_signals.py b/backend/documents/notification_signals.py.old similarity index 100% rename from backend/documents/notification_signals.py rename to backend/documents/notification_signals.py.old diff --git a/backend/documents/notifications/__init__.py b/backend/documents/notifications/__init__.py new file mode 100644 index 00000000..586a3a68 --- /dev/null +++ b/backend/documents/notifications/__init__.py @@ -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', +] diff --git a/backend/documents/notifications/config.py b/backend/documents/notifications/config.py new file mode 100644 index 00000000..dc163616 --- /dev/null +++ b/backend/documents/notifications/config.py @@ -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 'Администратор' diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py new file mode 100644 index 00000000..86e1513b --- /dev/null +++ b/backend/documents/notifications/handlers.py @@ -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, + }, + ) diff --git a/backend/documents/notifications/signals.py b/backend/documents/notifications/signals.py new file mode 100644 index 00000000..016a769d --- /dev/null +++ b/backend/documents/notifications/signals.py @@ -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)