refactor(procurement): реструктуризация notifications в модульную архитектуру
- Создана папка backend/procurement/notifications/ вместо одного signals.py - Разделена логика на специализированные модули: * config.py - типы уведомлений (verbs) и шаблоны сообщений * handlers.py - бизнес-логика отправки уведомлений * signals.py - только Django сигналы * websocket.py - WebSocket broadcast для real-time обновлений * __init__.py - публичный API модуля Преимущества новой структуры: - ✅ Четкое разделение ответственности (SRP) - ✅ WebSocket логика отделена от уведомлений - ✅ Все шаблоны сообщений в одном месте (MessageTemplates) - ✅ Константы verbs централизованы (NotificationVerbs) - ✅ Легче тестировать и расширять - ✅ Обратная совместимость через __init__.py Изменения: - Удален: signals.py (сохранен как .old для истории) - Добавлен: notifications/ модуль с 5 файлами - Обновлен: apps.py (новый импорт сигналов)
This commit is contained in:
parent
3583aee95a
commit
16c5f2023a
@ -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
|
||||
|
||||
80
backend/procurement/notifications/__init__.py
Normal file
80
backend/procurement/notifications/__init__.py
Normal file
@ -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',
|
||||
]
|
||||
211
backend/procurement/notifications/config.py
Normal file
211
backend/procurement/notifications/config.py
Normal file
@ -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}'
|
||||
340
backend/procurement/notifications/handlers.py
Normal file
340
backend/procurement/notifications/handlers.py
Normal file
@ -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
|
||||
)
|
||||
166
backend/procurement/notifications/signals.py
Normal file
166
backend/procurement/notifications/signals.py
Normal file
@ -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
|
||||
89
backend/procurement/notifications/websocket.py
Normal file
89
backend/procurement/notifications/websocket.py
Normal file
@ -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')
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user