refactor(documents): удаление FSM workflow и упрощение моделей
- Удалены поля document_type, status, published_at из модели Document - Упрощены разрешения и правила доступа - Удалены endpoint'ы для работы со статусами - Удалены тесты FSM workflow - Обновлены serializers для новой структуры модели
This commit is contained in:
parent
f2bff36f3c
commit
dd4f99c244
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 ДЛЯ МОДЕЛЕЙ
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user