From dd4f99c24425032e30f0c175c5cc8f09782ecd92 Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 6 Mar 2026 12:21:11 +0300 Subject: [PATCH] =?UTF-8?q?refactor(documents):=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20FSM=20workflow=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удалены поля document_type, status, published_at из модели Document - Упрощены разрешения и правила доступа - Удалены endpoint'ы для работы со статусами - Удалены тесты FSM workflow - Обновлены serializers для новой структуры модели --- backend/api/v1/documents/permissions.py | 38 +- backend/api/v1/documents/serializers.py | 128 ++--- backend/api/v1/documents/views.py | 290 ---------- backend/api/v1/urls.py | 4 - backend/documents/models.py | 106 +--- backend/documents/rules.py | 43 -- .../api/v1/documents/test_documents_api.py | 11 +- .../api/v1/documents/test_fsm_workflow.py | 518 ------------------ .../v1/documents/test_status_visibility.py | 270 --------- 9 files changed, 65 insertions(+), 1343 deletions(-) delete mode 100644 backend/tests/api/v1/documents/test_fsm_workflow.py delete mode 100644 backend/tests/api/v1/documents/test_status_visibility.py diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index b9a06c58..1b233bb0 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -35,18 +35,6 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): perms_map["HEAD"] = [] perms_map["OPTIONS"] = [] - # FSM workflow actions - разрешаем на основе object-level permissions - FSM_ACTIONS = ( - "submit_for_review", - "approve", - "reject", - "publish", - "archive", - "unarchive", - "return_to_draft", - "revert", - ) - def has_permission(self, request: Request, view: View) -> bool: action = getattr(view, "action", None) @@ -54,14 +42,12 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): if request.method in SAFE_METHODS or action in ("acknowledge", "download"): return bool(request.user and request.user.is_authenticated) - # CREATE → разрешаем всем аутентифицированным (документы создаются в draft) - # FSM workflow обеспечит контроль: draft → review → approve → publish + # CREATE → разрешаем всем аутентифицированным (документы создаются неопубликованными) if action == "create": return bool(request.user and request.user.is_authenticated) - # FSM actions, update/destroy и related documents - проверяем на уровне объекта - # Разрешаем здесь, has_object_permission проверит django-rules - if action in self.FSM_ACTIONS or action in ("update", "partial_update", "destroy", "add_related", "remove_related"): + # update/destroy и related documents - проверяем на уровне объекта + if action in ("update", "partial_update", "destroy", "add_related", "remove_related", "revert"): return bool(request.user and request.user.is_authenticated) # небезопасные → staff ИЛИ стандартные модельные права (add/change/delete) @@ -80,22 +66,8 @@ class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): if user.is_staff or _has_any_model_perm(user, "documents", "document"): return True - # FSM actions → проверяем специфичные django-rules права (разделение обязанностей!) - if action == "submit_for_review": - return user.has_perm("documents.submit_for_review_document", obj) - elif action == "approve": - return user.has_perm("documents.approve_document", obj) - elif action == "publish": - return user.has_perm("documents.publish_document", obj) - elif action == "reject": - return user.has_perm("documents.reject_document", obj) - elif action in ("archive", "unarchive"): - return user.has_perm("documents.archive_document", obj) - elif action == "return_to_draft": - # Вернуть в draft может автор или менеджер - return user.has_perm("documents.change_document", obj) - elif action == "revert": - # Откатить версию может автор или менеджер + # revert - откатить версию может автор или менеджер + if action == "revert": return user.has_perm("documents.change_document", obj) # add_related/remove_related → проверяем django-rules change_document diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index e8a2e5e9..341ad590 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -133,29 +133,6 @@ class FolderBriefSerializer(serializers.Serializer): name = serializers.CharField(read_only=True) -class DocumentTypeSerializer(serializers.Serializer): - """Сериализатор для типов документов.""" - id = serializers.IntegerField(read_only=True) - name = serializers.CharField() - code = serializers.SlugField() - description = serializers.CharField(required=False, allow_blank=True) - icon = serializers.CharField(required=False) - color = serializers.CharField(required=False) - is_active = serializers.BooleanField(default=True) - - def create(self, validated_data): - """Создаёт новый тип документа.""" - from documents.models import DocumentType - return DocumentType.objects.create(**validated_data) - - def update(self, instance, validated_data): - """Обновляет тип документа.""" - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - return instance - - class DocumentTagSerializer(serializers.Serializer): """Сериализатор для тегов документов.""" id = serializers.IntegerField(read_only=True) @@ -193,48 +170,6 @@ class DocumentTagSerializer(serializers.Serializer): return instance -class CabinetSerializer(serializers.Serializer): - """Сериализатор для кабинетов (виртуальных коллекций).""" - id = serializers.IntegerField(read_only=True) - name = serializers.CharField() - slug = serializers.SlugField(required=False) - description = serializers.CharField(required=False, allow_blank=True) - parent_id = serializers.IntegerField(source='parent.id', required=False, allow_null=True) - icon = serializers.CharField(required=False) - color = serializers.CharField(required=False) - document_count = serializers.SerializerMethodField() - created_by = EmployeeBriefSerializer(read_only=True) - created_at = serializers.DateTimeField(read_only=True) - - def get_document_count(self, obj) -> int: - """Возвращает количество документов в кабинете.""" - return obj.documents.count() if hasattr(obj, 'documents') else 0 - - def create(self, validated_data): - """Создаёт новый кабинет.""" - from documents.models import Cabinet - # Обработка parent_id - parent_data = validated_data.pop('parent', {}) - parent_id = parent_data.get('id') - parent = None - if parent_id: - parent = Cabinet.objects.get(id=parent_id) - return Cabinet.objects.create(parent=parent, **validated_data) - - def update(self, instance, validated_data): - """Обновляет кабинет.""" - # Обработка parent_id - parent_data = validated_data.pop('parent', None) - if parent_data is not None: - parent_id = parent_data.get('id') - instance.parent = Cabinet.objects.get(id=parent_id) if parent_id else None - - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - return instance - - class VersionSerializer(serializers.Serializer): """Сериализатор для версий документа (django-reversion).""" id = serializers.IntegerField(read_only=True) @@ -319,14 +254,11 @@ class DocumentReadSerializer(serializers.ModelSerializer): departments = DepartmentBriefSerializer(many=True, read_only=True) folder = FolderBriefSerializer(read_only=True) folder_path = serializers.CharField(read_only=True) - document_type = DocumentTypeSerializer(read_only=True) tags = DocumentTagSerializer(many=True, read_only=True) file_url = FilerFileSerializerField(source="file", read_only=True) file_name = serializers.CharField(source='file.name', read_only=True, allow_null=True) file_size = serializers.IntegerField(source='file.size', read_only=True, allow_null=True) is_acknowledged = serializers.SerializerMethodField() - status = serializers.CharField(source='get_status_display', read_only=True) - status_code = serializers.CharField(source='status', read_only=True) class Meta: model = Document @@ -337,10 +269,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): "extracted_text", # Для полнотекстового поиска "folder", # Папка документа "folder_path", # Полный путь папки - "document_type", # Тип документа "tags", # Теги - "status", # Человекочитаемый статус - "status_code", # Код статуса для программной обработки "created_by", # Создатель документа "created_at", # Дата создания "uploaded_by", @@ -386,6 +315,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): - sent_to_all - department_ids (отделы-получатели) - recipient_ids (repeat/JSON/CSV) при sent_to_all=false + - tag_ids (теги) """ recipient_ids = RecipientIDsField( @@ -400,6 +330,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer): help_text="Список ID отделов-получателей.", ) + tag_ids = RecipientIDsField( + write_only=True, + required=False, + help_text="Список ID тегов для привязки к документу.", + ) + file = FilerFileSerializerField( required=False, allow_null=True, @@ -413,11 +349,13 @@ class DocumentWriteSerializer(serializers.ModelSerializer): "title", "description", "file", + "folder", "extracted_text", "sent_to_all", "acknowledgement_required", "department_ids", "recipient_ids", + "tag_ids", ) def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: @@ -516,6 +454,38 @@ class DocumentWriteSerializer(serializers.ModelSerializer): ) return count + def _set_tags(self, doc: Document, tag_ids: Sequence[int]) -> int: + """Привязывает теги по списку ID. + + Args: + doc (Document): Документ. + tag_ids (Sequence[int]): Идентификаторы тегов. + + Returns: + int: Количество привязанных тегов. + """ + from documents.models import DocumentTag + + logger.info( + f"[serializers] _set_tags doc={doc.id} tag_ids={list(tag_ids)}" + ) + + if not tag_ids: + doc.tags.clear() + logger.info(f"[serializers] Cleared tags for doc={doc.id}") + return 0 + + tags = DocumentTag.objects.filter(id__in=set(tag_ids)) + count = tags.count() + logger.info( + f"[serializers] Found {count} tags from {len(tag_ids)} " + f"requested IDs" + ) + + doc.tags.set(tags) + logger.info(f"[serializers] Set {count} tags for doc={doc.id}") + return count + def create(self, validated_data: Dict[str, Any]) -> Document: """Создание документа с корректной установкой получателей и уведомлением. @@ -527,6 +497,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): """ recipient_ids = validated_data.pop("recipient_ids", []) department_ids = validated_data.pop("department_ids", []) + tag_ids = validated_data.pop("tag_ids", []) request = self.context.get("request") uploader = getattr(request, "user", None) sent_to_all = validated_data.get("sent_to_all", True) @@ -534,7 +505,8 @@ class DocumentWriteSerializer(serializers.ModelSerializer): logger.info( f"[serializers] Creating document sent_to_all={sent_to_all} " f"department_ids={list(department_ids)} " - f"recipient_ids={list(recipient_ids)}" + f"recipient_ids={list(recipient_ids)} " + f"tag_ids={list(tag_ids)}" ) # Создаём документ - сигналы из notification_signals.py сработают автоматически @@ -559,6 +531,11 @@ class DocumentWriteSerializer(serializers.ModelSerializer): ) self._set_recipients(doc, recipient_ids) + # Устанавливаем теги (независимо от sent_to_all) + if tag_ids: + logger.info(f"[serializers] Setting tags for doc={doc.id}") + self._set_tags(doc, tag_ids) + logger.info(f"[serializers] Document creation complete id={doc.id}") return doc @@ -574,8 +551,9 @@ class DocumentWriteSerializer(serializers.ModelSerializer): """ recipient_ids = validated_data.pop("recipient_ids", None) department_ids = validated_data.pop("department_ids", None) + tag_ids = validated_data.pop("tag_ids", None) - for f in ("title", "description", "sent_to_all", "file"): + for f in ("title", "description", "sent_to_all", "file", "folder"): if f in validated_data: setattr(instance, f, validated_data[f]) instance.save() @@ -589,5 +567,9 @@ class DocumentWriteSerializer(serializers.ModelSerializer): self._set_departments(instance, department_ids) if recipient_ids is not None: self._set_recipients(instance, recipient_ids) + + # Обновляем теги (независимо от sent_to_all) + if tag_ids is not None: + self._set_tags(instance, tag_ids) return instance diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index bb8e5c70..5c9874e0 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -10,8 +10,6 @@ from documents.models import ( Document, DocumentAcknowledgement, DocumentTag, - DocumentType, - Cabinet, DocumentComment, ) from easy_thumbnails.files import get_thumbnailer @@ -35,8 +33,6 @@ from .serializers import ( VersionSerializer, ActivityItemSerializer, DocumentTagSerializer, - DocumentTypeSerializer, - CabinetSerializer, DocumentCommentSerializer, ) @@ -125,21 +121,6 @@ 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( @@ -265,115 +246,6 @@ class DocumentViewSet(ModelViewSet): } ) - # ------------------------------------------------------------------------- - # FSM WORKFLOW ACTIONS - # ------------------------------------------------------------------------- - - @action(detail=True, methods=['post'], url_path='submit-for-review') - def submit_for_review(self, request, pk=None): - """Отправить документ на рассмотрение (draft → in_review).""" - document = self.get_object() - try: - document.submit_for_review() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['post']) - def approve(self, request, pk=None): - """Одобрить документ (in_review → approved).""" - document = self.get_object() - try: - document.approve() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['post']) - def reject(self, request, pk=None): - """Отклонить документ (in_review → rejected).""" - document = self.get_object() - try: - document.reject() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['post']) - def publish(self, request, pk=None): - """Опубликовать документ (approved → published).""" - document = self.get_object() - try: - document.publish() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['post'], url_path='return-to-draft') - def return_to_draft(self, request, pk=None): - """Вернуть документ в черновики.""" - document = self.get_object() - try: - document.return_to_draft() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['post']) - def archive(self, request, pk=None): - """Архивировать документ (published → archived).""" - document = self.get_object() - try: - document.archive() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['post']) - def unarchive(self, request, pk=None): - """Разархивировать документ (archived → published).""" - document = self.get_object() - try: - document.unarchive() - document.save() - serializer = self.get_serializer(document) - return Response(serializer.data) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - # ------------------------------------------------------------------------- # DJANGO-REVERSION ENDPOINTS # ------------------------------------------------------------------------- @@ -933,166 +805,4 @@ class DocumentTagViewSet(ModelViewSet): return Response(serializer.data) -class DocumentTypeViewSet(ModelViewSet): - """CRUD для типов документов. - - Типы определяют структуру метаданных и workflow для документов. - """ - queryset = DocumentType.objects.filter(is_active=True) - serializer_class = DocumentTypeSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - """Возвращает типы документов. - - Параметры: - ?include_inactive=true - показать неактивные типы - """ - qs = super().get_queryset() - - # Опционально показываем неактивные - include_inactive = self.request.query_params.get('include_inactive', '').lower() == 'true' - if include_inactive: - qs = DocumentType.objects.all() - - # Поиск - search = self.request.query_params.get('search', '') - if search: - qs = qs.filter( - Q(name__icontains=search) | - Q(code__icontains=search) | - Q(description__icontains=search) - ) - - return qs.order_by('order', 'name') - - @action(detail=True, methods=['get']) - def documents(self, request, pk=None): - """Получить все документы этого типа.""" - doc_type = self.get_object() - documents = Document.objects.filter(document_type=doc_type) - serializer = DocumentReadSerializer( - documents, - many=True, - context={'request': request} - ) - return Response(serializer.data) - - -class CabinetViewSet(ModelViewSet): - """CRUD для кабинетов (виртуальных коллекций документов). - - Кабинеты позволяют организовать документы в виртуальные коллекции - независимо от физической структуры папок. - """ - queryset = Cabinet.objects.all() - serializer_class = CabinetSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - """Возвращает кабинеты с поддержкой иерархии. - - Параметры: - ?parent_id= - показать только дочерние кабинеты - ?root=true - показать только корневые кабинеты - """ - qs = super().get_queryset() - - # Аннотируем количество документов - qs = qs.annotate(document_count=Count('documents')) - - parent_id = self.request.query_params.get('parent_id') - root = self.request.query_params.get('root', '').lower() == 'true' - - if root: - qs = qs.filter(parent__isnull=True) - elif parent_id: - qs = qs.filter(parent_id=parent_id) - - # Поиск - search = self.request.query_params.get('search', '') - if search: - qs = qs.filter( - Q(name__icontains=search) | - Q(description__icontains=search) - ) - - return qs.order_by('order', 'name') - - @transaction.atomic - def perform_create(self, serializer): - """Автоматически устанавливаем created_by при создании.""" - serializer.save(created_by=self.request.user) - - @action(detail=True, methods=['get']) - def children(self, request, pk=None): - """Получить дочерние кабинеты.""" - cabinet = self.get_object() - children = cabinet.children.all().order_by('order', 'name') - serializer = self.get_serializer(children, many=True) - return Response(serializer.data) - - @action(detail=True, methods=['get']) - def documents(self, request, pk=None): - """Получить документы в этом кабинете.""" - cabinet = self.get_object() - documents = cabinet.documents.all() - serializer = DocumentReadSerializer( - documents, - many=True, - context={'request': request} - ) - return Response(serializer.data) - - @action(detail=True, methods=['post']) - def add_document(self, request, pk=None): - """Добавить документ в кабинет. - - Body: - {"document_id": 123} - """ - cabinet = self.get_object() - document_id = request.data.get('document_id') - - if not document_id: - return Response( - {'error': 'document_id обязателен'}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - document = Document.objects.get(pk=document_id) - cabinet.documents.add(document) - return Response({'status': 'added'}) - except Document.DoesNotExist: - return Response( - {'error': 'Документ не найден'}, - status=status.HTTP_404_NOT_FOUND - ) - - @action(detail=True, methods=['post']) - def remove_document(self, request, pk=None): - """Удалить документ из кабинета. - - Body: - {"document_id": 123} - """ - cabinet = self.get_object() - document_id = request.data.get('document_id') - - if not document_id: - return Response( - {'error': 'document_id обязателен'}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - document = Document.objects.get(pk=document_id) - cabinet.documents.remove(document) - return Response({'status': 'removed'}) - except Document.DoesNotExist: - return Response( - {'error': 'Документ не найден'}, - status=status.HTTP_404_NOT_FOUND - ) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 6b305e01..5c89063c 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -16,8 +16,6 @@ from .documents.views import ( DocumentViewSet, FolderViewSet, DocumentTagViewSet, - DocumentTypeViewSet, - CabinetViewSet, DocumentCommentViewSet, ) from .employees.views import ( @@ -50,8 +48,6 @@ router.register(r"schedule/relations", ScheduleEventRelationViewSet, basename="s router.register(r"documents", DocumentViewSet, basename="documents") router.register(r"folders", FolderViewSet, basename="folders") router.register(r"document-tags", DocumentTagViewSet, basename="document-tags") -router.register(r"document-types", DocumentTypeViewSet, basename="document-types") -router.register(r"cabinets", CabinetViewSet, basename="cabinets") router.register(r"document-comments", DocumentCommentViewSet, basename="document-comments") router.register(r"requests", RequestViewSet, basename="request") diff --git a/backend/documents/models.py b/backend/documents/models.py index 88dcc01c..368322dc 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -12,7 +12,6 @@ from django.utils.translation import gettext_lazy as _ from django.conf import settings from django.utils import timezone from filer.fields.file import FilerFileField -from django_fsm import FSMField, transition import reversion User = settings.AUTH_USER_MODEL # 'employees.Employee' @@ -21,7 +20,7 @@ User = settings.AUTH_USER_MODEL # 'employees.Employee' @reversion.register() # Включаем версионирование для документов class Document(models.Model): """ - Документ с использованием django-filer для управления файлами и django-fsm для workflow. + Документ с использованием django-filer для управления файлами. Преимущества: - Поддержка папок и вложенной структуры @@ -29,34 +28,8 @@ class Document(models.Model): - Полнотекстовый поиск по содержимому файлов - Информация о размере, MIME-типе, метаданные - История версий через django-reversion - - Workflow управление через django-fsm - - Состояния документа (FSM): - - draft: Черновик (по умолчанию) - - in_review: На рассмотрении - - approved: Одобрен - - published: Опубликован (отправлен получателям) - - archived: Архивирован - - rejected: Отклонен """ - # FSM: Состояние документа - class Status(models.TextChoices): - DRAFT = 'draft', _('Черновик') - IN_REVIEW = 'in_review', _('На рассмотрении') - APPROVED = 'approved', _('Одобрен') - PUBLISHED = 'published', _('Опубликован') - ARCHIVED = 'archived', _('Архивирован') - REJECTED = 'rejected', _('Отклонен') - - status = FSMField( - default=Status.DRAFT, - choices=Status.choices, - verbose_name=_('Статус'), - protected=True, # Изменяется только через transitions - help_text=_('Текущее состояние документа в workflow') - ) - title = models.CharField(_('Название'), max_length=255) # Используем FilerFileField вместо FileField @@ -82,17 +55,6 @@ class Document(models.Model): description = models.TextField(_('Описание'), blank=True) - # Тип документа (Договор, Приказ, Акт и т.д.) - document_type = models.ForeignKey( - 'DocumentType', - verbose_name=_('Тип документа'), - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='documents', - help_text=_('Тип документа определяет шаблон метаданных и workflow') - ) - # Full-text search - извлеченный текст (OCR или парсинг PDF/DOCX) extracted_text = models.TextField( _('Извлеченный текст'), @@ -161,71 +123,7 @@ class Document(models.Model): ) # ------------------------------------------------------------------------- - # FSM TRANSITIONS (переходы между состояниями) - # ------------------------------------------------------------------------- - - @transition(field=status, source=Status.DRAFT, target=Status.IN_REVIEW) - def submit_for_review(self): - """ - Отправить документ на рассмотрение. - Из: draft → в: in_review - """ - pass - - @transition(field=status, source=Status.IN_REVIEW, target=Status.APPROVED) - def approve(self): - """ - Одобрить документ. - Из: in_review → в: approved - """ - pass - - @transition(field=status, source=Status.IN_REVIEW, target=Status.REJECTED) - def reject(self): - """ - Отклонить документ. - Из: in_review → в: rejected - """ - pass - - @transition(field=status, source=Status.APPROVED, target=Status.PUBLISHED) - def publish(self): - """ - Опубликовать документ (отправка уведомлений получателям). - Из: approved → в: published - - При публикации срабатывают сигналы из notification_signals.py - """ - pass - - @transition( - field=status, - source=[Status.DRAFT, Status.IN_REVIEW], - target=Status.DRAFT - ) - def return_to_draft(self): - """ - Вернуть документ в черновики. - Из: draft, in_review → в: draft - """ - pass - - @transition(field=status, source=Status.PUBLISHED, target=Status.ARCHIVED) - def archive(self): - """ - Архивировать опубликованный документ. - Из: published → в: archived - """ - pass - - @transition(field=status, source=Status.ARCHIVED, target=Status.PUBLISHED) - def unarchive(self): - """ - Разархивировать документ. - Из: archived → в: published - """ - pass - + # ПУБЛИКАЦИЯ ДОКУМЕНТА # ------------------------------------------------------------------------- class Meta: diff --git a/backend/documents/rules.py b/backend/documents/rules.py index 28191c28..c0ad1c82 100644 --- a/backend/documents/rules.py +++ b/backend/documents/rules.py @@ -78,14 +78,6 @@ def is_same_department(user, document): return document.departments.filter(id=user.department.id).exists() -@rules.predicate -def is_not_document_uploader(user, document): - """Пользователь НЕ загрузил документ (для разделения обязанностей)""" - if document is None: - return True - return document.uploaded_by != user - - @rules.predicate def has_acknowledged_document(user, document): """Пользователь уже ознакомился с документом""" @@ -158,41 +150,6 @@ rules.add_rule( ) -# ----------------------------------------------------------------------------- -# FSM TRANSITIONS PERMISSIONS (разделение обязанностей) -# ----------------------------------------------------------------------------- - -# Submit for review: автор может отправить СВОЙ документ на рассмотрение -rules.add_rule( - 'documents.submit_for_review_document', - is_superuser | is_document_uploader | can_manage_documents -) - -# Approve: ТОЛЬКО менеджеры/superuser, НЕ автор (разделение обязанностей!) -rules.add_rule( - 'documents.approve_document', - is_superuser | (can_manage_documents & is_not_document_uploader) -) - -# Publish: ТОЛЬКО менеджеры/superuser, НЕ автор (разделение обязанностей!) -rules.add_rule( - 'documents.publish_document', - is_superuser | (can_manage_documents & is_not_document_uploader) -) - -# Reject: ТОЛЬКО менеджеры/superuser -rules.add_rule( - 'documents.reject_document', - is_superuser | can_manage_documents -) - -# Archive/Unarchive: менеджеры или автор может архивировать свой документ -rules.add_rule( - 'documents.archive_document', - is_superuser | is_document_uploader | can_manage_documents -) - - # ----------------------------------------------------------------------------- # PERMISSIONS ДЛЯ МОДЕЛЕЙ # ----------------------------------------------------------------------------- diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 8b08d2c8..186d3b42 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -100,7 +100,6 @@ def make_document( description: str = "desc", sent_to_all: bool = True, recipients: Iterable[User] | None = None, - status: str = Document.Status.PUBLISHED, ) -> Document: """Создаёт Document напрямую (минует API). @@ -110,7 +109,6 @@ def make_document( description (str): Описание. sent_to_all (bool): Признак рассылки всем. recipients (Iterable[User] | None): Конкретные получатели при sent_to_all=false. - status (str): Статус документа (по умолчанию PUBLISHED для видимости в тестах). Returns: Document: Созданная модель с прикреплённым файлом. @@ -125,7 +123,6 @@ 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) @@ -235,10 +232,9 @@ class TestAuthAndPermissions: format="multipart", ) assert resp.status_code in (200, 201), f"Expected 200/201, got {resp.status_code}: {resp.content}" - # Проверяем что документ создан в статусе draft + # Проверяем что документ создан doc_id = resp.json()["id"] doc = Document.objects.get(id=doc_id) - assert doc.status == Document.Status.DRAFT, f"Expected DRAFT status, got {doc.status}" assert doc.uploaded_by == u # object not intended for user @@ -494,12 +490,11 @@ class TestCreate: assert body["title"] == "User Created Document" assert body["sent_to_all"] is True - # Проверяем что документ создан в статусе DRAFT + # Проверяем что документ создан doc = Document.objects.get(pk=body["id"]) - assert doc.status == Document.Status.DRAFT assert doc.uploaded_by == regular_user - # Проверяем что обычный пользователь видит свой документ в любом статусе + # Проверяем что обычный пользователь видит свой документ detail_r = client.get(api_urls["detail"](doc.pk)) assert detail_r.status_code == 200 diff --git a/backend/tests/api/v1/documents/test_fsm_workflow.py b/backend/tests/api/v1/documents/test_fsm_workflow.py deleted file mode 100644 index b00e3c50..00000000 --- a/backend/tests/api/v1/documents/test_fsm_workflow.py +++ /dev/null @@ -1,518 +0,0 @@ -""" -Unit tests: Document FSM Workflow - -Проверяет все переходы FSM состояний документа через API. -""" - -import pytest -from django.contrib.auth.models import Permission -from django.urls import reverse -from rest_framework import status -from rest_framework.test import APIClient - -from documents.models import Document -from tests.api.v1.documents.test_documents_api import make_document - -pytestmark = pytest.mark.django_db - - -@pytest.fixture -def api_client(): - """API клиент.""" - return APIClient() - - -@pytest.fixture -def grant_permissions(db): - """Хелпер для выдачи прав.""" - def _grant(user, *perm_codenames): - perms = Permission.objects.filter(codename__in=perm_codenames) - user.user_permissions.add(*perms) - user.refresh_from_db() - return _grant - - -def make_document_with_status(uploaded_by, status): - """Создает документ с указанным статусом (обходит FSM protected). - - Args: - uploaded_by: Пользователь-автор - status: Нужный статус (строка) - - Returns: - Document: Документ с установленным статусом - """ - doc = make_document(uploaded_by=uploaded_by) - # Обходим FSM protected=True для тестов - Document.objects.filter(pk=doc.pk).update(status=status) - return Document.objects.get(pk=doc.pk) - - -class TestFSMTransitions: - """Тесты FSM переходов документа.""" - - def test_submit_for_review_draft_to_in_review( - self, api_client, make_user, grant_permissions - ): - """draft → in_review через submit_for_review.""" - user = make_user("author@example.com") - grant_permissions(user, 'change_document') - - doc = make_document_with_status(uploaded_by=user, status='draft') - assert doc.status == 'draft' - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'in_review' - - def test_approve_in_review_to_approved( - self, api_client, make_user, grant_permissions - ): - """in_review → approved через approve.""" - approver = make_user("approver@example.com") - grant_permissions(approver, 'change_document') - - doc = make_document_with_status(uploaded_by=approver, status='in_review') - - api_client.force_authenticate(user=approver) - url = reverse('api:v1:documents-approve', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'approved' - - def test_reject_in_review_to_rejected( - self, api_client, make_user, grant_permissions - ): - """in_review → rejected через reject.""" - approver = make_user("approver@example.com") - grant_permissions(approver, 'change_document') - - doc = make_document_with_status(uploaded_by=approver, status='in_review') - - api_client.force_authenticate(user=approver) - url = reverse('api:v1:documents-reject', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'rejected' - - def test_publish_approved_to_published( - self, api_client, make_user, grant_permissions - ): - """approved → published через publish.""" - publisher = make_user("publisher@example.com") - grant_permissions(publisher, 'change_document') - - doc = make_document_with_status(uploaded_by=publisher, status='approved') - - api_client.force_authenticate(user=publisher) - url = reverse('api:v1:documents-publish', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'published' - - def test_archive_published_to_archived( - self, api_client, make_user, grant_permissions - ): - """published → archived через archive.""" - archiver = make_user("archiver@example.com") - grant_permissions(archiver, 'change_document') - - doc = make_document_with_status(uploaded_by=archiver, status='published') - - api_client.force_authenticate(user=archiver) - url = reverse('api:v1:documents-archive', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'archived' - - def test_unarchive_archived_to_published( - self, api_client, make_user, grant_permissions - ): - """archived → published через unarchive.""" - unarchiver = make_user("unarchiver@example.com") - grant_permissions(unarchiver, 'change_document') - - doc = make_document_with_status(uploaded_by=unarchiver, status='archived') - - api_client.force_authenticate(user=unarchiver) - url = reverse('api:v1:documents-unarchive', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'published' - - def test_return_to_draft_in_review_to_draft( - self, api_client, make_user, grant_permissions - ): - """in_review → draft через return_to_draft.""" - editor = make_user("editor@example.com") - grant_permissions(editor, 'change_document') - - doc = make_document_with_status(uploaded_by=editor, status='in_review') - - api_client.force_authenticate(user=editor) - url = reverse('api:v1:documents-return-to-draft', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'draft' - - -class TestFSMInvalidTransitions: - """Тесты невалидных переходов FSM.""" - - def test_cannot_approve_draft( - self, api_client, make_user, grant_permissions - ): - """Нельзя approve документ в состоянии draft.""" - user = make_user("user@example.com") - grant_permissions(user, 'change_document') - - doc = make_document_with_status(uploaded_by=user, status='draft') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-approve', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'draft' # Не изменился - - def test_cannot_publish_draft( - self, api_client, make_user, grant_permissions - ): - """Нельзя publish документ в состоянии draft.""" - user = make_user("user@example.com") - grant_permissions(user, 'change_document') - - doc = make_document_with_status(uploaded_by=user, status='draft') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-publish', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'draft' - - def test_cannot_archive_draft( - self, api_client, make_user, grant_permissions - ): - """Нельзя archive документ в состоянии draft.""" - user = make_user("user@example.com") - grant_permissions(user, 'change_document') - - doc = make_document_with_status(uploaded_by=user, status='draft') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-archive', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_400_BAD_REQUEST - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'draft' - - -class TestFSMPermissions: - """Тесты проверки прав доступа для FSM действий.""" - - def test_submit_requires_change_permission( - self, api_client, make_user - ): - """submit_for_review требует change_document.""" - owner = make_user("owner@example.com") - user = make_user("user@example.com") - doc = make_document_with_status(uploaded_by=owner, status='draft') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - - # Без прав - 403 или 404 (зависит от queryset filtering) - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_404_NOT_FOUND - ] - - def test_approve_requires_change_permission( - self, api_client, make_user - ): - """approve требует change_document.""" - owner = make_user("owner@example.com") - user = make_user("user@example.com") - doc = make_document_with_status(uploaded_by=owner, status='in_review') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-approve', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_404_NOT_FOUND - ] - - def test_publish_requires_change_permission( - self, api_client, make_user - ): - """publish требует change_document.""" - owner = make_user("owner@example.com") - user = make_user("user@example.com") - doc = make_document_with_status(uploaded_by=owner, status='approved') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-publish', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_404_NOT_FOUND - ] - - def test_archive_requires_change_permission( - self, api_client, make_user - ): - """archive требует change_document.""" - owner = make_user("owner@example.com") - user = make_user("user@example.com") - doc = make_document_with_status(uploaded_by=owner, status='published') - - api_client.force_authenticate(user=user) - url = reverse('api:v1:documents-archive', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_404_NOT_FOUND - ] - - def test_unauthenticated_cannot_change_status( - self, api_client, make_user - ): - """Неаутентифицированный пользователь не может менять статус.""" - user = make_user("user@example.com") - doc = make_document_with_status(uploaded_by=user, status='draft') - - # Без аутентификации - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - - assert response.status_code == status.HTTP_401_UNAUTHORIZED - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'draft' - - -class TestFullWorkflowCycle: - """Тест полного цикла документа через все состояния.""" - - def test_complete_document_lifecycle( - self, api_client, make_user, grant_permissions - ): - """Полный жизненный цикл: draft → in_review → approved → published → archived → published.""" - user = make_user("admin@example.com") - grant_permissions(user, 'change_document') - api_client.force_authenticate(user=user) - - # 1. Создаем документ (draft) - doc = make_document_with_status(uploaded_by=user, status='draft') - assert doc.status == 'draft' - - # 2. Отправляем на проверку - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'in_review' - - # 3. Утверждаем - url = reverse('api:v1:documents-approve', kwargs={'pk': doc.id}) - response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'approved' - - # 4. Публикуем - url = reverse('api:v1:documents-publish', kwargs={'pk': doc.id}) - response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'published' - - # 5. Архивируем - url = reverse('api:v1:documents-archive', kwargs={'pk': doc.id}) - response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'archived' - - # 6. Разархивируем - url = reverse('api:v1:documents-unarchive', kwargs={'pk': doc.id}) - response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - doc = Document.objects.get(pk=doc.id) - assert doc.status == 'published' - - -# ============================================================================= -# DJANGO-RULES: Author Permissions Tests -# ============================================================================= - -@pytest.mark.django_db -class TestAuthorCanSubmitOwnDocument: - """Тесты проверки что автор может отправить СВОЙ документ на рассмотрение - через django-rules (без модельного права change_document).""" - - def test_author_can_submit_own_document_without_model_permission( - self, api_client, make_user - ): - """Автор БЕЗ модельного права change_document может отправить - свой документ на рассмотрение (через django-rules is_document_uploader).""" - from documents.models import Document - - author = make_user("author@example.com", staff=False) - - # Создаем документ в draft явно - doc = make_document(uploaded_by=author, status=Document.Status.DRAFT) - assert doc.status == Document.Status.DRAFT - assert doc.uploaded_by == author - - # Автор БЕЗ модельного права должен иметь возможность submit_for_review - # благодаря django-rules: is_document_uploader - api_client.force_authenticate(user=author) - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - - # Должно работать! - assert response.status_code == status.HTTP_200_OK, \ - f"Expected 200, got {response.status_code}. Author should be able to submit own document via django-rules" - - # Перечитываем из БД - doc = Document.objects.get(pk=doc.id) - assert doc.status == Document.Status.IN_REVIEW - - def test_regular_user_cannot_submit_others_document( - self, api_client, make_user - ): - """Обычный пользователь НЕ может отправить чужой документ на рассмотрение.""" - from documents.models import Document - - author = make_user("author@example.com") - other_user = make_user("other@example.com", staff=False) - - # Документ автора в draft - doc = make_document(uploaded_by=author, status=Document.Status.DRAFT) - assert doc.status == Document.Status.DRAFT - - # Другой пользователь не должен иметь доступ - api_client.force_authenticate(user=other_user) - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - - # 404 (документ не в queryset) или 403 (нет прав) - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_404_NOT_FOUND - ], f"Expected 403/404, got {response.status_code}" - - # Статус не должен измениться - doc = Document.objects.get(pk=doc.id) - assert doc.status == Document.Status.DRAFT, "Status should not change" - - def test_author_can_edit_own_draft_document( - self, api_client, make_user - ): - """Автор может редактировать свой черновик.""" - from documents.models import Document - - author = make_user("author@example.com", staff=False) - - # Создаем документ в draft - doc = make_document(uploaded_by=author, title='Original Title', status=Document.Status.DRAFT) - assert doc.title == 'Original Title' - assert doc.status == Document.Status.DRAFT - - api_client.force_authenticate(user=author) - url = reverse('api:v1:documents-detail', kwargs={'pk': doc.id}) - response = api_client.patch(url, {'title': 'Updated Title'}, format='json') - - assert response.status_code == status.HTTP_200_OK - - # Перечитываем документ из БД - doc = Document.objects.get(pk=doc.id) - assert doc.title == 'Updated Title' - - def test_author_cannot_approve_own_document( - self, api_client, make_user - ): - """КРИТИЧНО: Автор НЕ может одобрить свой документ (разделение обязанностей!)""" - from documents.models import Document - - author = make_user("author@example.com", staff=False) - - # Создаем документ и отправляем на рассмотрение - doc = make_document(uploaded_by=author, status=Document.Status.DRAFT) - - # Автор отправляет на рассмотрение - это ОК - api_client.force_authenticate(user=author) - url = reverse('api:v1:documents-submit-for-review', kwargs={'pk': doc.id}) - response = api_client.post(url) - assert response.status_code == status.HTTP_200_OK - - # Проверяем что статус изменился - doc = Document.objects.get(pk=doc.id) - assert doc.status == Document.Status.IN_REVIEW - - # Теперь автор пытается ОДОБРИТЬ свой документ - должно быть запрещено! - url = reverse('api:v1:documents-approve', kwargs={'pk': doc.id}) - response = api_client.post(url) - - # Должен получить 403 ИЛИ 400 (FSM не разрешает или нет прав) - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_400_BAD_REQUEST - ], f"Author should NOT be able to approve own document! Got {response.status_code}" - - # Статус НЕ должен измениться - doc = Document.objects.get(pk=doc.id) - assert doc.status == Document.Status.IN_REVIEW, "Status should remain IN_REVIEW" - - def test_author_cannot_publish_own_document( - self, api_client, make_user - ): - """КРИТИЧНО: Автор НЕ может опубликовать свой документ (разделение обязанностей!)""" - from documents.models import Document - - author = make_user("author@example.com", staff=False) - - # Создаем документ в статусе APPROVED (как будто менеджер одобрил) - doc = make_document_with_status(uploaded_by=author, status='approved') - doc = Document.objects.get(pk=doc.id) - assert doc.status == Document.Status.APPROVED - - # Автор пытается ОПУБЛИКОВАТЬ - должно быть запрещено! - api_client.force_authenticate(user=author) - url = reverse('api:v1:documents-publish', kwargs={'pk': doc.id}) - response = api_client.post(url) - - # Должен получить 403 ИЛИ 400 - assert response.status_code in [ - status.HTTP_403_FORBIDDEN, - status.HTTP_400_BAD_REQUEST - ], f"Author should NOT be able to publish own document! Got {response.status_code}" - - # Статус НЕ должен измениться - doc = Document.objects.get(pk=doc.id) - assert doc.status == Document.Status.APPROVED, "Status should remain APPROVED" diff --git a/backend/tests/api/v1/documents/test_status_visibility.py b/backend/tests/api/v1/documents/test_status_visibility.py deleted file mode 100644 index 1b1ec0b6..00000000 --- a/backend/tests/api/v1/documents/test_status_visibility.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -Тесты видимости документов в зависимости от статуса. - -Проверяет, что обычные пользователи БЕЗ прав видят только: -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