diff --git a/backend/procurement/notifications/__init__.py b/backend/procurement/notifications/__init__.py index 55064e6a..d6c4d135 100644 --- a/backend/procurement/notifications/__init__.py +++ b/backend/procurement/notifications/__init__.py @@ -5,15 +5,14 @@ - Создании новой заявки на закупку - Изменении статуса заявки - Создании/изменении согласования -- WebSocket broadcast для real-time обновлений Использует универсальную систему уведомлений (backend/notifications). +Все уведомления (включая WebSocket) отправляются через notify.send(). Структура: - config.py - конфигурация (типы уведомлений, шаблоны, URLs) - handlers.py - бизнес-логика отправки уведомлений - signals.py - Django сигналы для автоматической генерации -- websocket.py - WebSocket broadcast для real-time уведомлений Usage: # Сигналы подключаются автоматически через AppConfig.ready() @@ -21,9 +20,8 @@ Usage: 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') + # WebSocket broadcast происходит автоматически через: + # notify.send() → channels.py → Celery → WebSocketNotificationSender """ # Импорты для удобства и обратной совместимости @@ -41,11 +39,6 @@ from .handlers import ( 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 @@ -70,11 +63,6 @@ __all__ = [ '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/signals.py b/backend/procurement/notifications/signals.py index e210fc39..fdc4c176 100644 --- a/backend/procurement/notifications/signals.py +++ b/backend/procurement/notifications/signals.py @@ -5,6 +5,9 @@ Django сигналы для автоматической генерации у - Создание новой заявки на закупку - Изменение статуса заявки - Изменение статуса согласования + +Все уведомления отправляются через универсальную систему notifications: +noti fy.send() → channels.py → Celery → WebSocket/Email/Push """ import logging @@ -24,10 +27,6 @@ from .handlers import ( notify_stage_approved, notify_stage_rejected, ) -from .websocket import ( - broadcast_request_created, - broadcast_request_status_changed, -) logger = logging.getLogger(__name__) @@ -46,7 +45,7 @@ def create_request_notifications(sender, instance, created, **kwargs): if created: # Новая заявка создана - broadcast_request_created(request) + # WebSocket broadcast будет автоматически через notify.send() notify_new_request(request) return @@ -58,10 +57,8 @@ def create_request_notifications(sender, instance, created, **kwargs): if old_status == new_status: return - # Broadcast обновления статуса - broadcast_request_status_changed(request) - # Обработка изменения статуса + # WebSocket broadcast будет автоматически через notify.send() _handle_status_change(request, new_status) except Exception as e: diff --git a/backend/procurement/notifications/websocket.py b/backend/procurement/notifications/websocket.py deleted file mode 100644 index 0d960187..00000000 --- a/backend/procurement/notifications/websocket.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -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.old b/backend/procurement/signals.py.old new file mode 100644 index 00000000..040aef1e --- /dev/null +++ b/backend/procurement/signals.py.old @@ -0,0 +1,304 @@ +""" +Сигналы для модуля закупок. +Автоматическая отправка уведомлений при изменении статусов. +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