diff --git a/backend/requests_app/apps.py b/backend/requests_app/apps.py index 9efa0562..cfc86788 100644 --- a/backend/requests_app/apps.py +++ b/backend/requests_app/apps.py @@ -6,6 +6,6 @@ class RequestsAppConfig(AppConfig): name = 'requests_app' def ready(self): - # Подключаем signals для уведомлений - import requests_app.notification_signals # noqa + # Подключаем модуль уведомлений (signals регистрируются автоматически) + import requests_app.notifications.signals # noqa import requests_app.rules # django-rules: регистрация предикатов и правил доступа diff --git a/backend/requests_app/notification_signals.py b/backend/requests_app/notification_signals.py deleted file mode 100644 index 94987b53..00000000 --- a/backend/requests_app/notification_signals.py +++ /dev/null @@ -1,516 +0,0 @@ -""" -Signals для автоматической генерации уведомлений в модуле Requests. - -Обрабатывает события: -- Новое заявление (для ответственных/руководителей) -- Одобрение заявления -- Отклонение заявления -- Комментарий к заявлению -- Изменение статуса заявления -""" - -import logging - -from django.contrib.auth import get_user_model -from django.db.models.signals import m2m_changed, post_save, pre_save -from django.dispatch import receiver -from notifications.signals import notify - -from .models import Request, RequestComment - -Employee = get_user_model() -logger = logging.getLogger(__name__) - - -# Флаг для отслеживания новых заявлений, ожидающих уведомления -_pending_new_requests = set() - - -@receiver(post_save, sender=Request) -def create_request_notifications(sender, instance, created, **kwargs): - """ - Создает уведомления при создании или изменении заявления. - - Обрабатывает: - 1. Новое заявление - помечаем для отправки уведомлений после установки recipients - 2. Изменение статуса - уведомление автору - """ - try: - request_obj = instance - - # Логирование для отладки - print( - f"🔔 [SIGNAL] create_request_notifications: created={created}, status={request_obj.status}, id={request_obj.id}" - ) - - if created and request_obj.status != "draft": - # Помечаем заявление как ожидающее отправки уведомлений - # Уведомления будут отправлены после установки recipients через m2m_changed - print( - f"📌 [SIGNAL] Помечаем заявление #{request_obj.id} для отправки уведомлений после установки recipients" - ) - _pending_new_requests.add(request_obj.id) - else: - print( - f"⏭️ [SIGNAL] Пропускаем: created={created}, status={request_obj.status}" - ) - - if not created: - # Проверяем изменение статуса через сохраненный атрибут _old_status - if hasattr(request_obj, "_old_status"): - old_status = request_obj._old_status - new_status = request_obj.status - - if old_status != new_status: - # Отправляем уведомления напрямую - channels.py автоматически - # сделает асинхронную отправку через Celery - notify_status_change(request_obj, old_status, new_status) - except Exception as e: - logger.exception(f"[SIGNAL ERROR] create_request_notifications: {e}") - print(f"❌ [SIGNAL ERROR] create_request_notifications: {e}") - - -@receiver(pre_save, sender=Request) -def track_status_change(sender, instance, **kwargs): - """ - Сохраняем старый статус перед обновлением для отслеживания изменений. - """ - if instance.pk: - try: - old_instance = Request.objects.get(pk=instance.pk) - instance._old_status = old_instance.status - except Request.DoesNotExist: - instance._old_status = None - - -@receiver(m2m_changed, sender=Request.recipients.through) -def notify_on_recipients_changed(sender, instance, action, **kwargs): - """ - Отправляет уведомления о новом заявлении ПОСЛЕ установки recipients. - - Срабатывает когда recipients устанавливаются через .set(), .add() и т.д. - """ - try: - # Проверяем что это завершение операции установки recipients - if action == "post_add" and instance.id in _pending_new_requests: - print( - f"🎯 [M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, отправляем уведомления" - ) - _pending_new_requests.discard(instance.id) - - # Отправляем уведомления напрямую - channels.py автоматически - # сделает асинхронную отправку через Celery - notify_new_request(instance) - except Exception as e: - logger.exception(f"[SIGNAL ERROR] notify_on_recipients_changed: {e}") - print(f"❌ [SIGNAL ERROR] notify_on_recipients_changed: {e}") - - -@receiver(m2m_changed, sender=Request.cc_users.through) -def notify_on_cc_users_changed(sender, instance, action, **kwargs): - """ - Дополнительная проверка для cc_users. - - Если recipients не были установлены, но установлены cc_users, - также отправляем уведомления. - """ - try: - # Если это завершение добавления cc_users И заявление все еще ожидает уведомлений - if action == "post_add" and instance.id in _pending_new_requests: - # Проверяем что recipients пусты (значит уведомления еще не отправлены) - if instance.recipients.count() == 0: - print( - f"🎯 [M2M_SIGNAL] CC users установлены для заявления #{instance.id} (без recipients), отправляем уведомления" - ) - _pending_new_requests.discard(instance.id) - - # Отправляем уведомления напрямую - channels.py автоматически - # сделает асинхронную отправку через Celery - notify_new_request(instance) - except Exception as e: - logger.exception(f"[SIGNAL ERROR] notify_on_cc_users_changed: {e}") - print(f"❌ [SIGNAL ERROR] notify_on_cc_users_changed: {e}") - - -@receiver(post_save, sender=RequestComment) -def create_comment_notification(sender, instance, created, **kwargs): - """ - Создает уведомления при добавлении комментария к заявлению. - Уведомляет: - - Автора заявления - - Всех получателей - - Всех в копии - - Согласующего - - Сотрудников отделов (если sent_to_all_department) - - notify.send() → channels.py автоматически отправит через Celery - """ - if not created: - return - - author = instance.author - recipients_set = set() - request_obj = instance.request - - # Автор заявки - if request_obj.employee.id != author.id: - recipients_set.add(request_obj.employee) - - # Получатели - recipients_set.update( - request_obj.recipients.filter(is_active=True).exclude(id=author.id) - ) - - # CC - recipients_set.update( - request_obj.cc_users.filter(is_active=True).exclude(id=author.id) - ) - - # Согласующий - if request_obj.approver and request_obj.approver.id != author.id: - recipients_set.add(request_obj.approver) - - # Если sent_to_all_department - все сотрудники отделов - if request_obj.sent_to_all_department: - dept_employees = ( - Employee.objects.filter( - departments_links__department__in=request_obj.departments.all(), - departments_links__is_active=True, - is_active=True, - ) - .exclude(id__in=[author.id, request_obj.employee.id]) - .distinct() - ) - - recipients_set.update(dept_employees) - - # Отправляем уведомления - author_name = author.get_full_name() or author.username - request_type = request_obj.get_type_display() - employee_name = ( - request_obj.employee.get_full_name() or request_obj.employee.username - ) - - for recipient in recipients_set: - # Не отправляем уведомление тому, кто сам написал комментарий - if recipient.id == author.id: - continue - - notify.send( - sender=author, - recipient=recipient, - verb='request_comment', - action_object=request_obj, - description=( - f"{author_name} прокомментировал заявление " - f'"{request_type}": {instance.text[:100]}' - ), - action_url='/requests', - data={ - 'title': f'💬 Новый комментарий к заявлению от {employee_name}', - 'request_id': request_obj.id, - 'request_type': request_obj.type, - 'comment_id': instance.id, - 'author_id': author.id, - }, - ) - - -# ===== Вспомогательные функции ===== - - -def notify_new_request(request_obj): - """ - Отправляет уведомление о новом заявлении: - - Всем основным получателям (recipients) - адресованное им - - Всем в копии (cc_users) - с пометкой "в копии" - - Согласующему (approver) - - Руководителям отделов - - Пользователям с правом can_process_requests - - При sent_to_all_department=True отправляет всем сотрудникам отделов - """ - logger.info( - f"\n{'=' * 80}\n" - f"[notify_new_request] 📨 НАЧАЛО ОТПРАВКИ УВЕДОМЛЕНИЙ О НОВОМ ЗАЯВЛЕНИИ\n" - f" Request ID: {request_obj.id}\n" - f" Тип: {request_obj.get_type_display()}\n" - f" Сотрудник: {request_obj.employee}\n" - f" Статус: {request_obj.status}\n" - f"{'=' * 80}" - ) - - print(f"📨 [notify_new_request] Начинаем обработку заявления #{request_obj.id}") - - recipients_set = set() - - # 1. Основные получатели - recipients_count = request_obj.recipients.count() - logger.info( - f"[notify_new_request] Основные получатели (recipients): {recipients_count}" - ) - print(f" Recipients в БД: {recipients_count}") - for recipient in request_obj.recipients.filter(is_active=True): - recipients_set.add(recipient) - logger.info( - f"[notify_new_request] ✅ Основной получатель: {recipient.username} (ID={recipient.id}, email={recipient.email})" - ) - print( - f" ✅ Добавлен основной получатель: {recipient.username} (ID: {recipient.id})" - ) - - # 2. Копия (CC) - cc_count = request_obj.cc_users.count() - logger.info(f"[notify_new_request] CC users: {cc_count}") - print(f" CC users в БД: {cc_count}") - for cc_user in request_obj.cc_users.filter(is_active=True): - recipients_set.add(cc_user) - logger.info( - f"[notify_new_request] ✅ CC получатель: {cc_user.username} (ID={cc_user.id}, email={cc_user.email})" - ) - print(f" ✅ Добавлен CC: {cc_user.username} (ID: {cc_user.id})") - - # 3. Если sent_to_all_department - все сотрудники отделов - if request_obj.sent_to_all_department: - dept_employees = ( - Employee.objects.filter( - departments_links__department__in=request_obj.departments.all(), - departments_links__is_active=True, - is_active=True, - ) - .exclude(id=request_obj.employee.id) - .distinct() - ) - - recipients_set.update(dept_employees) - - # 4. Согласующий - if request_obj.approver and request_obj.approver.id != request_obj.employee.id: - recipients_set.add(request_obj.approver) - - # 5. Руководители отделов - for department in request_obj.departments.all(): - if department.head and department.head.id != request_obj.employee.id: - recipients_set.add(department.head) - - # Также проверяем старое поле department для обратной совместимости - if request_obj.department and request_obj.department.head: - if request_obj.department.head.id != request_obj.employee.id: - recipients_set.add(request_obj.department.head) - - # 6. Пользователи с правом обрабатывать заявки в этих отделах - dept_ids = list(request_obj.departments.values_list("id", flat=True)) - if request_obj.department_id and request_obj.department_id not in dept_ids: - dept_ids.append(request_obj.department_id) - - if dept_ids: - dept_processors = ( - Employee.objects.filter( - departments_links__department_id__in=dept_ids, - departments_links__is_active=True, - departments_links__role__scoped_permissions__code="can_process_requests", - is_active=True, - ) - .exclude(id=request_obj.employee.id) - .distinct() - ) - - recipients_set.update(dept_processors) - - # Итоговое логирование - logger.info(f"[notify_new_request] 📊 ИТОГО получателей: {len(recipients_set)}") - print( - f"📊 [notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}" - ) - if len(recipients_set) == 0: - logger.warning( - f"[notify_new_request] ⚠️ НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не отправлены для request_id={request_obj.id}" - ) - print( - f"⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не будут отправлены." - ) - return - - for r in recipients_set: - logger.info( - f"[notify_new_request] 👤 {r.username} (ID={r.id}, email={r.email})" - ) - print(f" 👤 {r.username} (ID: {r.id})") - - # Определяем тип уведомления для каждого получателя - author_name = request_obj.employee.get_full_name() or request_obj.employee.username - request_type = request_obj.get_type_display() - comment_preview = ( - request_obj.comment[:150] if request_obj.comment else "Без комментария" - ) - - logger.info( - f"[notify_new_request] Начало отправки {len(recipients_set)} уведомлений..." - ) - - for recipient in recipients_set: - # Определяем роль получателя - is_primary = request_obj.recipients.filter(id=recipient.id).exists() - is_cc = request_obj.cc_users.filter(id=recipient.id).exists() - is_approver = request_obj.approver_id == recipient.id - - logger.info( - f"[notify_new_request] 📤 Отправка для {recipient.username}: " - f"primary={is_primary}, cc={is_cc}, approver={is_approver}" - ) - print( - f" 📤 Отправка уведомления для {recipient.username}: " - f"primary={is_primary}, cc={is_cc}, approver={is_approver}" - ) - - # Формируем заголовок и сообщение с учетом роли - if is_primary: - # Основной получатель - требует действия - title = f"📩 Вам адресовано заявление от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new" # Используем общий тип - elif is_cc: - # Копия - информирование - title = f"📋 Вы в копии заявления от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new" # Используем общий тип - elif is_approver: - title = f"✅ Новое заявление на согласование от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new" # Используем общий тип - else: - # Руководитель отдела или обработчик - title = f"📝 Новое заявление в отделе от {author_name}" - message = f'Тип: "{request_type}". {comment_preview}' - notification_type = "request_new" # Используем общий тип - - logger.info( - f"[notify_new_request] ➡️ Отправка notify.send для {recipient.username}" - ) - - notify.send( - sender=request_obj.employee, - recipient=recipient, - verb=notification_type, - action_object=request_obj, - description=message, - action_url='/requests', - data={ - 'title': title, - 'request_id': request_obj.id, - 'request_type': request_obj.type, - 'employee_id': request_obj.employee.id, - 'employee_name': author_name, - 'is_primary_recipient': is_primary, - 'is_cc': is_cc, - 'is_approver': is_approver, - }, - ) - - -def notify_status_change(request_obj, old_status, new_status): - """ - Уведомляет о изменении статуса: - - Всех получателей (recipients) - - Всех в копии (cc_users) - - Сотрудников отделов (если sent_to_all_department) - - ВАЖНО: - - Автор (employee) не получает уведомление о решении (approve/reject) - - Approver (тот кто принял решение) не получает уведомление о своем же решении - """ - recipients_to_notify = set() - - # ID пользователей которых НЕ нужно уведомлять при approve/reject - exclude_ids = set() - if new_status in ("approved", "rejected"): - # ВАЖНО: Уведомляем автора заявки о решении - это единственный способ - # информировать его, так как alert() был убран из UI (коммит 935c12a) - # Не уведомляем только того кто принял решение (он сам нажал кнопку) - if request_obj.approver_id: - exclude_ids.add(request_obj.approver_id) - - # 1. Автор - УВЕДОМЛЯЕМ всегда (даже при approved/rejected, это важно!) - recipients_to_notify.add(request_obj.employee) - - # 2. Основные получатели (исключая тех кто в exclude_ids) - recipients_to_notify.update( - request_obj.recipients.filter(is_active=True).exclude(id__in=exclude_ids) - ) - - # 3. Копия (исключая тех кто в exclude_ids) - recipients_to_notify.update( - request_obj.cc_users.filter(is_active=True).exclude(id__in=exclude_ids) - ) - - # 4. Если sent_to_all_department - все сотрудники отделов (исключая exclude_ids) - if request_obj.sent_to_all_department: - # При approve/reject также исключаем approver - all_exclude_ids = {request_obj.employee.id} - all_exclude_ids.update(exclude_ids) - - dept_employees = ( - Employee.objects.filter( - departments_links__department__in=request_obj.departments.all(), - departments_links__is_active=True, - is_active=True, - ) - .exclude(id__in=all_exclude_ids) - .distinct() - ) - - recipients_to_notify.update(dept_employees) - - # Формируем уведомления с подробной информацией - request_type = request_obj.get_type_display() - employee_name = ( - request_obj.employee.get_full_name() or request_obj.employee.username - ) - approver_name = ( - request_obj.approver.get_full_name() if request_obj.approver else "Руководитель" - ) - - for recipient in recipients_to_notify: - if new_status == 'approved': - notification_type = 'request_approved' - title = f'✅ Заявление одобрено: {request_type}' - message = ( - f'Заявление от {employee_name} "{request_type}" ' - f'одобрено пользователем {approver_name}' - ) - elif new_status == 'rejected': - notification_type = 'request_rejected' - title = f'❌ Заявление отклонено: {request_type}' - message = ( - f'Заявление от {employee_name} "{request_type}" ' - f'отклонено пользователем {approver_name}' - ) - else: - notification_type = 'request_status_changed' - title = f'🔄 Статус заявления изменен: {request_type}' - message = ( - f'Статус заявления от {employee_name} "{request_type}" ' - f'изменен: {old_status} → {new_status}' - ) - - notify.send( - sender=request_obj.approver if request_obj.approver else None, - recipient=recipient, - verb=notification_type, - action_object=request_obj, - description=message, - action_url='/requests', - data={ - 'title': title, - 'request_id': request_obj.id, - 'request_type': request_obj.type, - 'employee_id': request_obj.employee.id, - 'employee_name': employee_name, - 'old_status': old_status, - 'new_status': new_status, - 'approver_id': ( - request_obj.approver.id if request_obj.approver else None - ), - 'approver_name': approver_name, - }, - ) diff --git a/backend/requests_app/notifications/__init__.py b/backend/requests_app/notifications/__init__.py new file mode 100644 index 00000000..64f3b48e --- /dev/null +++ b/backend/requests_app/notifications/__init__.py @@ -0,0 +1,48 @@ +""" +Модуль уведомлений для приложения Requests. + +Обеспечивает автоматическую отправку уведомлений при: +- Создании нового заявления +- Изменении статуса заявления +- Добавлении комментария к заявлению + +Использует универсальную систему уведомлений (backend/notifications). + +Структура: +- constants.py - константы (типы уведомлений, шаблоны сообщений) +- handlers.py - бизнес-логика отправки уведомлений +- signals.py - Django сигналы для автоматической генерации + +Usage: + # Сигналы подключаются автоматически через AppConfig.ready() + # Для ручной отправки: + from requests_app.notifications import notify_new_request + notify_new_request(request_obj) +""" + +# Импорты для удобства и обратной совместимости +from .constants import NotificationVerbs, MessageTemplates, ActionURLs +from .handlers import ( + notify_new_request, + notify_status_change, + notify_comment, +) + +# Сигналы импортируются для регистрации через AppConfig.ready() +from . import signals # noqa: F401 + + +__all__ = [ + # Constants + 'NotificationVerbs', + 'MessageTemplates', + 'ActionURLs', + + # Handlers + 'notify_new_request', + 'notify_status_change', + 'notify_comment', + + # Signals module (для импорта в AppConfig) + 'signals', +] diff --git a/backend/requests_app/notifications/constants.py b/backend/requests_app/notifications/constants.py new file mode 100644 index 00000000..0d97af15 --- /dev/null +++ b/backend/requests_app/notifications/constants.py @@ -0,0 +1,171 @@ +""" +Константы для уведомлений модуля Requests. + +Определяет типы уведомлений (verbs) и шаблоны сообщений. +""" + + +# ===== Типы уведомлений (Verbs) ===== + +class NotificationVerbs: + """Типы уведомлений для заявлений.""" + + # Новое заявление (универсальный для всех ролей получателей) + REQUEST_NEW = 'request_new' + + # Комментарий к заявлению + REQUEST_COMMENT = 'request_comment' + + # Изменение статуса + REQUEST_APPROVED = 'request_approved' + REQUEST_REJECTED = 'request_rejected' + REQUEST_STATUS_CHANGED = 'request_status_changed' + + +# ===== Шаблоны сообщений ===== + +class MessageTemplates: + """Шаблоны сообщений для уведомлений.""" + + @staticmethod + def new_request_primary( + author_name: str, request_type: str, comment_preview: str + ) -> tuple[str, str]: + """ + Шаблон для основных получателей заявления. + + Returns: + tuple: (title, message) + """ + title = f"📩 Вам адресовано заявление от {author_name}" + message = f'Тип: "{request_type}". {comment_preview}' + return title, message + + @staticmethod + def new_request_cc( + author_name: str, request_type: str, comment_preview: str + ) -> tuple[str, str]: + """ + Шаблон для получателей в копии. + + Returns: + tuple: (title, message) + """ + title = f"📋 Вы в копии заявления от {author_name}" + message = f'Тип: "{request_type}". {comment_preview}' + return title, message + + @staticmethod + def new_request_approver( + author_name: str, request_type: str, comment_preview: str + ) -> tuple[str, str]: + """ + Шаблон для согласующего. + + Returns: + tuple: (title, message) + """ + title = f"✅ Новое заявление на согласование от {author_name}" + message = f'Тип: "{request_type}". {comment_preview}' + return title, message + + @staticmethod + def new_request_department( + author_name: str, request_type: str, comment_preview: str + ) -> tuple[str, str]: + """ + Шаблон для руководителей отдела и обработчиков. + + Returns: + tuple: (title, message) + """ + title = f"📝 Новое заявление в отделе от {author_name}" + message = f'Тип: "{request_type}". {comment_preview}' + return title, message + + @staticmethod + def comment( + author_name: str, + request_type: str, + employee_name: str, + comment_text: str, + ) -> tuple[str, str]: + """ + Шаблон для комментария. + + Returns: + tuple: (title, description) + """ + title = f'💬 Новый комментарий к заявлению от {employee_name}' + description = ( + f'{author_name} прокомментировал заявление ' + f'"{request_type}": {comment_text[:100]}' + ) + return title, description + + @staticmethod + def status_approved( + employee_name: str, request_type: str, approver_name: str + ) -> tuple[str, str]: + """ + Шаблон для одобрения заявления. + + Returns: + tuple: (title, message) + """ + title = f'✅ Заявление одобрено: {request_type}' + message = ( + f'Заявление от {employee_name} "{request_type}" ' + f'одобрено пользователем {approver_name}' + ) + return title, message + + @staticmethod + def status_rejected( + employee_name: str, request_type: str, approver_name: str + ) -> tuple[str, str]: + """ + Шаблон для отклонения заявления. + + Returns: + tuple: (title, message) + """ + title = f'❌ Заявление отклонено: {request_type}' + message = ( + f'Заявление от {employee_name} "{request_type}" ' + f'отклонено пользователем {approver_name}' + ) + return title, message + + @staticmethod + def status_changed( + employee_name: str, + request_type: str, + old_status: str, + new_status: str, + ) -> tuple[str, str]: + """ + Шаблон для изменения статуса. + + Returns: + tuple: (title, message) + """ + title = f'🔄 Статус заявления изменен: {request_type}' + message = ( + f'Статус заявления от {employee_name} "{request_type}" ' + f'изменен: {old_status} → {new_status}' + ) + return title, message + + +# ===== URL для действий ===== + +class ActionURLs: + """URL для переходов из уведомлений.""" + + REQUESTS_LIST = '/requests' + + @staticmethod + def request_detail(request_id: int) -> str: + """URL детальной страницы заявления.""" + return f'/requests/{request_id}' diff --git a/backend/requests_app/notifications/handlers.py b/backend/requests_app/notifications/handlers.py new file mode 100644 index 00000000..f9230836 --- /dev/null +++ b/backend/requests_app/notifications/handlers.py @@ -0,0 +1,462 @@ +""" +Обработчики (handlers) для отправки уведомлений о заявлениях. + +Содержит бизнес-логику формирования и отправки уведомлений +для различных событий в модуле Requests. +""" + +import logging +from django.contrib.auth import get_user_model +from notifications.signals import notify + +from .constants import NotificationVerbs, MessageTemplates, ActionURLs + +Employee = get_user_model() +logger = logging.getLogger(__name__) + + +def notify_new_request(request_obj): + """ + Отправляет уведомление о новом заявлении: + - Всем основным получателям (recipients) - адресованное им + - Всем в копии (cc_users) - с пометкой "в копии" + - Согласующему (approver) + - Руководителям отделов + - Пользователям с правом can_process_requests + + При sent_to_all_department=True отправляет всем сотрудникам отделов + + Args: + request_obj: Объект Request + """ + logger.info( + f"\n{'=' * 80}\n" + f"[notify_new_request] 📨 НАЧАЛО ОТПРАВКИ УВЕДОМЛЕНИЙ\n" + f" Request ID: {request_obj.id}\n" + f" Тип: {request_obj.get_type_display()}\n" + f" Сотрудник: {request_obj.employee}\n" + f" Статус: {request_obj.status}\n" + f"{'=' * 80}" + ) + + print( + f"📨 [notify_new_request] Начинаем обработку " + f"заявления #{request_obj.id}" + ) + + recipients_set = set() + + # 1. Основные получатели + recipients_count = request_obj.recipients.count() + logger.info( + f"[notify_new_request] Основные получатели: {recipients_count}" + ) + print(f" Recipients в БД: {recipients_count}") + for recipient in request_obj.recipients.filter(is_active=True): + recipients_set.add(recipient) + logger.info( + f"[notify_new_request] ✅ Основной получатель: " + f"{recipient.username} (ID={recipient.id})" + ) + print( + f" ✅ Добавлен основной получатель: " + f"{recipient.username} (ID: {recipient.id})" + ) + + # 2. Копия (CC) + cc_count = request_obj.cc_users.count() + logger.info(f"[notify_new_request] CC users: {cc_count}") + print(f" CC users в БД: {cc_count}") + for cc_user in request_obj.cc_users.filter(is_active=True): + recipients_set.add(cc_user) + logger.info( + f"[notify_new_request] ✅ CC получатель: " + f"{cc_user.username} (ID={cc_user.id})" + ) + print(f" ✅ Добавлен CC: {cc_user.username} (ID: {cc_user.id})") + + # 3. Если sent_to_all_department - все сотрудники отделов + if request_obj.sent_to_all_department: + dept_employees = ( + Employee.objects.filter( + departments_links__department__in=( + request_obj.departments.all() + ), + departments_links__is_active=True, + is_active=True, + ) + .exclude(id=request_obj.employee.id) + .distinct() + ) + recipients_set.update(dept_employees) + + # 4. Согласующий + if ( + request_obj.approver + and request_obj.approver.id != request_obj.employee.id + ): + recipients_set.add(request_obj.approver) + + # 5. Руководители отделов + for department in request_obj.departments.all(): + if department.head and department.head.id != request_obj.employee.id: + recipients_set.add(department.head) + + # Также проверяем старое поле department для обратной совместимости + if request_obj.department and request_obj.department.head: + if request_obj.department.head.id != request_obj.employee.id: + recipients_set.add(request_obj.department.head) + + # 6. Пользователи с правом обрабатывать заявки в этих отделах + dept_ids = list(request_obj.departments.values_list("id", flat=True)) + if request_obj.department_id and request_obj.department_id not in dept_ids: + dept_ids.append(request_obj.department_id) + + if dept_ids: + dept_processors = ( + Employee.objects.filter( + departments_links__department_id__in=dept_ids, + departments_links__is_active=True, + departments_links__role__scoped_permissions__code=( + "can_process_requests" + ), + is_active=True, + ) + .exclude(id=request_obj.employee.id) + .distinct() + ) + recipients_set.update(dept_processors) + + # Итоговое логирование + logger.info( + f"[notify_new_request] 📊 ИТОГО получателей: " + f"{len(recipients_set)}" + ) + print( + f"📊 [notify_new_request] Всего получателей для заявления " + f"#{request_obj.id}: {len(recipients_set)}" + ) + + if len(recipients_set) == 0: + logger.warning( + f"[notify_new_request] ⚠️ НЕТ ПОЛУЧАТЕЛЕЙ! " + f"request_id={request_obj.id}" + ) + print( + "⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! " + "Уведомления не будут отправлены." + ) + return + + for r in recipients_set: + logger.info( + f"[notify_new_request] 👤 {r.username} (ID={r.id})" + ) + print(f" 👤 {r.username} (ID: {r.id})") + + # Подготовка общих данных + author_name = request_obj.employee.get_full_name() or request_obj.employee.username + request_type = request_obj.get_type_display() + comment_preview = ( + request_obj.comment[:150] if request_obj.comment else "Без комментария" + ) + + logger.info( + f"[notify_new_request] Начало отправки {len(recipients_set)} уведомлений..." + ) + + # Отправка уведомлений каждому получателю + for recipient in recipients_set: + _send_new_request_notification( + request_obj=request_obj, + recipient=recipient, + author_name=author_name, + request_type=request_type, + comment_preview=comment_preview, + ) + + +def _send_new_request_notification(request_obj, recipient, author_name, request_type, comment_preview): + """ + Отправляет уведомление о новом заявлении конкретному получателю. + + Определяет роль получателя и формирует соответствующее сообщение. + + Args: + request_obj: Объект Request + recipient: Получатель (Employee) + author_name: Имя автора заявления + request_type: Тип заявления (отображаемое название) + comment_preview: Превью комментария + """ + # Определяем роль получателя + is_primary = request_obj.recipients.filter(id=recipient.id).exists() + is_cc = request_obj.cc_users.filter(id=recipient.id).exists() + is_approver = request_obj.approver_id == recipient.id + + logger.info( + f"[notify_new_request] 📤 Отправка для {recipient.username}: " + f"primary={is_primary}, cc={is_cc}, approver={is_approver}" + ) + print( + f" 📤 Отправка уведомления для {recipient.username}: " + f"primary={is_primary}, cc={is_cc}, approver={is_approver}" + ) + + # Формируем заголовок и сообщение с учетом роли + if is_primary: + title, message = MessageTemplates.new_request_primary( + author_name, request_type, comment_preview + ) + elif is_cc: + title, message = MessageTemplates.new_request_cc( + author_name, request_type, comment_preview + ) + elif is_approver: + title, message = MessageTemplates.new_request_approver( + author_name, request_type, comment_preview + ) + else: + title, message = MessageTemplates.new_request_department( + author_name, request_type, comment_preview + ) + + logger.info( + f"[notify_new_request] ➡️ Отправка notify.send для {recipient.username}" + ) + + notify.send( + sender=request_obj.employee, + recipient=recipient, + verb=NotificationVerbs.REQUEST_NEW, + action_object=request_obj, + description=message, + action_url=ActionURLs.REQUESTS_LIST, + data={ + 'title': title, + 'request_id': request_obj.id, + 'request_type': request_obj.type, + 'employee_id': request_obj.employee.id, + 'employee_name': author_name, + 'is_primary_recipient': is_primary, + 'is_cc': is_cc, + 'is_approver': is_approver, + }, + ) + + +def notify_status_change(request_obj, old_status, new_status): + """ + Уведомляет о изменении статуса: + - Всех получателей (recipients) + - Всех в копии (cc_users) + - Сотрудников отделов (если sent_to_all_department) + + ВАЖНО: + - Автор (employee) ВСЕГДА получает уведомление о решении (approve/reject) + - Approver (тот кто принял решение) НЕ получает уведомление о своем же решении + + Args: + request_obj: Объект Request + old_status: Старый статус + new_status: Новый статус + """ + recipients_to_notify = set() + + # ID пользователей которых НЕ нужно уведомлять при approve/reject + exclude_ids = set() + if new_status in ("approved", "rejected"): + # ВАЖНО: Уведомляем автора заявки о решении - это единственный способ + # информировать его, так как alert() был убран из UI (коммит 935c12a) + # Не уведомляем только того кто принял решение (он сам нажал кнопку) + if request_obj.approver_id: + exclude_ids.add(request_obj.approver_id) + + # 1. Автор - УВЕДОМЛЯЕМ всегда (даже при approved/rejected, это важно!) + recipients_to_notify.add(request_obj.employee) + + # 2. Основные получатели (исключая тех кто в exclude_ids) + recipients_to_notify.update( + request_obj.recipients.filter(is_active=True).exclude(id__in=exclude_ids) + ) + + # 3. Копия (исключая тех кто в exclude_ids) + recipients_to_notify.update( + request_obj.cc_users.filter(is_active=True).exclude(id__in=exclude_ids) + ) + + # 4. Если sent_to_all_department - все сотрудники отделов (исключая exclude_ids) + if request_obj.sent_to_all_department: + # При approve/reject также исключаем approver + all_exclude_ids = {request_obj.employee.id} + all_exclude_ids.update(exclude_ids) + + dept_employees = ( + Employee.objects.filter( + departments_links__department__in=request_obj.departments.all(), + departments_links__is_active=True, + is_active=True, + ) + .exclude(id__in=all_exclude_ids) + .distinct() + ) + recipients_to_notify.update(dept_employees) + + # Подготовка общих данных + request_type = request_obj.get_type_display() + employee_name = ( + request_obj.employee.get_full_name() or request_obj.employee.username + ) + approver_name = ( + request_obj.approver.get_full_name() if request_obj.approver else "Руководитель" + ) + + # Отправка уведомлений каждому получателю + for recipient in recipients_to_notify: + _send_status_change_notification( + request_obj=request_obj, + recipient=recipient, + new_status=new_status, + old_status=old_status, + employee_name=employee_name, + request_type=request_type, + approver_name=approver_name, + ) + + +def _send_status_change_notification( + request_obj, recipient, new_status, old_status, employee_name, request_type, approver_name +): + """ + Отправляет уведомление об изменении статуса конкретному получателю. + + Args: + request_obj: Объект Request + recipient: Получатель (Employee) + new_status: Новый статус + old_status: Старый статус + employee_name: Имя автора заявления + request_type: Тип заявления + approver_name: Имя согласующего + """ + # Определяем тип уведомления и формируем сообщение + if new_status == 'approved': + verb = NotificationVerbs.REQUEST_APPROVED + title, message = MessageTemplates.status_approved( + employee_name, request_type, approver_name + ) + elif new_status == 'rejected': + verb = NotificationVerbs.REQUEST_REJECTED + title, message = MessageTemplates.status_rejected( + employee_name, request_type, approver_name + ) + else: + verb = NotificationVerbs.REQUEST_STATUS_CHANGED + title, message = MessageTemplates.status_changed( + employee_name, request_type, old_status, new_status + ) + + notify.send( + sender=request_obj.approver if request_obj.approver else None, + recipient=recipient, + verb=verb, + action_object=request_obj, + description=message, + action_url=ActionURLs.REQUESTS_LIST, + data={ + 'title': title, + 'request_id': request_obj.id, + 'request_type': request_obj.type, + 'employee_id': request_obj.employee.id, + 'employee_name': employee_name, + 'old_status': old_status, + 'new_status': new_status, + 'approver_id': ( + request_obj.approver.id if request_obj.approver else None + ), + 'approver_name': approver_name, + }, + ) + + +def notify_comment(comment_obj): + """ + Отправляет уведомления о новом комментарии к заявлению. + + Уведомляет: + - Автора заявления + - Всех получателей + - Всех в копии + - Согласующего + - Сотрудников отделов (если sent_to_all_department) + + Args: + comment_obj: Объект RequestComment + """ + author = comment_obj.author + recipients_set = set() + request_obj = comment_obj.request + + # Автор заявки + if request_obj.employee.id != author.id: + recipients_set.add(request_obj.employee) + + # Получатели + recipients_set.update( + request_obj.recipients.filter(is_active=True).exclude(id=author.id) + ) + + # CC + recipients_set.update( + request_obj.cc_users.filter(is_active=True).exclude(id=author.id) + ) + + # Согласующий + if request_obj.approver and request_obj.approver.id != author.id: + recipients_set.add(request_obj.approver) + + # Если sent_to_all_department - все сотрудники отделов + if request_obj.sent_to_all_department: + dept_employees = ( + Employee.objects.filter( + departments_links__department__in=request_obj.departments.all(), + departments_links__is_active=True, + is_active=True, + ) + .exclude(id__in=[author.id, request_obj.employee.id]) + .distinct() + ) + recipients_set.update(dept_employees) + + # Подготовка данных + author_name = author.get_full_name() or author.username + request_type = request_obj.get_type_display() + employee_name = ( + request_obj.employee.get_full_name() or request_obj.employee.username + ) + + # Отправка уведомлений + for recipient in recipients_set: + # Не отправляем уведомление тому, кто сам написал комментарий + if recipient.id == author.id: + continue + + title, description = MessageTemplates.comment( + author_name, request_type, employee_name, comment_obj.text + ) + + notify.send( + sender=author, + recipient=recipient, + verb=NotificationVerbs.REQUEST_COMMENT, + action_object=request_obj, + description=description, + action_url=ActionURLs.REQUESTS_LIST, + data={ + 'title': title, + 'request_id': request_obj.id, + 'request_type': request_obj.type, + 'comment_id': comment_obj.id, + 'author_id': author.id, + }, + ) diff --git a/backend/requests_app/notifications/signals.py b/backend/requests_app/notifications/signals.py new file mode 100644 index 00000000..fb477bec --- /dev/null +++ b/backend/requests_app/notifications/signals.py @@ -0,0 +1,159 @@ +""" +Django сигналы для автоматической генерации уведомлений в модуле Requests. + +Обрабатывает события: +- Новое заявление (для ответственных/руководителей) +- Одобрение/отклонение заявления +- Комментарий к заявлению +- Изменение статуса заявления +""" + +import logging +from django.db.models.signals import m2m_changed, post_save, pre_save +from django.dispatch import receiver + +from ..models import Request, RequestComment +from .handlers import notify_new_request, notify_status_change, notify_comment + +logger = logging.getLogger(__name__) + + +# Флаг для отслеживания новых заявлений, ожидающих уведомления +_pending_new_requests = set() + + +@receiver(post_save, sender=Request) +def create_request_notifications(sender, instance, created, **kwargs): + """ + Создает уведомления при создании или изменении заявления. + + Обрабатывает: + 1. Новое заявление - помечаем для отправки уведомлений после установки recipients + 2. Изменение статуса - уведомление автору + """ + try: + request_obj = instance + + # Логирование для отладки + logger.debug( + f"[SIGNAL] create_request_notifications: created={created}, " + f"status={request_obj.status}, id={request_obj.id}" + ) + + if created and request_obj.status != "draft": + # Помечаем заявление как ожидающее отправки уведомлений + # Уведомления будут отправлены после установки recipients через m2m_changed + logger.debug( + f"[SIGNAL] Помечаем заявление #{request_obj.id} для отправки " + f"уведомлений после установки recipients" + ) + _pending_new_requests.add(request_obj.id) + + if not created: + # Проверяем изменение статуса через сохраненный атрибут _old_status + if hasattr(request_obj, "_old_status"): + old_status = request_obj._old_status + new_status = request_obj.status + + if old_status != new_status: + logger.info( + f"[SIGNAL] Статус изменен для заявления #{request_obj.id}: " + f"{old_status} → {new_status}" + ) + # Отправляем уведомления напрямую - channels.py автоматически + # сделает асинхронную отправку через Celery + notify_status_change(request_obj, old_status, new_status) + + except Exception as e: + logger.exception(f"[SIGNAL ERROR] create_request_notifications: {e}") + + +@receiver(pre_save, sender=Request) +def track_status_change(sender, instance, **kwargs): + """ + Сохраняем старый статус перед обновлением для отслеживания изменений. + """ + if instance.pk: + try: + old_instance = Request.objects.get(pk=instance.pk) + instance._old_status = old_instance.status + except Request.DoesNotExist: + instance._old_status = None + + +@receiver(m2m_changed, sender=Request.recipients.through) +def notify_on_recipients_changed(sender, instance, action, **kwargs): + """ + Отправляет уведомления о новом заявлении ПОСЛЕ установки recipients. + + Срабатывает когда recipients устанавливаются через .set(), .add() и т.д. + """ + try: + # Проверяем что это завершение операции установки recipients + if action == "post_add" and instance.id in _pending_new_requests: + logger.info( + f"[M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, " + f"отправляем уведомления" + ) + _pending_new_requests.discard(instance.id) + + # Отправляем уведомления напрямую - channels.py автоматически + # сделает асинхронную отправку через Celery + notify_new_request(instance) + + except Exception as e: + logger.exception(f"[SIGNAL ERROR] notify_on_recipients_changed: {e}") + + +@receiver(m2m_changed, sender=Request.cc_users.through) +def notify_on_cc_users_changed(sender, instance, action, **kwargs): + """ + Дополнительная проверка для cc_users. + + Если recipients не были установлены, но установлены cc_users, + также отправляем уведомления. + """ + try: + # Если это завершение добавления cc_users И заявление все еще ожидает уведомлений + if action == "post_add" and instance.id in _pending_new_requests: + # Проверяем что recipients пусты (значит уведомления еще не отправлены) + if instance.recipients.count() == 0: + logger.info( + f"[M2M_SIGNAL] CC users установлены для заявления #{instance.id} " + f"(без recipients), отправляем уведомления" + ) + _pending_new_requests.discard(instance.id) + + # Отправляем уведомления напрямую - channels.py автоматически + # сделает асинхронную отправку через Celery + notify_new_request(instance) + + except Exception as e: + logger.exception(f"[SIGNAL ERROR] notify_on_cc_users_changed: {e}") + + +@receiver(post_save, sender=RequestComment) +def create_comment_notification(sender, instance, created, **kwargs): + """ + Создает уведомления при добавлении комментария к заявлению. + + Уведомляет: + - Автора заявления + - Всех получателей + - Всех в копии + - Согласующего + - Сотрудников отделов (если sent_to_all_department) + + notify.send() → channels.py автоматически отправит через Celery + """ + if not created: + return + + try: + logger.info( + f"[SIGNAL] Новый комментарий #{instance.id} к заявлению #{instance.request.id}" + ) + notify_comment(instance) + + except Exception as e: + logger.exception(f"[SIGNAL ERROR] create_comment_notification: {e}")