diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 7c0f7896..bb8e5c70 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -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( diff --git a/backend/notifications/services.py b/backend/notifications/services.py index 12a4992e..6403666f 100644 --- a/backend/notifications/services.py +++ b/backend/notifications/services.py @@ -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( diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index bb25b62a..27be1967 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -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) diff --git a/backend/tests/api/v1/documents/test_status_visibility.py b/backend/tests/api/v1/documents/test_status_visibility.py new file mode 100644 index 00000000..1b1ec0b6 --- /dev/null +++ b/backend/tests/api/v1/documents/test_status_visibility.py @@ -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