Adjust document acknowledgement notifications

This commit is contained in:
lizerk 2026-06-02 16:36:47 +03:00
parent a794ff40c8
commit e676045a0a
5 changed files with 197 additions and 61 deletions

View File

@ -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
)

View File

@ -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

View File

@ -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:

View File

@ -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:

View File

@ -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: