Corp/backend/api/v1/requests_app/permissions.py
Игорь Надеин 87ff7f8ebb **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

125 lines
4.6 KiB
Python

# api/v1/requests_app/permissions.py
from __future__ import annotations
from typing import Any, Optional
from api.v1.permissions import AdminOrDeptAllowed
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
class CommentsPermission(BasePermission):
"""Права доступа к экшену `comments`.
- GET/HEAD: staff или право `requests_app.view_requestcomment`.
- POST: staff или право `requests_app.add_requestcomment`.
- Владелец заявки без модельных прав доступа не имеет.
Raises:
Ничего не выбрасывает: при отказе возвращает False → DRF вернёт 403.
"""
message = "Недостаточно прав для доступа к комментариям."
def has_permission(self, request: Request, view: Any) -> bool:
"""Проверка уровня запроса.
Args:
request (Request): Текущий запрос.
view (Any): Текущий вьюсет.
Returns:
bool: True, если пользователь аутентифицирован.
"""
return bool(getattr(request.user, "is_authenticated", False))
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
"""Объектная проверка на уровне конкретной заявки.
Args:
request (Request): Текущий запрос.
view (Any): Текущий вьюсет.
obj (Any): Экземпляр заявки (EmployeeRequest).
Returns:
bool: Разрешение/запрет.
"""
user = request.user
if getattr(user, "is_staff", False):
return True
if request.method in {"GET", "HEAD"}:
return user.has_perm("requests_app.view_requestcomment")
if request.method == "POST":
return user.has_perm("requests_app.add_requestcomment")
return False
class NotFinalOrStaff(BasePermission):
"""Запрещает удаление финальной заявки для не-админов.
- Разрешает любые методы, кроме DELETE, без ограничений.
- Для DELETE: разрешено, если заявка не финальная, либо пользователь — staff.
Атрибуты:
message (str): Текст ошибки для 403 Forbidden.
Notes:
Пермишены не выбрасывают исключения — они возвращают False,
что приводит к 403 Forbidden.
"""
message = "Финальная заявка не может быть удалена."
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
"""Объектная проверка.
Args:
request (Request): Текущий запрос DRF.
view (Any): Экземпляр вьюсета.
obj (Any): Экземпляр удаляемого объекта (заявки).
Returns:
bool: True, если доступ разрешён; иначе False.
"""
if request.method != "DELETE":
return True
is_final = bool(getattr(obj, "is_final", False))
is_staff = bool(getattr(request.user, "is_staff", False))
return (not is_final) or is_staff
class DeptViewRequest(AdminOrDeptAllowed):
"""Чтение заявки: staff ИЛИ право отдела 'view_request'."""
required_code = "view_request"
class DeptCanProcess(AdminOrDeptAllowed):
"""Обработка заявок: staff ИЛИ право отдела 'can_process_requests'."""
required_code = "can_process_requests"
class DeptChangeRequest(AdminOrDeptAllowed):
"""Правка заявок: staff ИЛИ право отдела 'change_request'."""
required_code = "change_request"
class DeptComments(AdminOrDeptAllowed):
"""Комментарии: staff ИЛИ право отдела
- GET/HEAD: 'view_requestcomment'
- POST: 'add_requestcomment'
"""
def _required_code(self, view: Any, request: Request) -> Optional[str]:
# Метод-чувствительная логика: GET/HEAD → view, POST → add
if request.method in {"GET", "HEAD"}:
return "view_requestcomment"
if request.method == "POST":
return "add_requestcomment"
return None # другие методы не поддерживаем