refactor(documents): удаление FSM workflow и упрощение моделей

- Удалены поля document_type, status, published_at из модели Document
- Упрощены разрешения и правила доступа
- Удалены endpoint'ы для работы со статусами
- Удалены тесты FSM workflow
- Обновлены serializers для новой структуры модели
This commit is contained in:
lizerk 2026-03-06 12:21:11 +03:00
parent f2bff36f3c
commit dd4f99c244
9 changed files with 65 additions and 1343 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ДЛЯ МОДЕЛЕЙ
# -----------------------------------------------------------------------------

View File

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

View File

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

View File

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