From e676045a0a57bdc1c6c680a5e7c940495e3630db Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 2 Jun 2026 16:36:47 +0300 Subject: [PATCH] Adjust document acknowledgement notifications --- backend/api/v1/documents/views.py | 10 ++ backend/documents/notifications/config.py | 20 ++- backend/documents/notifications/handlers.py | 85 +++++------ backend/documents/notifications/signals.py | 6 + .../api/v1/documents/test_documents_api.py | 137 +++++++++++++++++- 5 files changed, 197 insertions(+), 61 deletions(-) diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 618a1634..f39d18b9 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -306,6 +306,16 @@ class DocumentViewSet(ModelViewSet): Response: {"ok": true, "already": bool} """ doc = self.get_object() + if not doc.acknowledgement_required: + return Response( + { + "detail": ( + "Для этого документа не требуется подтверждение " + "ознакомления." + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) obj, created = DocumentAcknowledgement.objects.get_or_create( document=doc, user=request.user ) diff --git a/backend/documents/notifications/config.py b/backend/documents/notifications/config.py index 41c46c2c..15e8de71 100644 --- a/backend/documents/notifications/config.py +++ b/backend/documents/notifications/config.py @@ -23,16 +23,22 @@ class MessageTemplates: """Шаблоны сообщений для уведомлений documents.""" @staticmethod - def document_ready(uploader_name: str, document_title: str) -> str: - """Шаблон для нового документа на ознакомление.""" - return ( - f'{uploader_name} загрузил документ "{document_title}". ' - f'Требуется ознакомление.' - ) + def document_ready( + uploader_name: str, + document_title: str, + acknowledgement_required: bool = True, + ) -> str: + """Шаблон для нового документа.""" + message = f'{uploader_name} загрузил документ "{document_title}".' + if acknowledgement_required: + return f"{message} Требуется ознакомление." + return message @staticmethod - def document_ready_title() -> str: + def document_ready_title(acknowledgement_required: bool = True) -> str: """Заголовок для нового документа.""" + if not acknowledgement_required: + return 'Новый документ' return 'Новый документ на ознакомление' @staticmethod diff --git a/backend/documents/notifications/handlers.py b/backend/documents/notifications/handlers.py index 12d51ebe..d9f72d22 100644 --- a/backend/documents/notifications/handlers.py +++ b/backend/documents/notifications/handlers.py @@ -27,6 +27,29 @@ logger = logging.getLogger(__name__) Employee = get_user_model() +def _document_ready_payload(document, uploader_name): + """Собирает единый текст и данные уведомления о новом документе.""" + acknowledgement_required = bool(document.acknowledgement_required) + return { + "description": MessageTemplates.document_ready( + uploader_name, + document.title, + acknowledgement_required=acknowledgement_required, + ), + "data": { + "title": MessageTemplates.document_ready_title( + acknowledgement_required=acknowledgement_required, + ), + "document_id": document.id, + "uploaded_by_id": ( + document.uploaded_by.id if document.uploaded_by else None + ), + "sent_to_all": document.sent_to_all, + "acknowledgement_required": acknowledgement_required, + }, + } + + def notify_document_ready(document, recipient): """ Отправляет уведомление конкретному получателю о новом документе. @@ -41,24 +64,16 @@ def notify_document_ready(document, recipient): ) uploader_name = get_uploader_name(document.uploaded_by) + payload = _document_ready_payload(document, uploader_name) notify.send( sender=document.uploaded_by, recipient=recipient, verb=NotificationVerbs.DOCUMENT_READY, action_object=document, - description=MessageTemplates.document_ready( - uploader_name, document.title - ), + description=payload["description"], action_url=ActionURLs.DOCUMENTS, - data={ - "title": MessageTemplates.document_ready_title(), - "document_id": document.id, - "uploaded_by_id": document.uploaded_by.id - if document.uploaded_by - else None, - "sent_to_all": document.sent_to_all, - }, + data=payload["data"], ) @@ -86,6 +101,7 @@ def notify_all_employees(document): # Создаём уведомления created_count = 0 uploader_name = get_uploader_name(document.uploaded_by) + payload = _document_ready_payload(document, uploader_name) for employee in active_employees: try: @@ -94,20 +110,9 @@ def notify_all_employees(document): recipient=employee, verb=NotificationVerbs.DOCUMENT_READY, action_object=document, - description=MessageTemplates.document_ready( - uploader_name, document.title - ), + description=payload["description"], action_url=ActionURLs.DOCUMENTS, - data={ - "title": MessageTemplates.document_ready_title(), - "document_id": document.id, - "uploaded_by_id": ( - document.uploaded_by.id - if document.uploaded_by - else None - ), - "sent_to_all": document.sent_to_all, - }, + data=payload["data"], ) created_count += 1 except Exception as e: @@ -164,6 +169,7 @@ def notify_specific_users(document, user_ids): # Уведомляем получателей created_count = 0 uploader_name = get_uploader_name(document.uploaded_by) + payload = _document_ready_payload(document, uploader_name) for user_id in user_ids: try: @@ -174,20 +180,9 @@ def notify_specific_users(document, user_ids): recipient=user, verb=NotificationVerbs.DOCUMENT_READY, action_object=document, - description=MessageTemplates.document_ready( - uploader_name, document.title - ), + description=payload["description"], action_url=ActionURLs.DOCUMENTS, - data={ - "title": MessageTemplates.document_ready_title(), - "document_id": document.id, - "uploaded_by_id": ( - document.uploaded_by.id - if document.uploaded_by - else None - ), - "sent_to_all": document.sent_to_all, - }, + data=payload["data"], ) created_count += 1 except Employee.DoesNotExist: @@ -259,6 +254,7 @@ def notify_department_employees(document, department_ids): # Создаём уведомления created_count = 0 uploader_name = get_uploader_name(document.uploaded_by) + payload = _document_ready_payload(document, uploader_name) for employee in all_employees: try: @@ -267,20 +263,9 @@ def notify_department_employees(document, department_ids): recipient=employee, verb=NotificationVerbs.DOCUMENT_READY, action_object=document, - description=MessageTemplates.document_ready( - uploader_name, document.title - ), + description=payload["description"], action_url=ActionURLs.DOCUMENTS, - data={ - "title": MessageTemplates.document_ready_title(), - "document_id": document.id, - "uploaded_by_id": ( - document.uploaded_by.id - if document.uploaded_by - else None - ), - "sent_to_all": document.sent_to_all, - }, + data=payload["data"], ) created_count += 1 except Exception as e: diff --git a/backend/documents/notifications/signals.py b/backend/documents/notifications/signals.py index 16f740d2..5ccc570f 100644 --- a/backend/documents/notifications/signals.py +++ b/backend/documents/notifications/signals.py @@ -127,6 +127,12 @@ def check_all_acknowledged(sender, instance, created, **kwargs): acknowledgement = instance document = acknowledgement.document + if not document.acknowledgement_required: + logger.info( + "[signals] Skipping full acknowledgement notification " + f"for optional document id={document.pk}" + ) + return # Определяем общее количество получателей if document.sent_to_all: diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 63094291..f03949bc 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -8,9 +8,10 @@ from __future__ import annotations - Создавать — все аутентифицированные пользователи (документы создаются в draft). - Редактировать/удалять — только создатель, админы или обладатели модельных прав. - Approve/Publish — только пользователи с правом change_document (django-rules). -- Читать/скачивать/ознакомливаться — только аутентифицированные, и только документы, - предназначенные им (или с sent_to_all=True). Обычные пользователи видят только PUBLISHED - документы (кроме своих собственных). +- Читать/скачивать — только аутентифицированные, и только документы, + предназначенные им (или с sent_to_all=True). Ознакомливаться можно только + с документами, где acknowledgement_required=True. Обычные пользователи + видят только PUBLISHED документы (кроме своих собственных). Все функции и классы снабжены докстрингами (Google-style). """ @@ -101,6 +102,7 @@ def make_document( uploaded_by: User, description: str = "desc", sent_to_all: bool = True, + acknowledgement_required: bool = False, recipients: Iterable[User] | None = None, ) -> Document: """Создаёт Document напрямую (минует API). @@ -110,6 +112,7 @@ def make_document( uploaded_by (User): Кто загрузил. description (str): Описание. sent_to_all (bool): Признак рассылки всем. + acknowledgement_required (bool): Требуется ли ознакомление. recipients (Iterable[User] | None): Конкретные получатели при sent_to_all=false. Returns: @@ -124,6 +127,7 @@ def make_document( uploaded_by=uploaded_by, uploaded_at=timezone.now(), sent_to_all=sent_to_all, + acknowledgement_required=acknowledgement_required, file=filer_file, ) if not sent_to_all and recipients: @@ -892,6 +896,107 @@ class TestFolders: assert archive.read("Root/Child/child.txt") == b"child payload" assert "Other/other.txt" not in archive.namelist() + +# -------------------- E3. Уведомления -------------------- + +class TestDocumentNotifications: + """E3. Уведомления о новых документах и ознакомлении.""" + + def test_required_document_notification_mentions_acknowledgement( + self, monkeypatch, make_user + ): + """Документ на ознакомление получает требовательный текст.""" + from documents.notifications.handlers import notify_document_ready + + calls = [] + + def fake_notify_send(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr( + "documents.notifications.handlers.notify.send", + fake_notify_send, + ) + author = make_user("required-document-author@example.com") + viewer = make_user("required-document-viewer@example.com") + doc = make_document( + title="Регламент", + uploaded_by=author, + sent_to_all=False, + acknowledgement_required=True, + ) + + notify_document_ready(doc, viewer) + + assert len(calls) == 1 + payload = calls[0] + assert payload["recipient"] == viewer + assert payload["verb"] == "document_ready" + assert "Требуется ознакомление" in payload["description"] + assert payload["data"]["title"] == "Новый документ на ознакомление" + assert payload["data"]["acknowledgement_required"] is True + + def test_optional_document_notification_is_neutral( + self, monkeypatch, make_user + ): + """Документ без ознакомления уведомляет без требования ознакомиться.""" + from documents.notifications.handlers import notify_document_ready + + calls = [] + + def fake_notify_send(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr( + "documents.notifications.handlers.notify.send", + fake_notify_send, + ) + author = make_user("optional-document-author@example.com") + viewer = make_user("optional-document-viewer@example.com") + doc = make_document( + title="Справка", + uploaded_by=author, + sent_to_all=False, + acknowledgement_required=False, + ) + + notify_document_ready(doc, viewer) + + assert len(calls) == 1 + payload = calls[0] + assert payload["recipient"] == viewer + assert payload["verb"] == "document_ready" + assert "Требуется ознакомление" not in payload["description"] + assert payload["description"].endswith('загрузил документ "Справка".') + assert payload["data"]["title"] == "Новый документ" + assert payload["data"]["acknowledgement_required"] is False + + def test_optional_document_acknowledgement_does_not_notify_uploader( + self, monkeypatch, make_user + ): + """Ручные ACK по необязательному документу не запускают итоговое уведомление.""" + calls = [] + + def fake_notify_all_acknowledged(*args): + calls.append(args) + + monkeypatch.setattr( + "documents.notifications.signals.notify_all_acknowledged", + fake_notify_all_acknowledged, + ) + author = make_user("optional-ack-author@example.com") + viewer = make_user("optional-ack-viewer@example.com") + doc = make_document( + uploaded_by=author, + sent_to_all=False, + acknowledgement_required=False, + ) + + DocumentAcknowledgement.objects.create(document=doc, user=viewer) + + assert calls == [] + + # -------------------- F. Экшен acknowledge -------------------- class TestAcknowledge: @@ -906,7 +1011,9 @@ class TestAcknowledge: client = auth_client(any_user) d = make_document( - uploaded_by=author, sent_to_all=True + uploaded_by=author, + sent_to_all=True, + acknowledgement_required=True, ) # доступен всем аутентифицированным r1 = client.post(api_urls["ack"](d.pk), {}) @@ -954,6 +1061,28 @@ class TestAcknowledge: ) assert r.status_code == 404 + def test_ack_rejects_document_without_required_acknowledgement( + self, auth_client, make_user, api_urls + ): + """Документ без обязательного ознакомления нельзя отметить через API.""" + author = make_user("optional-author@example.com") + viewer = make_user("optional-viewer@example.com") + client = auth_client(viewer) + doc = make_document( + uploaded_by=author, + sent_to_all=True, + acknowledgement_required=False, + ) + + response = client.post(api_urls["ack"](doc.pk), {}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "не требуется" in response.json()["detail"] + assert not DocumentAcknowledgement.objects.filter( + document=doc, + user=viewer, + ).exists() + # -------------------- G. Сериализация и данные -------------------- class TestSerialization: