Большое обновление по модулю заявок и интеграции с отделами.
## 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`.
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any, Mapping
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from requests_app.models import Request, RequestComment
|
||
from rest_framework import serializers
|
||
|
||
from ..employees.serializers import EmployeeBriefSerializer
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
|
||
class RequestReadSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор чтения заявки.
|
||
|
||
Включает краткую информацию о сотрудниках и вычисляемые поля.
|
||
"""
|
||
|
||
employee = EmployeeBriefSerializer(read_only=True)
|
||
approver = EmployeeBriefSerializer(read_only=True)
|
||
display_title = serializers.CharField(read_only=True)
|
||
is_final = serializers.BooleanField(read_only=True)
|
||
|
||
class Meta:
|
||
model = Request
|
||
fields = (
|
||
"id",
|
||
"employee",
|
||
"approver",
|
||
"department",
|
||
"type",
|
||
"title",
|
||
"date_from",
|
||
"date_to",
|
||
"comment",
|
||
"status",
|
||
"attachment",
|
||
"created_at",
|
||
"updated_at",
|
||
"decided_at",
|
||
"display_title",
|
||
"is_final",
|
||
)
|
||
read_only_fields = (
|
||
"employee",
|
||
"approver",
|
||
"status",
|
||
"created_at",
|
||
"updated_at",
|
||
"decided_at",
|
||
"display_title",
|
||
"is_final",
|
||
)
|
||
|
||
|
||
|
||
class RequestWriteSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор записи заявки.
|
||
|
||
Обычным пользователям запрещено передавать `employee`, `status`, `approver` —
|
||
они будут проигнорированы. Админы/обладатели прав могут их указывать.
|
||
|
||
Поле `employee` умышленно НЕ делаем read_only, чтобы админ мог его задать.
|
||
Для обычных пользователей оно подставится автоматически в `create()`/`perform_create()`.
|
||
|
||
Raises:
|
||
serializers.ValidationError: При некорректных данных (кроме запрещённых полей у обычных пользователей — они вычищаются).
|
||
"""
|
||
|
||
# Важно: снять обязательность, чтобы отсутствие полей не роняло валидацию
|
||
employee = serializers.PrimaryKeyRelatedField(
|
||
queryset=User.objects.all(), required=False, allow_null=True
|
||
)
|
||
status = serializers.ChoiceField(
|
||
choices=Request.STATUS_CHOICES, required=False, allow_null=True
|
||
)
|
||
approver = serializers.PrimaryKeyRelatedField(
|
||
queryset=User.objects.all(), required=False, allow_null=True
|
||
)
|
||
|
||
class Meta:
|
||
model = Request
|
||
fields = (
|
||
"id",
|
||
"employee",
|
||
"approver",
|
||
"department",
|
||
"type",
|
||
"title",
|
||
"date_from",
|
||
"date_to",
|
||
"comment",
|
||
"status",
|
||
"attachment",
|
||
)
|
||
read_only_fields = ("id",)
|
||
|
||
def _is_power(self) -> bool:
|
||
"""Определяет, имеет ли пользователь расширенные права на создание/изменение.
|
||
|
||
Returns:
|
||
bool: True для staff или имеющих модельные права; иначе False.
|
||
"""
|
||
user = self.context["request"].user
|
||
return bool(
|
||
getattr(user, "is_staff", False)
|
||
or user.has_perm("requests_app.add_request")
|
||
or user.has_perm("requests_app.change_request")
|
||
or user.has_perm("requests_app.can_process_requests")
|
||
)
|
||
|
||
def to_internal_value(self, data: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""Предвалидационная очистка входа.
|
||
|
||
Для обычного пользователя удаляет `employee`, `status`, `approver` до полевой валидации,
|
||
чтобы не получить 400 из-за, например, несуществующего PK в `employee`.
|
||
|
||
Args:
|
||
data (Mapping[str, Any]): Входные данные запроса.
|
||
|
||
Returns:
|
||
dict[str, Any]: Преобразованные данные для дальнейшей валидации.
|
||
"""
|
||
if not self._is_power():
|
||
# data может быть QueryDict — сделаем копию как обычный dict
|
||
data = dict(data)
|
||
data.pop("employee", None)
|
||
data.pop("status", None)
|
||
data.pop("approver", None)
|
||
return super().to_internal_value(data)
|
||
|
||
def create(self, validated_data: dict[str, Any]) -> Request:
|
||
"""Создание заявки.
|
||
|
||
Обычным пользователям проставляет текущего пользователя; статус берётся из default модели.
|
||
|
||
Args:
|
||
validated_data (dict[str, Any]): Провалидированные данные.
|
||
|
||
Returns:
|
||
Request: Созданная заявка.
|
||
"""
|
||
if not self._is_power():
|
||
validated_data["employee"] = self.context["request"].user
|
||
validated_data.pop("status", None)
|
||
validated_data.pop("approver", None)
|
||
return super().create(validated_data)
|
||
|
||
|
||
class RequestCommentSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор комментариев к заявке."""
|
||
|
||
author = EmployeeBriefSerializer(read_only=True)
|
||
|
||
class Meta:
|
||
model = RequestComment
|
||
fields = ("id", "request", "author", "text", "created_at")
|
||
read_only_fields = ("id", "author", "created_at", "request")
|