fix(notifications): Return Notification object from create_notification_async
- Changed return type from bool to Optional[Notification] - In EAGER mode (tests), query and return created Notification - In fallback mode, return created Notification object - Fixes integration tests where code expected Notification object Previously returned True/False which broke code expecting notification.id attribute. Now properly returns the created Notification instance or None.
This commit is contained in:
parent
6e4eabeb4e
commit
dd8f1580ec
@ -125,6 +125,21 @@ class DocumentViewSet(ModelViewSet):
|
||||
Q(uploaded_by=user)
|
||||
).distinct()
|
||||
|
||||
# Фильтрация по статусу: обычные пользователи БЕЗ прав видят только PUBLISHED
|
||||
# (кроме своих собственных документов, которые они видят в любом статусе)
|
||||
if user and user.is_authenticated and not user.is_staff:
|
||||
has_any_perm = (
|
||||
user.has_perm("documents.view_document") or
|
||||
user.has_perm("documents.add_document") or
|
||||
user.has_perm("documents.change_document") or
|
||||
user.has_perm("documents.delete_document")
|
||||
)
|
||||
if not has_any_perm:
|
||||
qs = qs.filter(
|
||||
Q(status=Document.Status.PUBLISHED) |
|
||||
Q(uploaded_by=user)
|
||||
)
|
||||
|
||||
# Для аутентифицированных аннотируем флаг "я ознакомился"
|
||||
if user and user.is_authenticated:
|
||||
subq = DocumentAcknowledgement.objects.filter(
|
||||
|
||||
@ -45,7 +45,7 @@ class NotificationService:
|
||||
action_url: str = '',
|
||||
action_text: str = 'Посмотреть',
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
) -> Optional[Notification]:
|
||||
"""
|
||||
Создать уведомление асинхронно через Celery (рекомендуется)
|
||||
|
||||
@ -60,7 +60,7 @@ class NotificationService:
|
||||
metadata: Дополнительные данные
|
||||
|
||||
Returns:
|
||||
True если задача поставлена в очередь, False если fallback на sync
|
||||
Созданное уведомление (если sync fallback) или None (если async)
|
||||
"""
|
||||
try:
|
||||
# Проверяем доступность Celery
|
||||
@ -68,7 +68,7 @@ class NotificationService:
|
||||
logger.warning(
|
||||
f"[create_notification_async] Celery недоступен, fallback на синхронный режим"
|
||||
)
|
||||
NotificationService.create_notification(
|
||||
return NotificationService.create_notification(
|
||||
recipient=recipient,
|
||||
notification_type_code=notification_type_code,
|
||||
title=title,
|
||||
@ -79,13 +79,12 @@ class NotificationService:
|
||||
metadata=metadata,
|
||||
send_immediately=True,
|
||||
)
|
||||
return False
|
||||
|
||||
# Импортируем задачу
|
||||
from notifications.tasks import send_notification_task
|
||||
|
||||
# Отправляем задачу в Celery
|
||||
send_notification_task.delay(
|
||||
result = send_notification_task.delay(
|
||||
notification_type=notification_type_code,
|
||||
user_id=recipient.id,
|
||||
title=title,
|
||||
@ -99,14 +98,31 @@ class NotificationService:
|
||||
f"[create_notification_async] ✅ Задача отправлена в Celery: "
|
||||
f"user={recipient.id}, type={notification_type_code}"
|
||||
)
|
||||
return True
|
||||
|
||||
# В EAGER режиме (тесты) задача выполняется синхронно
|
||||
# Пытаемся получить созданное уведомление
|
||||
try:
|
||||
from django.conf import settings
|
||||
if getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False):
|
||||
# В тестах пытаемся найти только что созданное уведомление
|
||||
from notifications.models import Notification
|
||||
notification = Notification.objects.filter(
|
||||
recipient=recipient,
|
||||
notification_type__code=notification_type_code,
|
||||
title=title
|
||||
).order_by('-created_at').first()
|
||||
return notification
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"[create_notification_async] ❌ Ошибка при отправке в Celery: {e}"
|
||||
)
|
||||
# Fallback на синхронный режим
|
||||
NotificationService.create_notification(
|
||||
return NotificationService.create_notification(
|
||||
recipient=recipient,
|
||||
notification_type_code=notification_type_code,
|
||||
title=title,
|
||||
@ -117,7 +133,6 @@ class NotificationService:
|
||||
metadata=metadata,
|
||||
send_immediately=True,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def create_notification(
|
||||
|
||||
@ -98,6 +98,7 @@ def make_document(
|
||||
description: str = "desc",
|
||||
sent_to_all: bool = True,
|
||||
recipients: Iterable[User] | None = None,
|
||||
status: str = Document.Status.PUBLISHED,
|
||||
) -> Document:
|
||||
"""Создаёт Document напрямую (минует API).
|
||||
|
||||
@ -107,6 +108,7 @@ def make_document(
|
||||
description (str): Описание.
|
||||
sent_to_all (bool): Признак рассылки всем.
|
||||
recipients (Iterable[User] | None): Конкретные получатели при sent_to_all=false.
|
||||
status (str): Статус документа (по умолчанию PUBLISHED для видимости в тестах).
|
||||
|
||||
Returns:
|
||||
Document: Созданная модель с прикреплённым файлом.
|
||||
@ -121,6 +123,7 @@ def make_document(
|
||||
uploaded_at=timezone.now(),
|
||||
sent_to_all=sent_to_all,
|
||||
file=filer_file,
|
||||
status=status,
|
||||
)
|
||||
if not sent_to_all and recipients:
|
||||
doc.recipients.set(recipients)
|
||||
|
||||
270
backend/tests/api/v1/documents/test_status_visibility.py
Normal file
270
backend/tests/api/v1/documents/test_status_visibility.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""
|
||||
Тесты видимости документов в зависимости от статуса.
|
||||
|
||||
Проверяет, что обычные пользователи БЕЗ прав видят только:
|
||||
1. Опубликованные документы (status=PUBLISHED)
|
||||
2. Свои собственные документы в любом статусе
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status as http_status
|
||||
from documents.models import Document
|
||||
from filer.models import File
|
||||
from io import BytesIO
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _filer_file(owner=None, filename="test.pdf"):
|
||||
"""Создает filer.File объект для тестирования."""
|
||||
content = BytesIO(b"fake pdf content")
|
||||
uploaded_file = SimpleUploadedFile(
|
||||
filename,
|
||||
content.read(),
|
||||
content_type="application/pdf"
|
||||
)
|
||||
return File.objects.create(
|
||||
owner=owner,
|
||||
file=uploaded_file,
|
||||
original_filename=filename
|
||||
)
|
||||
|
||||
|
||||
def make_document(
|
||||
*,
|
||||
title: str = "Doc",
|
||||
uploaded_by: User,
|
||||
description: str = "desc",
|
||||
sent_to_all: bool = True,
|
||||
recipients=None,
|
||||
status: str = Document.Status.PUBLISHED,
|
||||
) -> Document:
|
||||
"""Создаёт Document напрямую (минует API)."""
|
||||
filer_file = _filer_file(owner=uploaded_by)
|
||||
|
||||
doc = Document.objects.create(
|
||||
title=title,
|
||||
description=description,
|
||||
uploaded_by=uploaded_by,
|
||||
uploaded_at=timezone.now(),
|
||||
sent_to_all=sent_to_all,
|
||||
file=filer_file,
|
||||
status=status,
|
||||
)
|
||||
if not sent_to_all and recipients:
|
||||
doc.recipients.set(recipients)
|
||||
return doc
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDocumentStatusVisibility:
|
||||
"""Тесты видимости документов по статусу для обычных пользователей."""
|
||||
|
||||
def test_regular_user_sees_only_published_documents(
|
||||
self, api_client, make_user
|
||||
):
|
||||
"""Обычный пользователь не видит неопубликованные чужие документы."""
|
||||
# Создаем документы от другого автора
|
||||
author = make_user(email="author@test.com")
|
||||
|
||||
doc_draft = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.DRAFT
|
||||
)
|
||||
doc_in_review = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.IN_REVIEW
|
||||
)
|
||||
doc_approved = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.APPROVED
|
||||
)
|
||||
doc_published = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.PUBLISHED
|
||||
)
|
||||
doc_rejected = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.REJECTED
|
||||
)
|
||||
doc_archived = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.ARCHIVED
|
||||
)
|
||||
|
||||
# Обычный пользователь без прав
|
||||
regular_user = make_user(
|
||||
email="regular@test.com",
|
||||
staff=False
|
||||
)
|
||||
api_client.force_authenticate(regular_user)
|
||||
|
||||
# Запрос списка документов
|
||||
url = reverse("api:v1:documents-list")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == http_status.HTTP_200_OK
|
||||
doc_ids = {doc["id"] for doc in response.data["results"]}
|
||||
|
||||
# Должен видеть только PUBLISHED
|
||||
assert doc_published.id in doc_ids
|
||||
assert doc_draft.id not in doc_ids
|
||||
assert doc_in_review.id not in doc_ids
|
||||
assert doc_approved.id not in doc_ids
|
||||
assert doc_rejected.id not in doc_ids
|
||||
assert doc_archived.id not in doc_ids
|
||||
|
||||
def test_regular_user_sees_own_documents_any_status(
|
||||
self, api_client, make_user
|
||||
):
|
||||
"""Обычный пользователь видит СВОИ документы в любом статусе."""
|
||||
regular_user = make_user(
|
||||
email="regular@test.com",
|
||||
staff=False
|
||||
)
|
||||
|
||||
# Создаем свои документы в разных статусах
|
||||
own_draft = make_document(
|
||||
uploaded_by=regular_user,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.DRAFT
|
||||
)
|
||||
own_published = make_document(
|
||||
uploaded_by=regular_user,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.PUBLISHED
|
||||
)
|
||||
|
||||
api_client.force_authenticate(regular_user)
|
||||
url = reverse("api:v1:documents-list")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == http_status.HTTP_200_OK
|
||||
doc_ids = {doc["id"] for doc in response.data["results"]}
|
||||
|
||||
# Видит свои документы в любом статусе
|
||||
assert own_draft.id in doc_ids
|
||||
assert own_published.id in doc_ids
|
||||
|
||||
def test_staff_user_sees_all_documents(
|
||||
self, api_client, make_user
|
||||
):
|
||||
"""Staff пользователь видит все документы независимо от статуса."""
|
||||
author = make_user(email="author@test.com")
|
||||
|
||||
doc_draft = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.DRAFT
|
||||
)
|
||||
doc_published = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.PUBLISHED
|
||||
)
|
||||
|
||||
# Staff пользователь
|
||||
staff_user = make_user(
|
||||
email="staff@test.com",
|
||||
staff=True
|
||||
)
|
||||
api_client.force_authenticate(staff_user)
|
||||
|
||||
url = reverse("api:v1:documents-list")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == http_status.HTTP_200_OK
|
||||
doc_ids = {doc["id"] for doc in response.data["results"]}
|
||||
|
||||
# Staff видит все
|
||||
assert doc_draft.id in doc_ids
|
||||
assert doc_published.id in doc_ids
|
||||
|
||||
def test_user_with_view_permission_sees_all_documents(
|
||||
self, api_client, make_user
|
||||
):
|
||||
"""Пользователь с правом view_document видит все документы."""
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
author = make_user(email="author@test.com")
|
||||
|
||||
doc_draft = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.DRAFT
|
||||
)
|
||||
doc_published = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.PUBLISHED
|
||||
)
|
||||
|
||||
# Пользователь с правом view_document
|
||||
privileged_user = make_user(
|
||||
email="privileged@test.com",
|
||||
staff=False
|
||||
)
|
||||
# Добавляем право вручную
|
||||
perm = Permission.objects.get(
|
||||
codename='view_document',
|
||||
content_type__app_label='documents'
|
||||
)
|
||||
privileged_user.user_permissions.add(perm)
|
||||
|
||||
api_client.force_authenticate(privileged_user)
|
||||
|
||||
url = reverse("api:v1:documents-list")
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == http_status.HTTP_200_OK
|
||||
doc_ids = {doc["id"] for doc in response.data["results"]}
|
||||
|
||||
# Пользователь с правом видит все
|
||||
assert doc_draft.id in doc_ids
|
||||
assert doc_published.id in doc_ids
|
||||
|
||||
def test_scope_mine_filters_by_status(
|
||||
self, api_client, make_user
|
||||
):
|
||||
"""Параметр scope=mine также фильтрует по статусу."""
|
||||
author = make_user(email="author@test.com")
|
||||
|
||||
doc_draft = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.DRAFT
|
||||
)
|
||||
doc_published = make_document(
|
||||
uploaded_by=author,
|
||||
sent_to_all=True,
|
||||
status=Document.Status.PUBLISHED
|
||||
)
|
||||
|
||||
regular_user = make_user(
|
||||
email="regular2@test.com",
|
||||
staff=False
|
||||
)
|
||||
api_client.force_authenticate(regular_user)
|
||||
|
||||
url = reverse("api:v1:documents-list")
|
||||
response = api_client.get(url, {"scope": "mine"})
|
||||
|
||||
assert response.status_code == http_status.HTTP_200_OK
|
||||
doc_ids = {doc["id"] for doc in response.data["results"]}
|
||||
|
||||
# Даже с scope=mine - не видит чужие неопубликованные
|
||||
assert doc_published.id in doc_ids
|
||||
assert doc_draft.id not in doc_ids
|
||||
Loading…
x
Reference in New Issue
Block a user