diff --git a/backend/procurement/apps.py b/backend/procurement/apps.py index 2be13deb..ce8c8a54 100644 --- a/backend/procurement/apps.py +++ b/backend/procurement/apps.py @@ -8,5 +8,6 @@ class ProcurementConfig(AppConfig): def ready(self): """Подключение сигналов при загрузке приложения.""" - import procurement.signals # noqa: F401 - import procurement.rules # django-rules: регистрация предикатов и правил доступа + import procurement.notifications.signals # noqa: F401 + # django-rules: регистрация предикатов и правил доступа + import procurement.rules # noqa: F401 diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py new file mode 100644 index 00000000..55064e6a --- /dev/null +++ b/backend/procurement/notifications/__init__.py @@ -0,0 +1,80 @@ +""" +Модуль уведомлений для приложения Procurement. + +Обеспечивает автоматическую отправку уведомлений при: +- Создании новой заявки на закупку +- Изменении статуса заявки +- Создании/изменении согласования +- WebSocket broadcast для real-time обновлений + +Использует универсальную систему уведомлений (backend/notifications). + +Структура: +- config.py - конфигурация (типы уведомлений, шаблоны, URLs) +- handlers.py - бизнес-логика отправки уведомлений +- signals.py - Django сигналы для автоматической генерации +- websocket.py - WebSocket broadcast для real-time уведомлений + +Usage: + # Сигналы подключаются автоматически через AppConfig.ready() + # Для ручной отправки: + from procurement.notifications import notify_new_request + notify_new_request(request_obj) + + # Для WebSocket broadcast: + from procurement.notifications import broadcast_request_update + broadcast_request_update(request_obj, 'request_updated') +""" + +# Импорты для удобства и обратной совместимости +from .config import NotificationVerbs, MessageTemplates, ActionURLs +from .handlers import ( + notify_new_request, + notify_approvers, + notify_approver, + notify_requestor, + notify_request_approved, + notify_request_rejected, + notify_request_completed, + notify_request_in_progress, + notify_request_cancelled, + notify_stage_approved, + notify_stage_rejected, +) +from .websocket import ( + broadcast_request_update, + broadcast_request_created, + broadcast_request_status_changed, +) + +# Сигналы импортируются для регистрации через AppConfig.ready() +from . import signals # noqa: F401 + + +__all__ = [ + # Config + 'NotificationVerbs', + 'MessageTemplates', + 'ActionURLs', + + # Handlers + 'notify_new_request', + 'notify_approvers', + 'notify_approver', + 'notify_requestor', + 'notify_request_approved', + 'notify_request_rejected', + 'notify_request_completed', + 'notify_request_in_progress', + 'notify_request_cancelled', + 'notify_stage_approved', + 'notify_stage_rejected', + + # WebSocket + 'broadcast_request_update', + 'broadcast_request_created', + 'broadcast_request_status_changed', + + # Signals module (для импорта в AppConfig) + 'signals', +] diff --git a/backend/procurement/notifications/config.py b/backend/procurement/notifications/config.py new file mode 100644 index 00000000..26b8792b --- /dev/null +++ b/backend/procurement/notifications/config.py @@ -0,0 +1,211 @@ +""" +Конфигурация уведомлений модуля Procurement. + +Определяет типы уведомлений (verbs), шаблоны сообщений и URLs. +""" + + +# ===== Типы уведомлений (Verbs) ===== + +class NotificationVerbs: + """Типы уведомлений для заявок на закупку.""" + + # Новая заявка + NEW_REQUEST = 'procurement_new_request' + + # Согласование + PENDING_APPROVAL = 'procurement_pending_approval' + STAGE_APPROVED = 'procurement_stage_approved' + + # Статусы + APPROVED = 'procurement_approved' + REJECTED = 'procurement_rejected' + IN_PROGRESS = 'procurement_in_progress' + COMPLETED = 'procurement_completed' + CANCELLED = 'procurement_cancelled' + + +# ===== Шаблоны сообщений ===== + +class MessageTemplates: + """Шаблоны сообщений для уведомлений.""" + + @staticmethod + def new_request(title: str, total_cost: float) -> tuple[str, str]: + """ + Шаблон для новой заявки. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Новая заявка на закупку' + description = f'Создана заявка "{title}" на сумму {total_cost}₽' + return notification_title, description + + @staticmethod + def pending_approval( + title: str, total_cost: float + ) -> tuple[str, str]: + """ + Шаблон для запроса на согласование. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Требуется согласование' + description = ( + f'Заявка "{title}" ожидает вашего согласования. ' + f'Сумма: {total_cost}₽' + ) + return notification_title, description + + @staticmethod + def stage_approved(approver_name: str, title: str) -> tuple[str, str]: + """ + Шаблон для одобрения этапа согласования. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Этап согласования пройден' + description = f'{approver_name} одобрил заявку "{title}".' + return notification_title, description + + @staticmethod + def approved(title: str) -> tuple[str, str]: + """ + Шаблон для полного одобрения заявки. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка одобрена' + description = ( + f'Ваша заявка "{title}" была одобрена. ' + f'Можно приступать к закупке.' + ) + return notification_title, description + + @staticmethod + def rejected(title: str, comment: str = None) -> tuple[str, str]: + """ + Шаблон для отклонения заявки. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка отклонена' + if comment: + description = ( + f'Ваша заявка "{title}" была отклонена. ' + f'Причина: {comment}' + ) + else: + description = ( + f'Ваша заявка "{title}" была отклонена. ' + f'Проверьте комментарии согласующих.' + ) + return notification_title, description + + @staticmethod + def rejected_by_approver( + approver_name: str, title: str, comment: str + ) -> tuple[str, str]: + """ + Шаблон для отклонения конкретным согласующим. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка отклонена' + description = ( + f'{approver_name} отклонил заявку "{title}". ' + f'Причина: {comment}' + ) + return notification_title, description + + @staticmethod + def in_progress( + title: str, executor_name: str + ) -> tuple[str, str]: + """ + Шаблон для взятия заявки в работу. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка взята в работу' + description = ( + f'Заявка "{title}" взята в работу ' + f'пользователем {executor_name}.' + ) + return notification_title, description + + @staticmethod + def in_progress_requestor( + title: str, executor_name: str + ) -> tuple[str, str]: + """ + Шаблон для создателя заявки о взятии в работу. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Ваша заявка взята в работу' + description = ( + f'Заявка "{title}" взята в работу ' + f'пользователем {executor_name}.' + ) + return notification_title, description + + @staticmethod + def completed(title: str) -> tuple[str, str]: + """ + Шаблон для завершения заявки. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка завершена' + description = f'Закупка по заявке "{title}" завершена.' + return notification_title, description + + @staticmethod + def completed_approver(title: str) -> tuple[str, str]: + """ + Шаблон для согласующих о завершении заявки. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка завершена' + description = f'Заявка "{title}" успешно завершена.' + return notification_title, description + + @staticmethod + def cancelled(title: str, reason: str = 'не указана') -> tuple[str, str]: + """ + Шаблон для отмены заявки. + + Returns: + tuple: (notification_title, description) + """ + notification_title = 'Заявка отменена' + description = ( + f'Заявка "{title}" была отменена автором. ' + f'Причина: {reason}' + ) + return notification_title, description + + +# ===== URL для действий ===== + +class ActionURLs: + """URL для переходов из уведомлений.""" + + PROCUREMENT_LIST = '/procurement' + + @staticmethod + def request_detail(request_id: int) -> str: + """URL детальной страницы заявки.""" + return f'/procurement/requests/{request_id}' diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py new file mode 100644 index 00000000..f87ee791 --- /dev/null +++ b/backend/procurement/notifications/handlers.py @@ -0,0 +1,340 @@ +""" +Обработчики (handlers) для отправки уведомлений о заявках на закупку. + +Содержит бизнес-логику формирования и отправки уведомлений +для различных событий в модуле Procurement. +""" + +import logging +from notifications.signals import notify + +from .config import NotificationVerbs, MessageTemplates, ActionURLs + +logger = logging.getLogger(__name__) + + +def notify_new_request(request): + """ + Уведомить о новой заявке на закупку. + + Отправляет уведомление руководителю отдела. + + Args: + request: Объект ProcurementRequest + """ + if not request.department.head: + logger.warning( + f"[Procurement] Нет руководителя для отдела {request.department}" + ) + return + + notification_title, description = MessageTemplates.new_request( + request.title, request.total_cost + ) + + notify.send( + sender=None, + recipient=request.department.head, + verb=NotificationVerbs.NEW_REQUEST, + action_object=request, + description=description, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': notification_title, + 'request_id': request.id, + 'total_cost': float(request.total_cost), + }, + ) + + logger.info( + f"[Procurement] Отправлено уведомление о новой заявке #{request.id} " + f"руководителю {request.department.head.username}" + ) + + +def notify_approvers(request): + """ + Уведомить всех согласующих о необходимости согласования заявки. + + Args: + request: Объект ProcurementRequest + """ + from ..constants import ApprovalStatus + + pending_approvals = request.approvals.filter( + status=ApprovalStatus.PENDING + ) + + if not pending_approvals.exists(): + logger.warning( + f"[Procurement] Нет ожидающих согласований " + f"для заявки #{request.id}" + ) + return + + for approval in pending_approvals: + notify_approver(approval) + + +def notify_approver(approval): + """ + Уведомить конкретного согласующего о необходимости согласования. + + Args: + approval: Объект Approval + """ + notification_title, description = MessageTemplates.pending_approval( + approval.request.title, approval.request.total_cost + ) + + notify.send( + sender=None, + recipient=approval.approver, + verb=NotificationVerbs.PENDING_APPROVAL, + action_object=approval.request, + description=description, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': notification_title, + 'request_id': approval.request.id, + 'approval_id': approval.id, + 'total_cost': float(approval.request.total_cost), + }, + ) + + logger.info( + f"[Procurement] Отправлено уведомление о согласовании " + f"заявки #{approval.request.id} для {approval.approver.username}" + ) + + +def notify_requestor(request, verb, title, message): + """ + Уведомить создателя заявки. + + Args: + request: Объект ProcurementRequest + verb: Тип уведомления (из NotificationVerbs) + title: Заголовок уведомления + message: Текст сообщения + """ + notify.send( + sender=None, + recipient=request.requestor, + verb=verb, + action_object=request, + description=message, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': title, + 'request_id': request.id, + }, + ) + + logger.info( + f"[Procurement] Отправлено уведомление '{verb}' " + f"создателю заявки #{request.id}" + ) + + +def notify_request_approved(request): + """ + Уведомить о полном одобрении заявки. + + Args: + request: Объект ProcurementRequest + """ + notification_title, description = MessageTemplates.approved(request.title) + notify_requestor( + request, + NotificationVerbs.APPROVED, + notification_title, + description + ) + + +def notify_request_rejected(request): + """ + Уведомить об отклонении заявки. + + Args: + request: Объект ProcurementRequest + """ + notification_title, description = MessageTemplates.rejected(request.title) + notify_requestor( + request, + NotificationVerbs.REJECTED, + notification_title, + description + ) + + +def notify_request_completed(request): + """ + Уведомить о завершении заявки. + + Отправляет уведомления: + - Создателю заявки + - Всем одобрившим согласующим + + Args: + request: Объект ProcurementRequest + """ + from ..constants import ApprovalStatus + + # Уведомляем создателя + notification_title, description = MessageTemplates.completed(request.title) + notify_requestor( + request, + NotificationVerbs.COMPLETED, + notification_title, + description + ) + + # Уведомляем всех одобривших согласующих + notification_title, description = MessageTemplates.completed_approver( + request.title + ) + + for approval in request.approvals.filter(status=ApprovalStatus.APPROVED): + notify.send( + sender=None, + recipient=approval.approver, + verb=NotificationVerbs.COMPLETED, + action_object=request, + description=description, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': notification_title, + 'request_id': request.id, + }, + ) + + +def notify_request_in_progress(request, executor): + """ + Уведомить о взятии заявки в работу. + + Отправляет уведомления: + - Создателю заявки (если он не сам взял в работу) + - Всем одобрившим согласующим + + Args: + request: Объект ProcurementRequest + executor: Пользователь, взявший заявку в работу + """ + from ..constants import ApprovalStatus + + executor_name = executor.get_full_name() if executor else 'Сотрудник' + + # Уведомляем создателя (если он не сам взял в работу) + if executor and request.requestor != executor: + notification_title, description = ( + MessageTemplates.in_progress_requestor( + request.title, executor_name + ) + ) + + notify.send( + sender=executor, + recipient=request.requestor, + verb=NotificationVerbs.IN_PROGRESS, + action_object=request, + description=description, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': notification_title, + 'request_id': request.id, + 'executor_id': executor.id, + }, + ) + + # Уведомляем всех одобривших согласующих + notification_title, description = MessageTemplates.in_progress( + request.title, executor_name + ) + + for approval in request.approvals.filter(status=ApprovalStatus.APPROVED): + notify.send( + sender=executor, + recipient=approval.approver, + verb=NotificationVerbs.IN_PROGRESS, + action_object=request, + description=description, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': notification_title, + 'request_id': request.id, + 'executor_id': executor.id if executor else None, + }, + ) + + +def notify_request_cancelled(request): + """ + Уведомить об отмене заявки. + + Отправляет уведомления всем причастным согласующим. + + Args: + request: Объект ProcurementRequest + """ + reason = getattr(request, 'cancellation_reason', 'не указана') + notification_title, description = MessageTemplates.cancelled( + request.title, reason + ) + + for approval in request.approvals.all(): + notify.send( + sender=None, + recipient=approval.approver, + verb=NotificationVerbs.CANCELLED, + action_object=request, + description=description, + action_url=ActionURLs.PROCUREMENT_LIST, + data={ + 'title': notification_title, + 'request_id': request.id, + 'reason': reason, + }, + ) + + +def notify_stage_approved(approval): + """ + Уведомить о прохождении этапа согласования. + + Args: + approval: Объект Approval + """ + approver_name = approval.approver.get_full_name() + notification_title, description = MessageTemplates.stage_approved( + approver_name, approval.request.title + ) + + notify_requestor( + approval.request, + NotificationVerbs.STAGE_APPROVED, + notification_title, + description + ) + + +def notify_stage_rejected(approval): + """ + Уведомить об отклонении на этапе согласования. + + Args: + approval: Объект Approval + """ + approver_name = approval.approver.get_full_name() + notification_title, description = MessageTemplates.rejected_by_approver( + approver_name, approval.request.title, approval.comment + ) + + notify_requestor( + approval.request, + NotificationVerbs.REJECTED, + notification_title, + description + ) diff --git a/backend/procurement/notifications/signals.py b/backend/procurement/notifications/signals.py new file mode 100644 index 00000000..e210fc39 --- /dev/null +++ b/backend/procurement/notifications/signals.py @@ -0,0 +1,166 @@ +""" +Django сигналы для автоматической генерации уведомлений в модуле Procurement. + +Обрабатывает события: +- Создание новой заявки на закупку +- Изменение статуса заявки +- Изменение статуса согласования +""" + +import logging +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +from ..constants import ApprovalStatus, ProcurementStatus +from ..models import Approval, ProcurementRequest +from .handlers import ( + notify_new_request, + notify_approvers, + notify_request_approved, + notify_request_rejected, + notify_request_completed, + notify_request_in_progress, + notify_request_cancelled, + notify_stage_approved, + notify_stage_rejected, +) +from .websocket import ( + broadcast_request_created, + broadcast_request_status_changed, +) + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=ProcurementRequest) +def create_request_notifications(sender, instance, created, **kwargs): + """ + Создает уведомления при создании или изменении заявки. + + Обрабатывает: + 1. Новая заявка - уведомление руководителю отдела + 2. Изменение статуса - уведомления соответствующим получателям + """ + try: + request = instance + + if created: + # Новая заявка создана + broadcast_request_created(request) + notify_new_request(request) + return + + # Проверяем изменение статуса + if hasattr(request, '_original_status'): + old_status = request._original_status + new_status = request.status + + if old_status == new_status: + return + + # Broadcast обновления статуса + broadcast_request_status_changed(request) + + # Обработка изменения статуса + _handle_status_change(request, new_status) + + except Exception as e: + logger.exception(f"[SIGNAL ERROR] create_request_notifications: {e}") + + +def _handle_status_change(request, new_status): + """ + Обработать изменение статуса заявки. + + Args: + request: Объект ProcurementRequest + new_status: Новый статус + """ + if new_status == ProcurementStatus.PENDING: + # Заявка отправлена на согласование + notify_approvers(request) + + elif new_status == ProcurementStatus.APPROVED: + # Заявка одобрена + notify_request_approved(request) + + elif new_status == ProcurementStatus.REJECTED: + # Заявка отклонена + notify_request_rejected(request) + + elif new_status == ProcurementStatus.COMPLETED: + # Заявка завершена + notify_request_completed(request) + + elif new_status == ProcurementStatus.IN_PROGRESS: + # Заявка взята в работу + executor = request.executor + notify_request_in_progress(request, executor) + + elif new_status == ProcurementStatus.CANCELLED: + # Заявка отменена + notify_request_cancelled(request) + + +@receiver(post_save, sender=Approval) +def create_approval_notifications(sender, instance, created, **kwargs): + """ + Создает уведомления при создании или изменении согласования. + + Обрабатывает: + 1. Новое согласование - уведомление согласующему + 2. Одобрение - уведомление создателю заявки + 3. Отклонение - уведомление создателю заявки + """ + try: + approval = instance + + if created: + # Новое согласование создано + from .handlers import notify_approver + notify_approver(approval) + return + + # Проверяем изменение статуса согласования + if hasattr(approval, '_original_approval_status'): + old_status = approval._original_approval_status + new_status = approval.status + + if old_status == new_status: + return + + # Обработка изменения статуса согласования + if new_status == ApprovalStatus.APPROVED: + notify_stage_approved(approval) + + elif new_status == ApprovalStatus.REJECTED: + notify_stage_rejected(approval) + + except Exception as e: + logger.exception(f"[SIGNAL ERROR] create_approval_notifications: {e}") + + +@receiver(pre_save, sender=ProcurementRequest) +def track_request_status_change(sender, instance, **kwargs): + """ + Сохранить оригинальный статус для отслеживания изменений. + """ + if instance.pk: + try: + original = ProcurementRequest.objects.get(pk=instance.pk) + instance._original_status = original.status + except ProcurementRequest.DoesNotExist: + pass + + +@receiver(pre_save, sender=Approval) +def track_approval_status_change(sender, instance, **kwargs): + """ + Сохранить оригинальный статус согласования для отслеживания изменений. + """ + if instance.pk: + try: + original = Approval.objects.get(pk=instance.pk) + instance._original_approval_status = original.status + except Approval.DoesNotExist: + pass diff --git a/backend/procurement/notifications/websocket.py b/backend/procurement/notifications/websocket.py new file mode 100644 index 00000000..0d960187 --- /dev/null +++ b/backend/procurement/notifications/websocket.py @@ -0,0 +1,89 @@ +""" +WebSocket broadcast для real-time уведомлений в модуле Procurement. + +Отправляет события через Django Channels о изменениях заявок на закупку. +""" + +import logging +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +logger = logging.getLogger(__name__) + + +def broadcast_request_update(request, event_type='request_updated'): + """ + Отправить WebSocket событие об обновлении заявки. + + Отправляет уведомления: + - Всем пользователям отдела + - Создателю заявки + + Args: + request: Объект ProcurementRequest + event_type: Тип события (request_created, request_status_changed, etc.) + """ + try: + channel_layer = get_channel_layer() + if not channel_layer: + logger.debug("[Procurement WS] Channel layer не настроен") + return + + # Данные заявки для broadcast + data = { + 'id': request.id, + 'title': request.title, + 'status': request.status, + 'status_display': request.get_status_display(), + 'department_id': request.department_id, + 'requestor_id': request.requestor_id, + } + + # Отправляем всем пользователям отдела + group_name = f'procurement_dept_{request.department_id}' + async_to_sync(channel_layer.group_send)( + group_name, + { + 'type': 'procurement_update', + 'event': event_type, + 'data': data, + } + ) + + # Отправляем создателю заявки + async_to_sync(channel_layer.group_send)( + f'user_{request.requestor_id}', + { + 'type': 'procurement_update', + 'event': event_type, + 'data': data, + } + ) + + logger.debug( + f"[Procurement WS] Broadcast {event_type} " + f"для заявки #{request.id}" + ) + + except Exception as e: + logger.warning(f"[Procurement WS] Broadcast failed: {e}") + + +def broadcast_request_created(request): + """ + Broadcast о создании новой заявки. + + Args: + request: Объект ProcurementRequest + """ + broadcast_request_update(request, 'request_created') + + +def broadcast_request_status_changed(request): + """ + Broadcast об изменении статуса заявки. + + Args: + request: Объект ProcurementRequest + """ + broadcast_request_update(request, 'request_status_changed') diff --git a/backend/procurement/signals.py b/backend/procurement/signals.py deleted file mode 100644 index 040aef1e..00000000 --- a/backend/procurement/signals.py +++ /dev/null @@ -1,304 +0,0 @@ -""" -Сигналы для модуля закупок. -Автоматическая отправка уведомлений при изменении статусов. -WebSocket события для real-time обновлений. -""" - -import logging - -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer -from django.db.models.signals import post_save, pre_save -from django.dispatch import receiver - -from notifications.signals import notify - -from .constants import ApprovalStatus, ProcurementStatus -from .models import Approval, ProcurementRequest - -logger = logging.getLogger(__name__) - - -def broadcast_request_update(request, event_type='request_updated'): - """Отправить WebSocket событие об обновлении заявки.""" - try: - channel_layer = get_channel_layer() - if not channel_layer: - return - - # Данные заявки для broadcast - data = { - 'id': request.id, - 'title': request.title, - 'status': request.status, - 'status_display': request.get_status_display(), - 'department_id': request.department_id, - 'requestor_id': request.requestor_id, - } - - # Отправляем всем пользователям отдела - group_name = f'procurement_dept_{request.department_id}' - async_to_sync(channel_layer.group_send)( - group_name, - { - 'type': 'procurement_update', - 'event': event_type, - 'data': data, - } - ) - - # Отправляем создателю заявки - async_to_sync(channel_layer.group_send)( - f'user_{request.requestor_id}', - { - 'type': 'procurement_update', - 'event': event_type, - 'data': data, - } - ) - - logger.debug( - f"[Procurement WS] Broadcast {event_type} for request {request.id}" - ) - except Exception as e: - logger.warning(f"[Procurement WS] Broadcast failed: {e}") - - -@receiver(post_save, sender=ProcurementRequest) -def notify_on_request_status_change(sender, instance, created, **kwargs): - """Отправить уведомление при изменении статуса заявки.""" - if created: - # Новая заявка создана - broadcast - broadcast_request_update(instance, 'request_created') - - if instance.department.head: - notify.send( - sender=None, - recipient=instance.department.head, - verb='procurement_new_request', - action_object=instance, - description=( - f'Создана заявка "{instance.title}" ' - f'на сумму {instance.total_cost}₽' - ), - action_url='/procurement', - data={'title': 'Новая заявка на закупку'}, - ) - return - - # Проверяем изменение статуса - if hasattr(instance, '_original_status'): - old_status = instance._original_status - new_status = instance.status - - if old_status == new_status: - return - - # Broadcast обновления статуса - broadcast_request_update(instance, 'request_status_changed') - - # Статус изменился - if new_status == ProcurementStatus.PENDING: - # Заявка отправлена на согласование - notify_approvers(instance) - - elif new_status == ProcurementStatus.APPROVED: - # Заявка одобрена - notify_requestor( - instance, - 'procurement_approved', - 'Заявка одобрена', - f'Ваша заявка "{instance.title}" была одобрена. ' - f'Можно приступать к закупке.' - ) - - elif new_status == ProcurementStatus.REJECTED: - # Заявка отклонена - notify_requestor( - instance, - 'procurement_rejected', - 'Заявка отклонена', - f'Ваша заявка "{instance.title}" была отклонена. ' - f'Проверьте комментарии согласующих.' - ) - - elif new_status == ProcurementStatus.COMPLETED: - # Уведомляем создателя заявки - notify_requestor( - instance, - 'procurement_completed', - 'Заявка завершена', - f'Закупка по заявке "{instance.title}" завершена.' - ) - # Уведомляем всех одобривших согласующих - for approval in instance.approvals.filter(status=ApprovalStatus.APPROVED): - notify.send( - sender=None, - recipient=approval.approver, - verb='procurement_completed', - action_object=instance, - description=f'Заявка "{instance.title}" успешно завершена.', - action_url='/procurement', - data={'title': 'Заявка завершена'}, - ) - - elif new_status == ProcurementStatus.IN_PROGRESS: - # Заявка взята в работу - executor = instance.executor - executor_name = executor.get_full_name() if executor else 'Сотрудник' - - # Уведомляем создателя заявки (если он не сам взял в работу) - if executor and instance.requestor != executor: - notify.send( - sender=executor, - recipient=instance.requestor, - verb='procurement_in_progress', - action_object=instance, - description=( - f'Заявка "{instance.title}" взята в работу ' - f'пользователем {executor_name}.' - ), - action_url='/procurement', - data={'title': 'Ваша заявка взята в работу'}, - ) - - # Уведомляем всех одобривших согласующих - for approval in instance.approvals.filter(status=ApprovalStatus.APPROVED): - notify.send( - sender=executor, - recipient=approval.approver, - verb='procurement_in_progress', - action_object=instance, - description=( - f'Заявка "{instance.title}" взята в работу ' - f'пользователем {executor_name}.' - ), - action_url='/procurement', - data={'title': 'Заявка взята в работу'}, - ) - - elif new_status == ProcurementStatus.CANCELLED: - # Заявка отменена - уведомляем всех причастных согласующих - reason = getattr(instance, 'cancellation_reason', 'не указана') - for approval in instance.approvals.all(): - notify.send( - sender=None, - recipient=approval.approver, - verb='procurement_cancelled', - action_object=instance, - description=( - f'Заявка "{instance.title}" была отменена автором. ' - f'Причина: {reason}' - ), - action_url='/procurement', - data={'title': 'Заявка отменена'}, - ) - - -@receiver(post_save, sender=Approval) -def notify_on_approval_change(sender, instance, created, **kwargs): - """Отправить уведомление при изменении согласования.""" - if created: - # Новое согласование создано - notify_approver(instance) - return - - # Проверяем изменение статуса согласования - if hasattr(instance, '_original_approval_status'): - old_status = instance._original_approval_status - new_status = instance.status - - if old_status == new_status: - return - - # Статус согласования изменился - if new_status == ApprovalStatus.APPROVED: - notify_requestor( - instance.request, - 'procurement_stage_approved', - 'Этап согласования пройден', - f'{instance.approver.get_full_name()} одобрил ' - f'заявку "{instance.request.title}".' - ) - - elif new_status == ApprovalStatus.REJECTED: - notify_requestor( - instance.request, - 'procurement_rejected', - 'Заявка отклонена', - f'{instance.approver.get_full_name()} отклонил ' - f'заявку "{instance.request.title}". ' - f'Причина: {instance.comment}' - ) - - -def notify_approvers(request): - """Уведомить всех согласующих о новой заявке.""" - for approval in request.approvals.filter( - status=ApprovalStatus.PENDING - ): - notify_approver(approval) - - -def notify_approver(approval): - """Уведомить согласующего о необходимости согласования.""" - notify.send( - sender=None, - recipient=approval.approver, - verb='procurement_pending_approval', - action_object=approval.request, - description=( - f'Заявка "{approval.request.title}" ' - f'ожидает вашего согласования. ' - f'Сумма: {approval.request.total_cost}₽' - ), - action_url='/procurement', - data={'title': 'Требуется согласование'}, - ) - - -def notify_requestor(request, verb, title, message): - """Уведомить создателя заявки.""" - notify.send( - sender=None, - recipient=request.requestor, - verb=verb, - action_object=request, - description=message, - action_url='/procurement', - data={'title': title}, - ) - - -# Сохраняем исходный статус перед сохранением -@receiver(pre_save, sender=ProcurementRequest) -def save_original_status(sender, instance, **kwargs): - """Сохранить оригинальный статус для отслеживания изменений.""" - if instance.pk: - try: - original = ProcurementRequest.objects.get(pk=instance.pk) - instance._original_status = original.status - except ProcurementRequest.DoesNotExist: - pass - - -@receiver(pre_save, sender=Approval) -def save_original_approval_status(sender, instance, **kwargs): - """Сохранить оригинальный статус согласования для отслеживания изменений.""" - if instance.pk: - try: - original = Approval.objects.get(pk=instance.pk) - instance._original_approval_status = original.status - except Approval.DoesNotExist: - pass - - -@receiver(pre_save, sender=Approval) -def save_original_approval_status(sender, instance, **kwargs): - """Сохранить оригинальный статус согласования.""" - if instance.pk: - try: - original = Approval.objects.get(pk=instance.pk) - instance._original_approval_status = original.status - except Approval.DoesNotExist: - pass