procurement: add workflow notifications
This commit is contained in:
parent
a9d9b6ae3d
commit
b40f32e849
@ -356,13 +356,19 @@ class ProcurementRequestCreateSerializer(serializers.ModelSerializer):
|
||||
"""Создать заявку с позициями."""
|
||||
items_data = validated_data.pop("items", [])
|
||||
request = self.context["request"]
|
||||
has_processing_department = bool(
|
||||
validated_data.get("processing_department")
|
||||
)
|
||||
|
||||
if validated_data.get("processing_department"):
|
||||
if has_processing_department:
|
||||
validated_data["status"] = ProcurementStatus.WAITING
|
||||
|
||||
procurement_request = ProcurementRequest.objects.create(
|
||||
requestor=request.user, **validated_data
|
||||
procurement_request = ProcurementRequest(
|
||||
requestor=request.user,
|
||||
**validated_data,
|
||||
)
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.save()
|
||||
|
||||
# Создаем позиции (если они переданы)
|
||||
for item_data in items_data:
|
||||
|
||||
@ -26,6 +26,11 @@ from procurement.models import (
|
||||
ProcurementRequest,
|
||||
Supplier,
|
||||
)
|
||||
from procurement.notifications.handlers import (
|
||||
notify_item_comment,
|
||||
notify_item_updated,
|
||||
notify_request_comment,
|
||||
)
|
||||
from communications import comments_helpers
|
||||
from communications.models import Message
|
||||
from procurement.services import ProcurementApprovalResolver
|
||||
@ -258,6 +263,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
author=request.user,
|
||||
content=text,
|
||||
)
|
||||
notify_request_comment(
|
||||
procurement_request,
|
||||
message,
|
||||
actor=request.user,
|
||||
)
|
||||
author_ser = EmployeeBriefSerializer(message.author)
|
||||
return Response(
|
||||
{
|
||||
@ -417,6 +427,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
procurement_request.status = ProcurementStatus.PENDING
|
||||
procurement_request.submitted_at = timezone.now()
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.save(
|
||||
update_fields=["status", "submitted_at", "updated_at"]
|
||||
)
|
||||
@ -471,6 +482,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
# Сигнал post_save(ProcurementRequest) отправит уведомление
|
||||
# requestor'у
|
||||
procurement_request.status = ProcurementStatus.APPROVED
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.save()
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
@ -506,6 +518,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
# Меняем статус заявки; сигнал post_save(ProcurementRequest) уведомит
|
||||
# requestor'а
|
||||
procurement_request.status = ProcurementStatus.REJECTED
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request._requestor_rejected_notified = True
|
||||
procurement_request.save()
|
||||
|
||||
procurement_request.refresh_from_db()
|
||||
@ -647,6 +661,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
procurement_request.executor = request.user
|
||||
procurement_request.started_at = timezone.now()
|
||||
procurement_request.status = ProcurementStatus.IN_PROGRESS
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.save()
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
@ -674,9 +689,9 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
# Меняем статус
|
||||
procurement_request.status = ProcurementStatus.COMPLETED
|
||||
procurement_request.completed_at = timezone.now()
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.save()
|
||||
# Сигнал post_save(ProcurementRequest) с COMPLETED отправит уведомления
|
||||
# requestor'у и согласующим
|
||||
# Сигнал post_save(ProcurementRequest) с COMPLETED уведомит автора
|
||||
|
||||
serializer = self.get_serializer(procurement_request)
|
||||
return Response(serializer.data)
|
||||
@ -701,9 +716,23 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
items_to_update = list(
|
||||
procurement_request.items.exclude(
|
||||
execution_status=ProcurementItemExecutionStatus.RECEIVED,
|
||||
)
|
||||
)
|
||||
|
||||
procurement_request.items.update(
|
||||
execution_status=ProcurementItemExecutionStatus.RECEIVED,
|
||||
)
|
||||
for item in items_to_update:
|
||||
item.execution_status = ProcurementItemExecutionStatus.RECEIVED
|
||||
notify_item_updated(
|
||||
item,
|
||||
actor=request.user,
|
||||
changed_fields=["execution_status"],
|
||||
)
|
||||
procurement_request._notification_actor = request.user
|
||||
procurement_request.recalculate_fulfillment_status(save=True)
|
||||
procurement_request.refresh_from_db()
|
||||
|
||||
@ -736,6 +765,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
# Сохраняем причину на экземпляре, чтобы сигнал мог её использовать
|
||||
procurement_request.cancellation_reason = reason or "не указана"
|
||||
procurement_request._notification_actor = request.user
|
||||
|
||||
# Меняем статус; сигнал post_save(ProcurementRequest) с CANCELLED
|
||||
# уведомит согласующих с указанием причины
|
||||
@ -749,6 +779,18 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet):
|
||||
class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet для позиций заявок."""
|
||||
|
||||
NOTIFICATION_TRACKED_FIELDS = [
|
||||
"name",
|
||||
"quantity",
|
||||
"unit",
|
||||
"estimated_unit_price",
|
||||
"links",
|
||||
"expected_delivery_date",
|
||||
"actual_unit_price",
|
||||
"execution_status",
|
||||
"executor_comment",
|
||||
]
|
||||
|
||||
queryset = ProcurementItem.objects.select_related(
|
||||
"request",
|
||||
"request__department",
|
||||
@ -944,7 +986,28 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
"""Проверяем права при обновлении позиции."""
|
||||
item = self.get_object()
|
||||
self._check_item_edit_permission(item)
|
||||
serializer.save()
|
||||
procurement_request = item.request
|
||||
before = {
|
||||
field: getattr(item, field)
|
||||
for field in self.NOTIFICATION_TRACKED_FIELDS
|
||||
}
|
||||
procurement_request._notification_actor = self.request.user
|
||||
updated_item = serializer.save()
|
||||
after = {
|
||||
field: getattr(updated_item, field)
|
||||
for field in self.NOTIFICATION_TRACKED_FIELDS
|
||||
}
|
||||
changed_fields = [
|
||||
field
|
||||
for field in self.NOTIFICATION_TRACKED_FIELDS
|
||||
if before[field] != after[field]
|
||||
]
|
||||
if changed_fields:
|
||||
notify_item_updated(
|
||||
updated_item,
|
||||
actor=self.request.user,
|
||||
changed_fields=changed_fields,
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Проверяем права при удалении позиции."""
|
||||
@ -994,6 +1057,11 @@ class ProcurementItemViewSet(viewsets.ModelViewSet):
|
||||
author=request.user,
|
||||
content=text,
|
||||
)
|
||||
notify_item_comment(
|
||||
item,
|
||||
message,
|
||||
actor=request.user,
|
||||
)
|
||||
author_ser = EmployeeBriefSerializer(message.author)
|
||||
return Response(
|
||||
{
|
||||
|
||||
@ -17,8 +17,8 @@
|
||||
Usage:
|
||||
# Сигналы подключаются автоматически через AppConfig.ready()
|
||||
# Для ручной отправки:
|
||||
from procurement.notifications import notify_new_request
|
||||
notify_new_request(request_obj)
|
||||
from procurement.notifications import notify_request_comment
|
||||
notify_request_comment(request_obj, message_obj, actor=user)
|
||||
|
||||
# WebSocket broadcast происходит автоматически через:
|
||||
# notify.send() → channels.py → Celery → WebSocketNotificationSender
|
||||
@ -30,6 +30,7 @@ from .handlers import (
|
||||
notify_new_request,
|
||||
notify_approvers,
|
||||
notify_approver,
|
||||
notify_processing_department_request,
|
||||
notify_requestor,
|
||||
notify_request_approved,
|
||||
notify_request_rejected,
|
||||
@ -38,6 +39,9 @@ from .handlers import (
|
||||
notify_request_cancelled,
|
||||
notify_stage_approved,
|
||||
notify_stage_rejected,
|
||||
notify_item_updated,
|
||||
notify_request_comment,
|
||||
notify_item_comment,
|
||||
)
|
||||
|
||||
# Сигналы импортируются для регистрации через AppConfig.ready()
|
||||
@ -54,6 +58,7 @@ __all__ = [
|
||||
'notify_new_request',
|
||||
'notify_approvers',
|
||||
'notify_approver',
|
||||
'notify_processing_department_request',
|
||||
'notify_requestor',
|
||||
'notify_request_approved',
|
||||
'notify_request_rejected',
|
||||
@ -62,6 +67,9 @@ __all__ = [
|
||||
'notify_request_cancelled',
|
||||
'notify_stage_approved',
|
||||
'notify_stage_rejected',
|
||||
'notify_item_updated',
|
||||
'notify_request_comment',
|
||||
'notify_item_comment',
|
||||
|
||||
# Signals module (для импорта в AppConfig)
|
||||
'signals',
|
||||
|
||||
@ -12,6 +12,7 @@ class NotificationVerbs:
|
||||
|
||||
# Новая заявка
|
||||
NEW_REQUEST = 'procurement_new_request'
|
||||
DEPARTMENT_REQUEST = 'procurement_department_request'
|
||||
|
||||
# Согласование
|
||||
PENDING_APPROVAL = 'procurement_pending_approval'
|
||||
@ -24,6 +25,11 @@ class NotificationVerbs:
|
||||
COMPLETED = 'procurement_completed'
|
||||
CANCELLED = 'procurement_cancelled'
|
||||
|
||||
# Позиции и комментарии
|
||||
ITEM_UPDATED = 'procurement_item_updated'
|
||||
REQUEST_COMMENTED = 'procurement_request_commented'
|
||||
ITEM_COMMENTED = 'procurement_item_commented'
|
||||
|
||||
|
||||
# ===== Шаблоны сообщений =====
|
||||
|
||||
@ -42,6 +48,17 @@ class MessageTemplates:
|
||||
description = f'Создана заявка "{title}" на сумму {total_cost}₽'
|
||||
return notification_title, description
|
||||
|
||||
@staticmethod
|
||||
def department_request(
|
||||
title: str, department_name: str
|
||||
) -> tuple[str, str]:
|
||||
notification_title = 'Новая заявка для отдела'
|
||||
description = (
|
||||
f'Заявка "{title}" направлена в отдел '
|
||||
f'{department_name}.'
|
||||
)
|
||||
return notification_title, description
|
||||
|
||||
@staticmethod
|
||||
def pending_approval(
|
||||
title: str, total_cost: float
|
||||
@ -197,6 +214,38 @@ class MessageTemplates:
|
||||
)
|
||||
return notification_title, description
|
||||
|
||||
@staticmethod
|
||||
def item_updated(
|
||||
title: str, item_name: str, actor_name: str
|
||||
) -> tuple[str, str]:
|
||||
notification_title = 'Позиция закупки изменена'
|
||||
description = (
|
||||
f'{actor_name} изменил позицию "{item_name}" '
|
||||
f'в заявке "{title}".'
|
||||
)
|
||||
return notification_title, description
|
||||
|
||||
@staticmethod
|
||||
def request_commented(
|
||||
title: str, actor_name: str
|
||||
) -> tuple[str, str]:
|
||||
notification_title = 'Новый комментарий к закупке'
|
||||
description = (
|
||||
f'{actor_name} оставил комментарий к заявке "{title}".'
|
||||
)
|
||||
return notification_title, description
|
||||
|
||||
@staticmethod
|
||||
def item_commented(
|
||||
title: str, item_name: str, actor_name: str
|
||||
) -> tuple[str, str]:
|
||||
notification_title = 'Новый комментарий к позиции'
|
||||
description = (
|
||||
f'{actor_name} оставил комментарий к позиции "{item_name}" '
|
||||
f'в заявке "{title}".'
|
||||
)
|
||||
return notification_title, description
|
||||
|
||||
|
||||
# ===== URL для действий =====
|
||||
|
||||
|
||||
@ -13,6 +13,114 @@ from .config import NotificationVerbs, MessageTemplates, ActionURLs
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _actor_id(actor):
|
||||
return getattr(actor, "id", None)
|
||||
|
||||
|
||||
def _actor_name(actor):
|
||||
if not actor:
|
||||
return "Система"
|
||||
return actor.get_full_name() or getattr(actor, "username", "") or str(actor)
|
||||
|
||||
|
||||
def _request_actor(request):
|
||||
return getattr(request, "_notification_actor", None)
|
||||
|
||||
|
||||
def _send_procurement_notification(
|
||||
*,
|
||||
recipient,
|
||||
verb,
|
||||
procurement_request,
|
||||
title,
|
||||
description,
|
||||
actor=None,
|
||||
action_object=None,
|
||||
extra_data=None,
|
||||
):
|
||||
"""Отправить procurement-уведомление одному получателю."""
|
||||
if not recipient:
|
||||
return
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"request_id": procurement_request.id,
|
||||
}
|
||||
if actor:
|
||||
data["actor_id"] = actor.id
|
||||
if extra_data:
|
||||
data.update(extra_data)
|
||||
|
||||
notify.send(
|
||||
sender=actor,
|
||||
recipient=recipient,
|
||||
verb=verb,
|
||||
action_object=action_object or procurement_request,
|
||||
target=procurement_request,
|
||||
description=description,
|
||||
action_url=ActionURLs.request_detail(procurement_request.id),
|
||||
data=data,
|
||||
)
|
||||
|
||||
|
||||
def _notify_many(
|
||||
recipients,
|
||||
*,
|
||||
verb,
|
||||
procurement_request,
|
||||
title,
|
||||
description,
|
||||
actor=None,
|
||||
action_object=None,
|
||||
extra_data=None,
|
||||
):
|
||||
"""Отправить уведомление списку получателей с дедупликацией."""
|
||||
seen = set()
|
||||
actor_id = _actor_id(actor)
|
||||
for recipient in recipients:
|
||||
if not recipient or recipient.id in seen:
|
||||
continue
|
||||
seen.add(recipient.id)
|
||||
if actor_id and recipient.id == actor_id:
|
||||
continue
|
||||
_send_procurement_notification(
|
||||
recipient=recipient,
|
||||
verb=verb,
|
||||
procurement_request=procurement_request,
|
||||
title=title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
action_object=action_object,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
|
||||
def _processing_department_recipients(procurement_request):
|
||||
"""Активные участники отдела-исполнителя, включая роли и руководителя."""
|
||||
if not procurement_request.processing_department_id:
|
||||
return []
|
||||
|
||||
from employees.models import Employee, EmployeeDepartment, RoleAssignment
|
||||
|
||||
department = procurement_request.processing_department
|
||||
recipient_ids = set(
|
||||
EmployeeDepartment.objects.filter(
|
||||
department_id=department.id,
|
||||
is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
recipient_ids.update(
|
||||
RoleAssignment.objects.filter(
|
||||
role__department_id=department.id,
|
||||
is_active=True,
|
||||
).values_list("employee_id", flat=True)
|
||||
)
|
||||
if department.head_id:
|
||||
recipient_ids.add(department.head_id)
|
||||
|
||||
return list(Employee.objects.filter(id__in=recipient_ids, is_active=True))
|
||||
|
||||
|
||||
def get_current_pending_approvals(request):
|
||||
"""Вернуть pending approvals только текущего этапа."""
|
||||
from ..constants import ApprovalStatus
|
||||
@ -28,9 +136,9 @@ def get_current_pending_approvals(request):
|
||||
|
||||
def notify_new_request(request):
|
||||
"""
|
||||
Уведомить о новой заявке на закупку.
|
||||
Legacy-helper для старого сценария уведомления руководителя отдела.
|
||||
|
||||
Отправляет уведомление руководителю отдела.
|
||||
Новая логика адресных заявок использует notify_processing_department_request().
|
||||
|
||||
Args:
|
||||
request: Объект ProcurementRequest
|
||||
@ -65,6 +173,36 @@ def notify_new_request(request):
|
||||
)
|
||||
|
||||
|
||||
def notify_processing_department_request(request, actor=None):
|
||||
"""Уведомить отдел-исполнитель о новой адресной заявке."""
|
||||
if not request.processing_department_id:
|
||||
return
|
||||
|
||||
department_name = request.processing_department.name
|
||||
notification_title, description = MessageTemplates.department_request(
|
||||
request.title,
|
||||
department_name,
|
||||
)
|
||||
recipients = _processing_department_recipients(request)
|
||||
_notify_many(
|
||||
recipients,
|
||||
verb=NotificationVerbs.DEPARTMENT_REQUEST,
|
||||
procurement_request=request,
|
||||
title=notification_title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"processing_department_id": request.processing_department_id,
|
||||
"total_cost": float(request.total_cost),
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[Procurement] Отправлено уведомление о заявке #{request.id} "
|
||||
f"в отдел {department_name}"
|
||||
)
|
||||
|
||||
|
||||
def notify_approvers(request):
|
||||
"""
|
||||
Уведомить согласующих только текущего этапа.
|
||||
@ -96,18 +234,15 @@ def notify_approver(approval):
|
||||
approval.request.title, approval.request.total_cost
|
||||
)
|
||||
|
||||
notify.send(
|
||||
sender=None,
|
||||
_send_procurement_notification(
|
||||
recipient=approval.approver,
|
||||
verb=NotificationVerbs.PENDING_APPROVAL,
|
||||
action_object=approval.request,
|
||||
procurement_request=approval.request,
|
||||
title=notification_title,
|
||||
description=description,
|
||||
action_url=ActionURLs.request_detail(approval.request.id),
|
||||
data={
|
||||
'title': notification_title,
|
||||
'request_id': approval.request.id,
|
||||
'approval_id': approval.id,
|
||||
'total_cost': float(approval.request.total_cost),
|
||||
extra_data={
|
||||
"approval_id": approval.id,
|
||||
"total_cost": float(approval.request.total_cost),
|
||||
},
|
||||
)
|
||||
|
||||
@ -117,7 +252,16 @@ def notify_approver(approval):
|
||||
)
|
||||
|
||||
|
||||
def notify_requestor(request, verb, title, message):
|
||||
def notify_requestor(
|
||||
request,
|
||||
verb,
|
||||
title,
|
||||
message,
|
||||
*,
|
||||
actor=None,
|
||||
action_object=None,
|
||||
extra_data=None,
|
||||
):
|
||||
"""
|
||||
Уведомить создателя заявки.
|
||||
|
||||
@ -127,17 +271,19 @@ def notify_requestor(request, verb, title, message):
|
||||
title: Заголовок уведомления
|
||||
message: Текст сообщения
|
||||
"""
|
||||
notify.send(
|
||||
sender=None,
|
||||
recipient=request.requestor,
|
||||
actor = actor or _request_actor(request)
|
||||
if actor and actor.id == request.requestor_id:
|
||||
return
|
||||
|
||||
_send_procurement_notification(
|
||||
actor=actor,
|
||||
verb=verb,
|
||||
action_object=request,
|
||||
recipient=request.requestor,
|
||||
procurement_request=request,
|
||||
title=title,
|
||||
description=message,
|
||||
action_url=ActionURLs.request_detail(request.id),
|
||||
data={
|
||||
'title': title,
|
||||
'request_id': request.id,
|
||||
},
|
||||
action_object=action_object,
|
||||
extra_data=extra_data,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@ -158,7 +304,11 @@ def notify_request_approved(request):
|
||||
request,
|
||||
NotificationVerbs.APPROVED,
|
||||
notification_title,
|
||||
description
|
||||
description,
|
||||
extra_data={
|
||||
"old_status": getattr(request, "_original_status", None),
|
||||
"new_status": request.status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -174,7 +324,11 @@ def notify_request_rejected(request):
|
||||
request,
|
||||
NotificationVerbs.REJECTED,
|
||||
notification_title,
|
||||
description
|
||||
description,
|
||||
extra_data={
|
||||
"old_status": getattr(request, "_original_status", None),
|
||||
"new_status": request.status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -184,41 +338,22 @@ 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
|
||||
description,
|
||||
extra_data={
|
||||
"old_status": getattr(request, "_original_status", None),
|
||||
"new_status": request.status,
|
||||
},
|
||||
)
|
||||
|
||||
# Уведомляем всех одобривших согласующих
|
||||
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.request_detail(request.id),
|
||||
data={
|
||||
'title': notification_title,
|
||||
'request_id': request.id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_request_in_progress(request, executor):
|
||||
"""
|
||||
@ -226,57 +361,28 @@ 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.request_detail(request.id),
|
||||
data={
|
||||
'title': notification_title,
|
||||
'request_id': request.id,
|
||||
'executor_id': executor.id,
|
||||
},
|
||||
)
|
||||
|
||||
# Уведомляем всех одобривших согласующих
|
||||
notification_title, description = MessageTemplates.in_progress(
|
||||
notification_title, description = MessageTemplates.in_progress_requestor(
|
||||
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.request_detail(request.id),
|
||||
data={
|
||||
'title': notification_title,
|
||||
'request_id': request.id,
|
||||
'executor_id': executor.id if executor else None,
|
||||
},
|
||||
)
|
||||
notify_requestor(
|
||||
request,
|
||||
NotificationVerbs.IN_PROGRESS,
|
||||
notification_title,
|
||||
description,
|
||||
actor=executor,
|
||||
extra_data={
|
||||
"old_status": getattr(request, "_original_status", None),
|
||||
"new_status": request.status,
|
||||
"executor_id": executor.id if executor else None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_request_cancelled(request):
|
||||
@ -293,20 +399,21 @@ def notify_request_cancelled(request):
|
||||
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.request_detail(request.id),
|
||||
data={
|
||||
'title': notification_title,
|
||||
'request_id': request.id,
|
||||
'reason': reason,
|
||||
},
|
||||
)
|
||||
actor = _request_actor(request)
|
||||
recipients = [approval.approver for approval in request.approvals.all()]
|
||||
_notify_many(
|
||||
recipients,
|
||||
verb=NotificationVerbs.CANCELLED,
|
||||
procurement_request=request,
|
||||
title=notification_title,
|
||||
description=description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"reason": reason,
|
||||
"old_status": getattr(request, "_original_status", None),
|
||||
"new_status": request.status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_stage_approved(approval):
|
||||
@ -325,7 +432,12 @@ def notify_stage_approved(approval):
|
||||
approval.request,
|
||||
NotificationVerbs.STAGE_APPROVED,
|
||||
notification_title,
|
||||
description
|
||||
description,
|
||||
actor=approval.approver,
|
||||
extra_data={
|
||||
"approval_id": approval.id,
|
||||
"approval_priority": approval.priority,
|
||||
},
|
||||
)
|
||||
notify_approvers(approval.request)
|
||||
|
||||
@ -346,5 +458,80 @@ def notify_stage_rejected(approval):
|
||||
approval.request,
|
||||
NotificationVerbs.REJECTED,
|
||||
notification_title,
|
||||
description
|
||||
description,
|
||||
actor=approval.approver,
|
||||
extra_data={
|
||||
"approval_id": approval.id,
|
||||
"approval_priority": approval.priority,
|
||||
"new_status": "rejected",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_item_updated(item, actor=None, changed_fields=None):
|
||||
"""Уведомить автора заявки об изменении позиции."""
|
||||
procurement_request = item.request
|
||||
actor_name = _actor_name(actor)
|
||||
notification_title, description = MessageTemplates.item_updated(
|
||||
procurement_request.title,
|
||||
item.name,
|
||||
actor_name,
|
||||
)
|
||||
notify_requestor(
|
||||
procurement_request,
|
||||
NotificationVerbs.ITEM_UPDATED,
|
||||
notification_title,
|
||||
description,
|
||||
actor=actor,
|
||||
extra_data={
|
||||
"item_id": item.id,
|
||||
"item_name": item.name,
|
||||
"changed_fields": changed_fields or [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_request_comment(request, message, actor=None):
|
||||
"""Уведомить автора заявки о комментарии к заявке."""
|
||||
actor = actor or message.author
|
||||
notification_title, description = MessageTemplates.request_commented(
|
||||
request.title,
|
||||
_actor_name(actor),
|
||||
)
|
||||
notify_requestor(
|
||||
request,
|
||||
NotificationVerbs.REQUEST_COMMENTED,
|
||||
notification_title,
|
||||
description,
|
||||
actor=actor,
|
||||
action_object=message,
|
||||
extra_data={
|
||||
"comment_id": message.id,
|
||||
"author_id": message.author_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def notify_item_comment(item, message, actor=None):
|
||||
"""Уведомить автора заявки о комментарии к позиции."""
|
||||
actor = actor or message.author
|
||||
procurement_request = item.request
|
||||
notification_title, description = MessageTemplates.item_commented(
|
||||
procurement_request.title,
|
||||
item.name,
|
||||
_actor_name(actor),
|
||||
)
|
||||
notify_requestor(
|
||||
procurement_request,
|
||||
NotificationVerbs.ITEM_COMMENTED,
|
||||
notification_title,
|
||||
description,
|
||||
actor=actor,
|
||||
action_object=message,
|
||||
extra_data={
|
||||
"comment_id": message.id,
|
||||
"author_id": message.author_id,
|
||||
"item_id": item.id,
|
||||
"item_name": item.name,
|
||||
},
|
||||
)
|
||||
|
||||
@ -7,7 +7,7 @@ Django сигналы для автоматической генерации у
|
||||
- Изменение статуса согласования
|
||||
|
||||
Все уведомления отправляются через универсальную систему notifications:
|
||||
noti fy.send() → channels.py → Celery → WebSocket/Email/Push
|
||||
notify.send() → channels.py → Celery → WebSocket/Email/Push
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -17,8 +17,8 @@ from django.dispatch import receiver
|
||||
from ..constants import ApprovalStatus, ProcurementStatus
|
||||
from ..models import Approval, ProcurementRequest
|
||||
from .handlers import (
|
||||
notify_new_request,
|
||||
notify_approvers,
|
||||
notify_processing_department_request,
|
||||
notify_request_approved,
|
||||
notify_request_rejected,
|
||||
notify_request_completed,
|
||||
@ -37,16 +37,26 @@ def create_request_notifications(sender, instance, created, **kwargs):
|
||||
Создает уведомления при создании или изменении заявки.
|
||||
|
||||
Обрабатывает:
|
||||
1. Новая заявка - уведомление руководителю отдела
|
||||
1. Новая адресная заявка - уведомление отделу-исполнителю
|
||||
2. Изменение статуса - уведомления соответствующим получателям
|
||||
"""
|
||||
try:
|
||||
request = instance
|
||||
|
||||
if created:
|
||||
# Новая заявка создана
|
||||
# WebSocket broadcast будет автоматически через notify.send()
|
||||
notify_new_request(request)
|
||||
if (
|
||||
request.status == ProcurementStatus.WAITING
|
||||
and request.processing_department_id
|
||||
and not getattr(
|
||||
request,
|
||||
"_suppress_created_procurement_notifications",
|
||||
False,
|
||||
)
|
||||
):
|
||||
notify_processing_department_request(
|
||||
request,
|
||||
actor=getattr(request, "_notification_actor", None),
|
||||
)
|
||||
return
|
||||
|
||||
# Проверяем изменение статуса
|
||||
@ -57,8 +67,6 @@ def create_request_notifications(sender, instance, created, **kwargs):
|
||||
if old_status == new_status:
|
||||
return
|
||||
|
||||
# Обработка изменения статуса
|
||||
# WebSocket broadcast будет автоматически через notify.send()
|
||||
_handle_status_change(request, new_status)
|
||||
|
||||
except Exception as e:
|
||||
@ -83,7 +91,8 @@ def _handle_status_change(request, new_status):
|
||||
|
||||
elif new_status == ProcurementStatus.REJECTED:
|
||||
# Заявка отклонена
|
||||
notify_request_rejected(request)
|
||||
if not getattr(request, "_requestor_rejected_notified", False):
|
||||
notify_request_rejected(request)
|
||||
|
||||
elif new_status == ProcurementStatus.COMPLETED:
|
||||
# Заявка завершена
|
||||
|
||||
@ -10,7 +10,13 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from employees.models import Department, Employee, EmployeeDepartment
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentRole,
|
||||
Employee,
|
||||
EmployeeDepartment,
|
||||
RoleAssignment,
|
||||
)
|
||||
from procurement.constants import (
|
||||
ApprovalStatus,
|
||||
ProcurementFulfillmentStatus,
|
||||
@ -758,6 +764,125 @@ class TestProcessingDepartmentWorkflow:
|
||||
urgency=UrgencyLevel.MEDIUM,
|
||||
)
|
||||
|
||||
def test_create_draft_request_does_not_notify(
|
||||
self, api_client, user, department, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
|
||||
response = api_client.post(
|
||||
url,
|
||||
{
|
||||
"title": "Черновик закупки",
|
||||
"description": "Без отдела-исполнителя",
|
||||
"department": department.id,
|
||||
"urgency": UrgencyLevel.MEDIUM,
|
||||
"items": [
|
||||
{
|
||||
"name": "Перчатки",
|
||||
"quantity": 1,
|
||||
"unit": "шт",
|
||||
"estimated_unit_price": "100.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["status"] == ProcurementStatus.DRAFT
|
||||
assert sent == []
|
||||
|
||||
def test_create_processing_department_request_notifies_department_members(
|
||||
self, api_client, user, department, supply_department, supply_user,
|
||||
monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
supply_head = Employee.objects.create_user(
|
||||
email="supply-head@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79998888890",
|
||||
first_name="Начальник",
|
||||
last_name="Снабжения",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
supply_department.head = supply_head
|
||||
supply_department.save()
|
||||
|
||||
role_user = Employee.objects.create_user(
|
||||
email="role-supply@example.com",
|
||||
password="testpass123",
|
||||
phone_number="+79998888891",
|
||||
first_name="Ролевой",
|
||||
last_name="Снабженец",
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
send_activation_email=False,
|
||||
)
|
||||
role = DepartmentRole.objects.create(
|
||||
department=supply_department,
|
||||
name="Закупки",
|
||||
)
|
||||
RoleAssignment.objects.create(employee=role_user, role=role)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
url = reverse('api:v1:procurement:procurementrequest-list')
|
||||
|
||||
response = api_client.post(
|
||||
url,
|
||||
{
|
||||
"title": "Расходники",
|
||||
"description": "Нужно для производства",
|
||||
"department": department.id,
|
||||
"processing_department": supply_department.id,
|
||||
"urgency": UrgencyLevel.MEDIUM,
|
||||
"items": [
|
||||
{
|
||||
"name": "Лампы",
|
||||
"quantity": 2,
|
||||
"unit": "шт",
|
||||
"estimated_unit_price": "300.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["status"] == ProcurementStatus.WAITING
|
||||
recipients = sorted(
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_department_request"
|
||||
)
|
||||
assert recipients == [
|
||||
"role-supply@example.com",
|
||||
"supply-head@example.com",
|
||||
"supply@example.com",
|
||||
]
|
||||
assert user.email not in recipients
|
||||
|
||||
def test_available_shows_processing_department_requests(
|
||||
self, api_client, supply_user, processing_request, procurement_item_factory
|
||||
):
|
||||
@ -847,10 +972,214 @@ class TestProcessingDepartmentWorkflow:
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.COMPLETED
|
||||
|
||||
def test_item_update_notifies_requestor(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
item = procurement_item_factory(request=processing_request)
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.IN_PROGRESS
|
||||
processing_request.save()
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
item_url = reverse(
|
||||
'api:v1:procurement:procurementitem-detail',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
item_url,
|
||||
{
|
||||
'execution_status': ProcurementItemExecutionStatus.ORDERED,
|
||||
'actual_unit_price': '120.00',
|
||||
'expected_delivery_date': '2026-05-25',
|
||||
'links': ['https://example.com/item'],
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
item_notifications = [
|
||||
item for item in sent
|
||||
if item["verb"] == "procurement_item_updated"
|
||||
]
|
||||
assert len(item_notifications) == 1
|
||||
assert item_notifications[0]["recipient"] == processing_request.requestor
|
||||
assert item_notifications[0]["data"]["item_id"] == item.id
|
||||
assert sorted(item_notifications[0]["data"]["changed_fields"]) == [
|
||||
"actual_unit_price",
|
||||
"execution_status",
|
||||
"expected_delivery_date",
|
||||
"links",
|
||||
]
|
||||
|
||||
def test_requestor_item_update_does_not_notify_self(
|
||||
self, api_client, user, processing_request,
|
||||
procurement_item_factory, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
item = procurement_item_factory(request=processing_request)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
item_url = reverse(
|
||||
'api:v1:procurement:procurementitem-detail',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.patch(
|
||||
item_url,
|
||||
{'name': 'Перчатки нитриловые'},
|
||||
format='json',
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [
|
||||
item for item in sent
|
||||
if item["verb"] == "procurement_item_updated"
|
||||
] == []
|
||||
|
||||
def test_request_comment_notifies_requestor(
|
||||
self, api_client, supply_user, processing_request, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementrequest-comments',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'text': 'Взяли в анализ'})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
comment_notifications = [
|
||||
item for item in sent
|
||||
if item["verb"] == "procurement_request_commented"
|
||||
]
|
||||
assert len(comment_notifications) == 1
|
||||
assert comment_notifications[0]["recipient"] == processing_request.requestor
|
||||
assert comment_notifications[0]["data"]["comment_id"] == response.data["id"]
|
||||
|
||||
def test_item_comment_notifies_requestor(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
item = procurement_item_factory(request=processing_request)
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
url = reverse(
|
||||
'api:v1:procurement:procurementitem-comments',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
response = api_client.post(url, {'text': 'Заказали аналог'})
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
comment_notifications = [
|
||||
item for item in sent
|
||||
if item["verb"] == "procurement_item_commented"
|
||||
]
|
||||
assert len(comment_notifications) == 1
|
||||
assert comment_notifications[0]["recipient"] == processing_request.requestor
|
||||
assert comment_notifications[0]["data"]["item_id"] == item.id
|
||||
assert comment_notifications[0]["data"]["comment_id"] == response.data["id"]
|
||||
|
||||
def test_requestor_comments_do_not_notify_self(
|
||||
self, api_client, user, processing_request,
|
||||
procurement_item_factory, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
item = procurement_item_factory(request=processing_request)
|
||||
|
||||
api_client.force_authenticate(user=user)
|
||||
request_comment_url = reverse(
|
||||
'api:v1:procurement:procurementrequest-comments',
|
||||
kwargs={'pk': processing_request.id},
|
||||
)
|
||||
item_comment_url = reverse(
|
||||
'api:v1:procurement:procurementitem-comments',
|
||||
kwargs={'pk': item.id},
|
||||
)
|
||||
|
||||
request_response = api_client.post(
|
||||
request_comment_url,
|
||||
{'text': 'Мой комментарий'},
|
||||
)
|
||||
item_response = api_client.post(
|
||||
item_comment_url,
|
||||
{'text': 'Мой комментарий к позиции'},
|
||||
)
|
||||
|
||||
assert request_response.status_code == status.HTTP_201_CREATED
|
||||
assert item_response.status_code == status.HTTP_201_CREATED
|
||||
assert [
|
||||
item for item in sent
|
||||
if item["verb"] in {
|
||||
"procurement_request_commented",
|
||||
"procurement_item_commented",
|
||||
}
|
||||
] == []
|
||||
|
||||
def test_mark_all_received_completes_processing_request(
|
||||
self, api_client, supply_user, processing_request,
|
||||
procurement_item_factory
|
||||
procurement_item_factory, monkeypatch
|
||||
):
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
sent.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"procurement.notifications.handlers.notify.send",
|
||||
fake_notify_send,
|
||||
)
|
||||
|
||||
item_1 = procurement_item_factory(request=processing_request)
|
||||
item_2 = procurement_item_factory(
|
||||
request=processing_request,
|
||||
@ -859,6 +1188,7 @@ class TestProcessingDepartmentWorkflow:
|
||||
processing_request.executor = supply_user
|
||||
processing_request.status = ProcurementStatus.IN_PROGRESS
|
||||
processing_request.save()
|
||||
sent.clear()
|
||||
|
||||
api_client.force_authenticate(user=supply_user)
|
||||
url = reverse(
|
||||
@ -879,6 +1209,21 @@ class TestProcessingDepartmentWorkflow:
|
||||
== ProcurementFulfillmentStatus.COMPLETED
|
||||
)
|
||||
assert processing_request.status == ProcurementStatus.COMPLETED
|
||||
item_update_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_item_updated"
|
||||
]
|
||||
assert item_update_recipients == [
|
||||
processing_request.requestor.email,
|
||||
processing_request.requestor.email,
|
||||
]
|
||||
completed_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_completed"
|
||||
]
|
||||
assert completed_recipients == [processing_request.requestor.email]
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@ -1007,6 +1352,12 @@ class TestProcurementRequestWorkflow:
|
||||
if item["verb"] == "procurement_pending_approval"
|
||||
]
|
||||
assert pending_recipients == ["user@example.com"]
|
||||
stage_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_stage_approved"
|
||||
]
|
||||
assert stage_recipients == [procurement_request.requestor.email]
|
||||
|
||||
def test_final_approve_does_not_notify_future_approvers(
|
||||
self, api_client, approver_with_permission, procurement_request, procurement_item, monkeypatch
|
||||
@ -1048,6 +1399,12 @@ class TestProcurementRequestWorkflow:
|
||||
if item["verb"] == "procurement_pending_approval"
|
||||
]
|
||||
assert pending_notifications == []
|
||||
approved_recipients = [
|
||||
item["recipient"].email
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_approved"
|
||||
]
|
||||
assert approved_recipients == [procurement_request.requestor.email]
|
||||
|
||||
def test_reject_notifies_requestor_without_next_stage_notification(
|
||||
self, api_client, department_head, procurement_request, procurement_item, monkeypatch
|
||||
@ -1093,8 +1450,8 @@ class TestProcurementRequestWorkflow:
|
||||
|
||||
verbs = [item["verb"] for item in sent]
|
||||
recipients = [item["recipient"].email for item in sent]
|
||||
assert "procurement_rejected" in verbs
|
||||
assert procurement_request.requestor.email in recipients
|
||||
assert verbs.count("procurement_rejected") == 1
|
||||
assert recipients.count(procurement_request.requestor.email) == 1
|
||||
assert "procurement_pending_approval" not in verbs
|
||||
|
||||
def test_cancel_notifies_all_related_approvers(
|
||||
@ -1144,12 +1501,12 @@ class TestProcurementRequestWorkflow:
|
||||
for item in sent
|
||||
if item["verb"] == "procurement_cancelled"
|
||||
]
|
||||
assert cancelled_recipients == ["head@example.com", "user@example.com"]
|
||||
assert cancelled_recipients == ["head@example.com"]
|
||||
|
||||
def test_start_work_notifies_requestor_and_approved_approvers(
|
||||
def test_start_work_notifies_requestor(
|
||||
self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""При взятии в работу уведомляются создатель и уже одобрившие этапы."""
|
||||
"""При взятии в работу уведомляется автор заявки."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
@ -1196,14 +1553,12 @@ class TestProcurementRequestWorkflow:
|
||||
]
|
||||
assert in_progress_recipients == [
|
||||
procurement_request.requestor.email,
|
||||
"head@example.com",
|
||||
"user@example.com",
|
||||
]
|
||||
|
||||
def test_complete_notifies_requestor_and_approved_approvers(
|
||||
def test_complete_notifies_requestor(
|
||||
self, api_client, user, staff_user, department_head, procurement_request, procurement_item, monkeypatch
|
||||
):
|
||||
"""При завершении уведомляются создатель и уже одобрившие этапы."""
|
||||
"""При завершении уведомляется автор заявки."""
|
||||
sent = []
|
||||
|
||||
def fake_notify_send(**kwargs):
|
||||
@ -1250,8 +1605,6 @@ class TestProcurementRequestWorkflow:
|
||||
]
|
||||
assert completed_recipients == [
|
||||
procurement_request.requestor.email,
|
||||
"head@example.com",
|
||||
"user@example.com",
|
||||
]
|
||||
|
||||
def test_submit_request(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user