from __future__ import annotations from typing import Any from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import Count, Exists, OuterRef, QuerySet, Q from django.http import FileResponse, Http404 from documents.models import ( Document, DocumentAcknowledgement, DocumentTag, ) from easy_thumbnails.files import get_thumbnailer from filer.models import Folder import reversion from reversion.models import Version from rest_framework import status, serializers as rest_serializers from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from ..employees.serializers import EmployeeBriefSerializer from ..permissions import AdminOrActionOrModelPerms from .permissions import DocumentReadOrModelPerms from .serializers import ( DocumentReadSerializer, DocumentWriteSerializer, FolderSerializer, VersionSerializer, ActivityItemSerializer, DocumentTagSerializer, ) User = get_user_model() class DocumentViewSet(ModelViewSet): """Полный CRUD по документам + экшен `acknowledge`. Создание/обновление поддерживает multipart (для файла) и назначение получателей. """ queryset: QuerySet[Document] = ( Document.objects.all() .select_related("uploaded_by") .prefetch_related("recipients") ) permission_classes = [DocumentReadOrModelPerms] parser_classes = (MultiPartParser, FormParser, JSONParser) def get_serializer_class(self): """Возвращает сериализатор в зависимости от действия. Returns: type: Класс сериализатора для чтения/записи. """ if self.action in ("create", "update", "partial_update"): return DocumentWriteSerializer return DocumentReadSerializer def get_queryset(self) -> QuerySet[Document]: """Базовый queryset + аннотация флага ознакомления текущего пользователя. Поддерживает параметр ?scope=mine для фильтрации документов, доступных текущему пользователю. Поддерживает параметр ?folder_id для фильтрации по папке. Returns: QuerySet[Document]: оптимизированный для списка без N+1. """ qs = super().get_queryset() request = getattr(self, "request", None) user = getattr(request, "user", None) # Параметр scope из запроса scope = "" if request: scope = request.query_params.get("scope", "").lower() # Фильтрация по папке folder_id = request.query_params.get("folder_id") if folder_id is not None: try: qs = qs.filter(folder_id=int(folder_id)) except (ValueError, TypeError): pass # Игнорируем некорректное значение # Если scope=mine - показываем только доступные пользователю if scope == "mine" and user and user.is_authenticated: # Документы для всех ИЛИ где пользователь получатель # ИЛИ в отделе ИЛИ автор qs = qs.filter( Q(sent_to_all=True) | Q(recipients=user) | Q(departments__employeedepartment__employee=user, departments__employeedepartment__is_active=True) | Q(uploaded_by=user) ).distinct() # Если нет НИКАКИХ модельных прав на документы - ограничиваем queryset elif (user and user.is_authenticated and not user.is_staff): # Проверяем наличие любого права на модель Document 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(sent_to_all=True) | Q(recipients=user) | Q(departments__employeedepartment__employee=user, departments__employeedepartment__is_active=True) | Q(uploaded_by=user) ).distinct() # Для аутентифицированных аннотируем флаг "я ознакомился" if user and user.is_authenticated: subq = DocumentAcknowledgement.objects.filter( document=OuterRef("pk"), user=user ) qs = qs.annotate(_is_acknowledged=Exists(subq)) return qs.order_by("-uploaded_at") @transaction.atomic def create(self, request, *args, **kwargs): """Создаёт документ и возвращает read-схему + recipient_count. recipient_count: - 0, если sent_to_all=True; - число активных пользователей из переданных recipient_ids (неактивные/несуществующие игнорируются). """ serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Сохраним нормализованные ID до save(), т.к. serializer.save() их вытаскивает. input_recipient_ids = list(serializer.validated_data.get("recipient_ids", [])) doc = serializer.save() read = DocumentReadSerializer(doc, context={"request": request}) data = dict(read.data) if doc.sent_to_all: data["recipient_count"] = 0 else: active_count = User.objects.filter( is_active=True, id__in=input_recipient_ids ).aggregate(n=Count("id", distinct=True))["n"] data["recipient_count"] = active_count return Response(data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): """Полная замена документа. Returns: Response: Тело ответа в формате read-сериализатора (с file_url). """ partial = kwargs.pop("partial", False) instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) doc = serializer.save() read = DocumentReadSerializer(doc, context={"request": request}) return Response(read.data, status=status.HTTP_200_OK) def partial_update(self, request, *args, **kwargs): """Частичное обновление (PATCH) — аналогично update, но partial=True.""" kwargs["partial"] = True return self.update(request, *args, **kwargs) @action(methods=["post"], detail=True) def acknowledge(self, request, pk=None): """Отметить ознакомление текущего пользователя с документом. Args: request: DRF Request pk: ID документа Returns: Response: {"ok": true, "already": bool} """ doc = self.get_object() obj, created = DocumentAcknowledgement.objects.get_or_create( document=doc, user=request.user ) return Response({"ok": True, "already": not created}) @action( detail=True, methods=["get"], permission_classes=[IsAuthenticated] ) def acknowledgements(self, request, pk=None): """Ведомость ознакомлений: доступна всем авторизованным пользователям. Поддерживает ?search= для фильтра и отдаёт непагинированно (или подключите пагинацию). """ doc = self.get_object() q = (request.query_params.get("search") or "").strip() base = User.objects.filter(is_active=True) if not doc.sent_to_all: # Собираем ID получателей из recipients и departments recipient_ids = set(doc.recipients.values_list("pk", flat=True)) # Добавляем активных сотрудников из отделов for department in doc.departments.all(): recipient_ids.update(emp.id for emp in department.active_employees) base = base.filter(pk__in=recipient_ids) if q: base = base.filter( Q(email__icontains=q) | Q(first_name__icontains=q) | Q(last_name__icontains=q) | Q(patronymic__icontains=q) ) acked_qs = base.filter(document_acknowledgements__document=doc) unacked_qs = base.exclude(pk__in=acked_qs.values("pk")) acked = EmployeeBriefSerializer( acked_qs, many=True, context={"request": request} ).data unacked = EmployeeBriefSerializer( unacked_qs, many=True, context={"request": request} ).data # при желании подключите PageNumberPagination и верните {"acknowledged": page1, "unacknowledged": page2} return Response( { "acknowledged": acked, "unacknowledged": unacked, "counts": { "acknowledged": acked_qs.count(), "unacknowledged": unacked_qs.count(), "total": base.count(), }, } ) # ------------------------------------------------------------------------- # DJANGO-REVERSION ENDPOINTS # ------------------------------------------------------------------------- @action(detail=True, methods=['get']) def versions(self, request, pk=None): """Получить историю версий документа. Returns: [ { "id": 1, "revision_id": 123, "date_created": "2026-02-28T10:00:00Z", "user": {"id": 1, "full_name": "Иванов Иван"}, "comment": "Изменено описание", "data": {"title": "...", "description": "..."} }, ... ] """ document = self.get_object() # Получаем все версии документа versions = Version.objects.get_for_object(document).select_related('revision', 'revision__user') serializer = VersionSerializer(versions, many=True) return Response(serializer.data) @action(detail=True, methods=['get']) def activity(self, request, pk=None): """Получить timeline активности документа (версии + аудит). Объединяет данные из: - reversion.Version (изменения документа) - DocumentAuditLog (аудит действий) - DocumentAcknowledgement (ознакомления) Returns: [ { "type": "version", "timestamp": "2026-02-28T10:00:00Z", "user": {"id": 1, "full_name": "Иванов Иван"}, "action": "Изменение документа", "details": {"comment": "...", "fields": ["title", "description"]} }, { "type": "audit", "timestamp": "2026-02-28T09:00:00Z", "user": {...}, "action": "Просмотрен", "details": {"ip_address": "192.168.1.1"} }, ... ] """ document = self.get_object() activity_items = [] # 1. Добавляем версии versions = Version.objects.get_for_object(document).select_related('revision', 'revision__user') for version in versions: user_data = None if version.revision.user: user = version.revision.user user_data = { 'id': user.id, 'full_name': f'{user.last_name} {user.first_name}'.strip(), 'avatar_url': getattr(user, 'avatar_url', None), } activity_items.append({ 'type': 'version', 'timestamp': version.revision.date_created, 'user': user_data, 'action': 'Изменение документа', 'details': { 'comment': version.revision.comment or '', 'version_id': version.id, 'revision_id': version.revision.id, } }) # 2. Добавляем аудит (если есть) if hasattr(document, 'audit_log'): audit_logs = document.audit_log.all().select_related('user')[:50] # Ограничиваем 50 записями for log in audit_logs: user_data = None if log.user: user_data = { 'id': log.user.id, 'full_name': f'{log.user.last_name} {log.user.first_name}'.strip(), 'avatar_url': getattr(log.user, 'avatar_url', None), } activity_items.append({ 'type': 'audit', 'timestamp': log.timestamp, 'user': user_data, 'action': log.get_action_display(), 'details': { 'ip_address': log.ip_address, 'metadata': log.metadata, } }) # 3. Добавляем ознакомления acknowledgements = DocumentAcknowledgement.objects.filter(document=document).select_related('user')[:50] for ack in acknowledgements: user_data = { 'id': ack.user.id, 'full_name': f'{ack.user.last_name} {ack.user.first_name}'.strip(), 'avatar_url': getattr(ack.user, 'avatar_url', None), } activity_items.append({ 'type': 'acknowledgement', 'timestamp': ack.acknowledged_at, 'user': user_data, 'action': 'Ознакомление', 'details': None }) # Сортируем по времени (новые сверху) activity_items.sort(key=lambda x: x['timestamp'], reverse=True) serializer = ActivityItemSerializer(activity_items, many=True) return Response(serializer.data) @action(detail=True, methods=['post']) def revert(self, request, pk=None): """Откатить документ к указанной версии. Body: { "version_id": 123, // ID версии из reversion.Version "comment": "Причина отката" // опционально } Returns: Обновленный документ Note: Поле status не откатывается, так как оно управляется через FSM transitions. ManyToMany поля (departments, recipients, related_documents) откатываются отдельно. """ document = self.get_object() version_id = request.data.get('version_id') comment = request.data.get('comment', '') if not version_id: return Response( {'error': 'version_id обязателен'}, status=status.HTTP_400_BAD_REQUEST ) try: # Получаем версию version = Version.objects.get(id=version_id, object_id=str(document.pk)) version_data = version.field_dict # Исключаем защищенные FSM поля и служебные поля excluded_fields = ['status', 'id', 'uploaded_at', 'modified_at', 'uploaded_by'] # ManyToMany поля нужно обрабатывать отдельно через .set() m2m_fields = ['departments', 'recipients', 'related_documents'] # Откатываем с комментарием with reversion.create_revision(): # Собираем обычные поля для восстановления updated_fields = [] for field, value in version_data.items(): if (field not in excluded_fields and field not in m2m_fields and hasattr(document, field)): setattr(document, field, value) updated_fields.append(field) # Сохраняем с автором отката (чтобы ID был доступен для M2M) document.modified_by = request.user updated_fields.append('modified_by') # ВАЖНО: Указываем update_fields, чтобы избежать проверки FSM protected=True document.save(update_fields=updated_fields) # Восстанавливаем ManyToMany поля for m2m_field in m2m_fields: if m2m_field in version_data: m2m_manager = getattr(document, m2m_field) m2m_value = version_data[m2m_field] # version_data хранит список ID if isinstance(m2m_value, list): m2m_manager.set(m2m_value) reversion.set_user(request.user) reversion.set_comment(comment or f'Откат к версии {version_id}') # Обновляем объект из базы document.refresh_from_db() serializer = self.get_serializer(document) return Response(serializer.data) except Version.DoesNotExist: return Response( {'error': 'Версия не найдена'}, status=status.HTTP_404_NOT_FOUND ) except Exception as e: return Response( {'error': str(e)}, status=status.HTTP_400_BAD_REQUEST ) @action(detail=True, methods=['get']) def thumbnail(self, request, pk=None): """Получить thumbnail документа. Query параметры: size: admin_thumbnail | small | medium | large (default: medium) Returns: Изображение thumbnail или 404 если документ не является изображением/PDF """ document = self.get_object() if not document.file: return Response( {'error': 'У документа нет файла'}, status=status.HTTP_404_NOT_FOUND ) size = request.query_params.get('size', 'medium') allowed_sizes = ['admin_thumbnail', 'small', 'medium', 'large'] if size not in allowed_sizes: return Response( {'error': f'Размер должен быть одним из: {", ".join(allowed_sizes)}'}, status=status.HTTP_400_BAD_REQUEST ) try: # Получаем thumbnailer для файла thumbnailer = get_thumbnailer(document.file.file) # Генерируем thumbnail используя alias из settings thumbnail = thumbnailer[size] # Возвращаем файл return FileResponse( open(thumbnail.path, 'rb'), content_type='image/jpeg' ) except Exception as e: return Response( {'error': f'Не удалось создать thumbnail: {str(e)}'}, status=status.HTTP_400_BAD_REQUEST ) # ------------------------------------------------------------------------- # RELATED DOCUMENTS ENDPOINTS # ------------------------------------------------------------------------- @action(detail=True, methods=['get']) def related(self, request, pk=None): """Получить связанные документы. Returns: Список связанных документов """ document = self.get_object() related = document.related_documents.all() serializer = DocumentReadSerializer( related, many=True, context={'request': request} ) return Response(serializer.data) @action(detail=True, methods=['post']) def add_related(self, request, pk=None): """Добавить связанный документ. Body: {"document_id": 123} Returns: {"status": "added"} """ document = self.get_object() related_id = request.data.get('document_id') if not related_id: return Response( {'error': 'document_id обязателен'}, status=status.HTTP_400_BAD_REQUEST ) if related_id == document.pk: return Response( {'error': 'Нельзя связать документ с самим собой'}, status=status.HTTP_400_BAD_REQUEST ) try: related_doc = Document.objects.get(pk=related_id) document.related_documents.add(related_doc) return Response({'status': 'added'}) except Document.DoesNotExist: return Response( {'error': 'Документ не найден'}, status=status.HTTP_404_NOT_FOUND ) @action(detail=True, methods=['post']) def remove_related(self, request, pk=None): """Удалить связанный документ. Body: {"document_id": 123} Returns: {"status": "removed"} """ document = self.get_object() related_id = request.data.get('document_id') if not related_id: return Response( {'error': 'document_id обязателен'}, status=status.HTTP_400_BAD_REQUEST ) try: related_doc = Document.objects.get(pk=related_id) document.related_documents.remove(related_doc) return Response({'status': 'removed'}) except Document.DoesNotExist: return Response( {'error': 'Документ не найден'}, status=status.HTTP_404_NOT_FOUND ) class FolderViewSet(ModelViewSet): """CRUD для папок документов (filer.Folder). Поддерживает иерархическую структуру папок с parent/children связями. """ queryset = Folder.objects.all() serializer_class = FolderSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """Возвращает папки, опционально фильтрованные по parent_id. Параметры: ?parent_id= - показать только дочерние папки указанного parent ?root=true - показать только корневые папки (parent=null) """ from django.db.models import Count qs = super().get_queryset() request = getattr(self, "request", None) # Аннотируем количество документов в каждой папке qs = qs.annotate(document_count=Count('documents')) if not request: return qs parent_id = request.query_params.get('parent_id') root = 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) return qs.order_by('name') @transaction.atomic def create(self, request, *args, **kwargs): """Создать папку. Body: { "name": "Название папки", "parent": | null // опционально } """ serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) # Создаем папку name = serializer.validated_data['name'] parent_id = request.data.get('parent') parent = None if parent_id: try: parent = Folder.objects.get(pk=parent_id) except Folder.DoesNotExist: return Response( {'error': 'Родительская папка не найдена'}, status=status.HTTP_404_NOT_FOUND ) folder = Folder.objects.create( name=name, parent=parent, owner=request.user ) result = FolderSerializer(folder).data return Response(result, status=status.HTTP_201_CREATED) @action(detail=True, methods=['get']) def children(self, request, pk=None): """Получить дочерние папки данной папки.""" folder = self.get_object() children = folder.children.all().order_by('name') serializer = self.get_serializer(children, many=True) return Response(serializer.data) @action(detail=True, methods=['get']) def documents(self, request, pk=None): """Получить документы в данной папке.""" folder = self.get_object() documents = Document.objects.filter(folder=folder) serializer = DocumentReadSerializer( documents, many=True, context={'request': request} ) return Response(serializer.data) class DocumentTagViewSet(ModelViewSet): """CRUD для тегов документов. Теги используются для категоризации документов. """ queryset = DocumentTag.objects.all() serializer_class = DocumentTagSerializer permission_classes = [IsAuthenticated] def get_queryset(self): """Возвращает теги, опционально с аннотацией количества документов.""" qs = super().get_queryset() # Аннотируем количество документов для каждого тега qs = qs.annotate(document_count=Count('documents')) # Поиск по названию search = self.request.query_params.get('search', '') if search: qs = qs.filter( Q(name__icontains=search) | Q(description__icontains=search) ) return qs.order_by('name') @action(detail=True, methods=['get']) def documents(self, request, pk=None): """Получить все документы с этим тегом.""" tag = self.get_object() documents = tag.documents.all() serializer = DocumentReadSerializer( documents, many=True, context={'request': request} ) return Response(serializer.data)