240 lines
8.7 KiB
Python
240 lines
8.7 KiB
Python
"""Core document CRUD viewset."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.db import transaction
|
||
from django.db.models import Count, Exists, OuterRef, QuerySet
|
||
from rest_framework import status
|
||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||
from rest_framework.response import Response
|
||
from rest_framework.viewsets import ModelViewSet
|
||
|
||
from documents.models import (
|
||
Document,
|
||
DocumentAcknowledgement,
|
||
DocumentDisplayState,
|
||
)
|
||
|
||
from ..permissions import (
|
||
DocumentReadOrModelPerms,
|
||
filter_documents_for_audience,
|
||
filter_documents_for_user,
|
||
user_has_any_document_model_perm,
|
||
)
|
||
from ..presentation import attach_document_metrics, attach_linked_task_payloads
|
||
from ..serializers import DocumentReadSerializer, DocumentWriteSerializer
|
||
from .mixins import (
|
||
DocumentAcknowledgementMixin,
|
||
DocumentArchiveMixin,
|
||
DocumentCommentsMixin,
|
||
DocumentHistoryMixin,
|
||
DocumentMediaMixin,
|
||
DocumentRelationsMixin,
|
||
)
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
def _parse_integer(value):
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _parse_boolean(value):
|
||
normalized = str(value).strip().lower()
|
||
if normalized in {"1", "true", "yes", "on"}:
|
||
return True
|
||
if normalized in {"0", "false", "no", "off"}:
|
||
return False
|
||
return None
|
||
|
||
|
||
class DocumentViewSet(
|
||
DocumentArchiveMixin,
|
||
DocumentAcknowledgementMixin,
|
||
DocumentCommentsMixin,
|
||
DocumentHistoryMixin,
|
||
DocumentMediaMixin,
|
||
DocumentRelationsMixin,
|
||
ModelViewSet,
|
||
):
|
||
"""Полный CRUD по документам + экшен `acknowledge`.
|
||
|
||
Создание/обновление поддерживает multipart (для файла)
|
||
и назначение получателей.
|
||
"""
|
||
|
||
queryset: QuerySet[Document] = (
|
||
Document.objects.all()
|
||
.select_related("uploaded_by")
|
||
.prefetch_related(
|
||
"recipients",
|
||
"departments",
|
||
"acknowledgement_recipients",
|
||
"acknowledgement_departments",
|
||
)
|
||
)
|
||
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
|
||
|
||
@staticmethod
|
||
def _apply_query_filters(queryset, query_params):
|
||
folder_id = query_params.get("folder_id")
|
||
if folder_id is not None:
|
||
parsed_folder_id = _parse_integer(folder_id)
|
||
if parsed_folder_id is not None:
|
||
queryset = queryset.filter(folder_id=parsed_folder_id)
|
||
|
||
regulation = query_params.get("is_regulation")
|
||
if regulation is None:
|
||
regulation = query_params.get("regulation")
|
||
if regulation is not None:
|
||
parsed_regulation = _parse_boolean(regulation)
|
||
if parsed_regulation is not None:
|
||
queryset = queryset.filter(is_regulation=parsed_regulation)
|
||
return queryset
|
||
|
||
@staticmethod
|
||
def _annotate_user_state(queryset, user):
|
||
acknowledged = DocumentAcknowledgement.objects.filter(
|
||
document=OuterRef("pk"),
|
||
user=user,
|
||
)
|
||
maximally_hidden = DocumentDisplayState.objects.filter(
|
||
document=OuterRef("pk"),
|
||
user=user,
|
||
is_maximally_hidden=True,
|
||
)
|
||
return queryset.annotate(
|
||
_is_acknowledged=Exists(acknowledged),
|
||
_is_maximally_hidden=Exists(maximally_hidden),
|
||
)
|
||
|
||
def get_queryset(self) -> QuerySet[Document]:
|
||
"""Базовый queryset + аннотация флага ознакомления
|
||
текущего пользователя.
|
||
|
||
Поддерживает параметр ?scope=mine для фильтрации документов,
|
||
доступных текущему пользователю.
|
||
|
||
Поддерживает параметр ?folder_id для фильтрации по папке.
|
||
Поддерживает параметр ?is_regulation=true для раздела регламентов.
|
||
|
||
Returns:
|
||
QuerySet[Document]: оптимизированный для списка без N+1.
|
||
"""
|
||
queryset = super().get_queryset()
|
||
request = getattr(self, "request", None)
|
||
user = getattr(request, "user", None)
|
||
if request:
|
||
queryset = self._apply_query_filters(queryset, request.query_params)
|
||
|
||
if user and user.is_authenticated:
|
||
scope = request.query_params.get("scope", "").lower()
|
||
if scope == "mine":
|
||
queryset = filter_documents_for_audience(queryset, user)
|
||
elif not user.is_staff and not user_has_any_document_model_perm(user):
|
||
queryset = filter_documents_for_user(queryset, user)
|
||
queryset = self._annotate_user_state(queryset, user)
|
||
|
||
return queryset.order_by("-uploaded_at")
|
||
|
||
def _attach_linked_task_payloads(self, documents: list[Document]) -> None:
|
||
attach_linked_task_payloads(
|
||
documents,
|
||
user=getattr(self.request, "user", None),
|
||
)
|
||
|
||
def _attach_document_metrics(self, documents: list[Document]) -> None:
|
||
attach_document_metrics(documents)
|
||
|
||
def list(self, request, *args, **kwargs):
|
||
queryset = self.filter_queryset(self.get_queryset())
|
||
|
||
page = self.paginate_queryset(queryset)
|
||
if page is not None:
|
||
documents = list(page)
|
||
self._attach_linked_task_payloads(documents)
|
||
self._attach_document_metrics(documents)
|
||
serializer = self.get_serializer(documents, many=True)
|
||
return self.get_paginated_response(serializer.data)
|
||
|
||
documents = list(queryset)
|
||
self._attach_linked_task_payloads(documents)
|
||
self._attach_document_metrics(documents)
|
||
serializer = self.get_serializer(documents, many=True)
|
||
return Response(serializer.data)
|
||
|
||
def retrieve(self, request, *args, **kwargs):
|
||
document = self.get_object()
|
||
self._attach_linked_task_payloads([document])
|
||
self._attach_document_metrics([document])
|
||
serializer = self.get_serializer(document)
|
||
return Response(serializer.data)
|
||
|
||
@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()
|
||
|
||
self._attach_document_metrics([doc])
|
||
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()
|
||
|
||
self._attach_document_metrics([doc])
|
||
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)
|