Игорь Надеин 5390e514c3 **feat(api,front,tests): заявки — единый ответ на create, запрет удаления финальных, ACL для комментариев, базовый департамент-скоуп; фронт-вьюхи через backend/api/client.py; добавлены департаментные тесты**
Большое обновление по модулю заявок и интеграции с отделами.

## API (DRF)

* `requests_app.views.RequestViewSet`

  * `create()`: валидируем write-сериализатором, **отдаём read-сериализатор** → `employee` в ответе теперь объект, а не PK (чинит падение `TypeError: 'int' object is not subscriptable`).
  * `perform_create()`: для не-админов принудительно подставляем текущего пользователя.
  * `perform_update()`: явные проверки финальности и владения.
  * `destroy()`: подключён объектный пермишен `NotFinalOrStaff` через `get_permissions()` — **нельзя удалять финальные заявки** не-админам.
  * `get_permissions()`:

    * исправлены ветки `comments/approve/reject/cancel/destroy`, убран некорректный «битовый OR» классов пермишенов;
    * для `comments` — специальный пермишен (владелец без прав НЕ имеет доступа), соответствует тестам.
    * для `approve/reject/retrieve/comments` добавлена интеграция с `AdminOrDeptAllowed` (департаментные права) — основа для отдел-скоупа.
  * `get_queryset()`: добавлен **департаментный скоуп** — «свои» + заявки отделов, где у пользователя есть право `view_request` (через `EmployeeDepartment → role.scoped_permissions`).
  * `get_object()`: для `retrieve/approve/reject/comments/cancel` объект берётся из полного набора и затем прогоняется через `check_object_permissions()` → возвращаем **403, а не 404** при нехватке прав.
  * `pagination_class = None` — `list` стабильно возвращает массив, без обёртки `{count, results…}` (устраняет типовую ошибку парсинга в тесте).

* `requests_app.permissions`

  * `CommentsPermission`: политика «только staff/право отдела», **владелец без прав — 403**.
  * `NotFinalOrStaff`: объектный пермишен для запрета удаления финальных заявок.

* `requests_app.serializers`

  * `RequestWriteSerializer`:

    * делаем поля `employee/status/approver/department` **необязательными**;
    * в `to_internal_value()` для обычных пользователей **тихо удаляем** запрещённые поля до полевой валидации (чтобы не ловить 400 на несуществующих PK);
    * в `create()` для обычных пользователей проставляется текущий пользователь; статус берётся из `default` модели.

## Фронтенд (server-rendered views)

* Добавлены FBV, работающие через наш `backend/api/client.py`:

  * `my_requests`, `all_requests`, `request_create`, `request_detail`,
  * `request_process` (approve/reject), `request_cancel`,
  * `request_comment_add`, `request_comment_delete`.
* Проксируем аутентификацию, корректно отображаем ошибки API, поддерживаем multipart для вложений.

## Тесты

* Добавлен модуль `tests/api/v1/requests_app/test_requests_departments.py` с чек-листом интеграции заявок и отделов:

  * видимость по отделам, детальный просмотр, approve/reject по отдел-правам,
  * доступ к комментариям по отдел-правам,
  * валидация подмены `department` при создании.
* Базовые API-тесты теперь проходят:

  * `tests/api/v1/requests_app/test_requests_api.py` — **все зелёные**.
* Департаментные тесты **частично красные** (починим завтра):

  ```
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_viewer_cannot_retrieve_other_department_request - AssertionError: b'{"detail":"No Request matches the given query."}'
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_processor_can_approve_own_department_request - AssertionError: b'{"detail":"У вас недостаточно прав для данного действия."}'
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_comment_viewer_can_read_comments_in_department - AssertionError: b'{"detail":"Недостаточно прав для доступа к комментариям."}'
  FAILED tests/api/v1/requests_app/test_requests_departments.py::test_dept_comment_adder_can_post_in_department - AssertionError: b'{"detail":"Недостаточно прав для доступа к комментариям."}'
  ```

  Причины на текущем этапе:

  * `retrieve` местами ещё даёт 404 вместо 403 при отсутствии отдел-прав — добьём маршрут через `get_object()`/пермишены.
  * Для `approve/reject` и `comments` требуется довести проверку `AdminOrDeptAllowed(required_code=...)` до полной логики «И» по кодам и убедиться, что в тестовых фикстурах корректно создаются связки ролей/прав отдела.

## Прочее

* Мелкие чистки импортов и докстрингов; типизация (Google-style) по новым функциям/классам.

---

**Итог по прогону:**
`tests/api/v1/requests_app/` — **20 passed, 5 failed** (см. список выше). Базовые сценарии готовы, департаментные — почти готовы; добьём завтра строгую выдачу 403 и все ветки отдел-прав для `retrieve/approve/reject/comments`.
2025-09-16 16:36:39 +03:00

172 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 ..employees.serializers import EmployeeBriefSerializer
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
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=[AdminOrActionOrModelPerms]
)
def acknowledgements(self, request, pk=None):
"""Ведомость ознакомлений: доступна только staff/модельные права.
Поддерживает ?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:
base = base.filter(pk__in=doc.recipients.values_list("pk", flat=True))
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(),
},
}
)