### Основное * Политика доступа для документов: * **CRUD** — только `is_staff` или обладатели модельных прав (`add/change/delete_document`). * **Чтение/скачивание/ACK** — любой **аутентифицированный**, но **только** свои документы (получатель активен) или если `sent_to_all=True`. * Для календаря: `GET /api/v1/calendar/events/` (компания) доступен авторизованным пользователям. ### permissions / views * Введён `DocumentReadOrModelPerms` на базе `DjangoModelPermissions`: * `GET/HEAD/OPTIONS` не требуют model-perms (только `IsAuthenticated`). * Объектно: staff/любой model-perm → доступ; иначе только `sent_to_all` или пользователь в `recipients(is_active=True)`. * Небезопасные методы требуют `add/change/delete_document` (или staff); **не требуем** дополнительно `view_document`. * `DocumentViewSet.get_queryset()` фильтрует **list** для пользователей без `view_document`: только `sent_to_all` и документы, где текущий пользователь — активный получатель. * Экшен `acknowledge` больше не переопределяет `permission_classes`; вызывается `self.get_object()` → срабатывает объектная проверка доступа. * (dev) Раздача медиа: убедились, что подключён `urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)` — скачивание по `file_url` отдаёт `200`. ### serializers * Добавлен `RecipientIDsField`: принимает `recipient_ids` в форматах **repeat-params / JSON-строка / CSV**, корректно читает `QueryDict.getlist()`. * `DocumentWriteSerializer`: * `file` теперь **необязателен** на PUT/PATCH, обязателен на POST (проверяется в `validate()`). * Валидация `sent_to_all`/`recipient_ids` учитывает текущее состояние при апдейте; пустой список при `sent_to_all=False` запрещён. * `_set_recipients()` назначает **только активных** пользователей и очищает список при `sent_to_all=True`. * `validate_file()` — отказ по лимиту размера (`DATA_UPLOAD_MAX_MEMORY_SIZE`) с 400. * `DocumentReadSerializer` — `file_url` через `FileField(source="file")`, `is_acknowledged` рассчитывается корректно. ### create response * В `DocumentViewSet.create()` рассчитываем `recipient_count` из **нормализованных входных ID** (до `save()`), считая только активных; при `sent_to_all=True` возвращаем `0`. ### employees * `EmployeeBriefSerializer` дополнен полем `full_name` (для тестов сериализации документов). ### Исправленные тесты/регрессии * `TestCreate::test_create_sent_to_all_false_with_recipients_variants` — теперь `recipient_count` корректен для repeat/JSON/CSV. * `TestUpdate::*` (PUT/PATCH) — больше не требуют `file`, возвращают `200`. * `TestDelete::test_delete_and_cascade_acknowledgements` — `delete_document` достаточно, `403` устранён. * `TestAcknowledge::test_ack_forbidden_if_not_recipient_and_not_sent_to_all` — теперь **403** для чужих документов с `sent_to_all=False`. * `TestSerialization::test_file_url_downloadable_in_dev` — `200` при скачивании `file_url` в dev. * `TestSecurityAndPolicy::test_policy_without_perms_list_is_200_detail_and_ack_require_access` — `list` даёт `200`, `detail/ack` чужого документа с `sent_to_all=False` дают `403`. * `calendar_app`: `TestAuthAndPermissions.test_company_get_ok_regular_user` — `GET` по компании даёт `200`. ### Затронутые файлы (основные) * `backend/api/v1/documents/permissions.py` (+ `DocumentReadOrModelPerms`) * `backend/api/v1/documents/serializers.py` (+ `RecipientIDsField`, правки `DocumentWriteSerializer`, `DocumentReadSerializer`) * `backend/api/v1/documents/views.py` (фильтрация `get_queryset()`, логика `create()`, `acknowledge`) * `backend/api/v1/employees/serializers.py` (+ `full_name`) * `eusrr_backend/urls.py` (проверка `static()` для MEDIA) * *(календарь)* `backend/api/v1/calendar/views.py` (`get_permissions()` для company list) --- **BREAKING**: ACK для документов с `sent_to_all=False` теперь доступен **только получателям** (или при наличии модельных прав/staff).
133 lines
5.3 KiB
Python
133 lines
5.3 KiB
Python
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
|
||
from documents.models import Document, DocumentAcknowledgement
|
||
from rest_framework import status
|
||
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 ..permissions import AdminOrActionOrModelPerms
|
||
from .permissions import DocumentReadOrModelPerms
|
||
from .serializers import DocumentReadSerializer, DocumentWriteSerializer
|
||
|
||
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 + аннотация флага ознакомления текущего пользователя.
|
||
|
||
Returns:
|
||
QuerySet[Document]: оптимизированный для списка без N+1.
|
||
"""
|
||
qs = super().get_queryset()
|
||
request = getattr(self, "request", None)
|
||
user = getattr(request, "user", None)
|
||
|
||
# Для аутентифицированных аннотируем флаг "я ознакомился"
|
||
if user and user.is_authenticated:
|
||
subq = DocumentAcknowledgement.objects.filter(
|
||
document=OuterRef("pk"), user=user
|
||
)
|
||
qs = qs.annotate(_is_acknowledged=Exists(subq))
|
||
return qs
|
||
|
||
@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
|
||
print(('input_recipient_ids', input_recipient_ids))
|
||
|
||
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})
|
||
|