**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`.
This commit is contained in:
parent
751d65d2f4
commit
87ff7f8ebb
@ -89,7 +89,6 @@ class DocumentViewSet(ModelViewSet):
|
||||
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)
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# backend\api\v1\permissions.py
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Iterable, Optional, Tuple
|
||||
|
||||
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
|
||||
from employees.constants import DeptPerm
|
||||
@ -17,7 +17,7 @@ CHANGE_HEAD_PERM = "change_department_head"
|
||||
ASSIGN_ROLE_PERM = "assign_department_role"
|
||||
|
||||
|
||||
def user_is_dept_head(user, dept: Department) -> bool:
|
||||
def user_is_dept_head(user, dept: Department) -> bool:
|
||||
return bool(
|
||||
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
|
||||
)
|
||||
@ -238,12 +238,7 @@ class AdminOrDeptAllowed(BasePermission):
|
||||
def has_permission(self, request, view) -> bool:
|
||||
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
|
||||
откладываем проверку до object-level (вернём True)."""
|
||||
print(
|
||||
view.action,
|
||||
view.kwargs,
|
||||
self.get_required_code(request, view),
|
||||
self._to_int_or_none(self._extract_dept_id_from_request(request, view)),
|
||||
)
|
||||
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
@ -351,110 +346,125 @@ class IsSelfOrStaff(BasePermission):
|
||||
|
||||
|
||||
class AdminOrActionOrModelPerms(DjangoModelPermissions):
|
||||
"""Комбинированная политика доступа для документов.
|
||||
|
||||
Поведение:
|
||||
- staff/superuser → полный доступ (всегда True).
|
||||
- Если на вью задан `required_perm_code` или есть карта
|
||||
`required_perms_by_action[action]`, проверяем её через `user.has_perm(...)`.
|
||||
- Иначе:
|
||||
* для SAFE-методов (`GET/HEAD/OPTIONS`) требуем `view_<model>`;
|
||||
* для остальных методов используем стандартную карту `DjangoModelPermissions`
|
||||
(`add/change/delete`).
|
||||
|
||||
Raises:
|
||||
exceptions.MethodNotAllowed: если передан неизвестный HTTP-метод.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
"""Проверка доступа на уровне запроса.
|
||||
@staticmethod
|
||||
def _normalize_codes(value: Any) -> Optional[list[str]]:
|
||||
"""Приводит конфигурацию кодов прав к списку строк.
|
||||
|
||||
Args:
|
||||
request: DRF Request.
|
||||
view: Текущий ViewSet.
|
||||
value (Any): Значение из конфигурации прав:
|
||||
- str — один код,
|
||||
- Iterable[str] — несколько кодов,
|
||||
- None — отсутствует.
|
||||
|
||||
Returns:
|
||||
Optional[list[str]]: Список кодов или None.
|
||||
|
||||
Raises:
|
||||
TypeError: Если найден нестроковый элемент в Iterable.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
v = value.strip()
|
||||
return [v] if v else None
|
||||
if isinstance(value, Iterable):
|
||||
out: list[str] = []
|
||||
for v in value:
|
||||
if not isinstance(v, str):
|
||||
raise TypeError("Коды прав должны быть строками.")
|
||||
v = v.strip()
|
||||
if v:
|
||||
out.append(v)
|
||||
return out or None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _explicit_perm_codes(view) -> Optional[list[str]]:
|
||||
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
|
||||
|
||||
Поддерживаемые формы на ViewSet:
|
||||
- required_perm_code: str | Iterable[str]
|
||||
- required_perms_by_action[action]: str | Iterable[str]
|
||||
- required_perms_by_action[action]: {method: str|Iterable[str]}
|
||||
где method сравнивается без учёта регистра, возможны '*', 'ANY',
|
||||
а HEAD трактуется как GET.
|
||||
|
||||
Args:
|
||||
view: DRF ViewSet с полями `action`, `request` (опционально).
|
||||
|
||||
Returns:
|
||||
Optional[list[str]]: Список кодов прав или None.
|
||||
"""
|
||||
# 1) Единый код(ы) на всю вью
|
||||
codes = AdminOrActionOrModelPerms._normalize_codes(
|
||||
getattr(view, "required_perm_code", None)
|
||||
)
|
||||
if codes:
|
||||
return codes
|
||||
|
||||
# 2) Карта по экшенам (+ по методам)
|
||||
mapping = getattr(view, "required_perms_by_action", None) or {}
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
|
||||
action = getattr(view, "action", None)
|
||||
if not action or action not in mapping:
|
||||
return None
|
||||
|
||||
value = mapping[action]
|
||||
|
||||
# 2.1) Прямо строка/итерируемое — сразу нормализуем
|
||||
codes = AdminOrActionOrModelPerms._normalize_codes(value)
|
||||
if codes is not None:
|
||||
return codes
|
||||
|
||||
# 2.2) Вложенная карта по методам
|
||||
if isinstance(value, dict):
|
||||
method = getattr(getattr(view, "request", None), "method", None)
|
||||
if not method:
|
||||
return None
|
||||
m = method.upper()
|
||||
keys = (m, m.lower())
|
||||
if m == "HEAD":
|
||||
keys += ("GET", "get")
|
||||
keys += ("*", "ANY")
|
||||
for k in keys:
|
||||
if k in value:
|
||||
return AdminOrActionOrModelPerms._normalize_codes(value[k])
|
||||
return None
|
||||
|
||||
# Иное — игнорируем
|
||||
return None
|
||||
|
||||
def has_permission(self, request, view) -> bool:
|
||||
"""Проверка прав на уровне запроса.
|
||||
|
||||
Логика:
|
||||
- staff/superuser → True
|
||||
- если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них
|
||||
- иначе: для SAFE-методов требуем view_<model>, остальные — по DjangoModelPermissions
|
||||
|
||||
Returns:
|
||||
bool: Разрешён ли доступ.
|
||||
"""
|
||||
user = request.user
|
||||
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
|
||||
user = getattr(request, "user", None)
|
||||
if not (user and user.is_authenticated):
|
||||
return False
|
||||
if user.is_superuser or user.is_staff:
|
||||
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
|
||||
return True
|
||||
|
||||
code = self._explicit_perm_code(view)
|
||||
if code:
|
||||
return user.has_perm(code)
|
||||
explicit = self._explicit_perm_codes(view)
|
||||
if explicit:
|
||||
return any(user.has_perm(code) for code in explicit)
|
||||
|
||||
# SAFE-методы → требуем view_<model>
|
||||
if request.method in SAFE_METHODS:
|
||||
model = self._model_from_view(view)
|
||||
return user.has_perm(
|
||||
f"{model._meta.app_label}.view_{model._meta.model_name}"
|
||||
)
|
||||
|
||||
# Остальные → стандартная логика DjangoModelPermissions
|
||||
return super().has_permission(request, view)
|
||||
|
||||
def has_object_permission(self, request, view, obj) -> bool:
|
||||
"""Проверка доступа к конкретному объекту.
|
||||
|
||||
Args:
|
||||
request: DRF Request.
|
||||
view: Текущий ViewSet.
|
||||
obj: Объект модели.
|
||||
|
||||
Returns:
|
||||
bool: Разрешён ли доступ.
|
||||
"""
|
||||
user = request.user
|
||||
if user.is_superuser or user.is_staff:
|
||||
return True
|
||||
|
||||
code = self._explicit_perm_code(view)
|
||||
if code:
|
||||
return user.has_perm(code)
|
||||
|
||||
if request.method in SAFE_METHODS:
|
||||
meta = obj._meta
|
||||
return user.has_perm(f"{meta.app_label}.view_{meta.model_name}")
|
||||
|
||||
# Для остальных методов полагаемся на проверку уровня запроса
|
||||
return super().has_object_permission(request, view, obj)
|
||||
|
||||
@staticmethod
|
||||
def _explicit_perm_code(view) -> Optional[str]:
|
||||
"""Возвращает явный код права, заданный на уровне вью.
|
||||
|
||||
Args:
|
||||
view: DRF ViewSet.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Код права или None.
|
||||
"""
|
||||
mapping = getattr(view, "required_perms_by_action", {}) or {}
|
||||
action = getattr(view, "action", None)
|
||||
return getattr(view, "required_perm_code", None) or mapping.get(action)
|
||||
|
||||
@staticmethod
|
||||
def _model_from_view(view):
|
||||
"""Извлекает модель из view.queryset.
|
||||
|
||||
Args:
|
||||
view: DRF ViewSet.
|
||||
|
||||
Returns:
|
||||
type: Класс модели.
|
||||
|
||||
Raises:
|
||||
AssertionError: если у вью нет queryset/model (для ModelViewSet не ожидается).
|
||||
"""
|
||||
qs = getattr(view, "queryset", None)
|
||||
assert qs is not None and hasattr(
|
||||
qs, "model"
|
||||
), "View must define `.queryset` with `.model`"
|
||||
return qs.model
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MANAGE_PERM",
|
||||
|
||||
124
backend/api/v1/requests_app/permissions.py
Normal file
124
backend/api/v1/requests_app/permissions.py
Normal file
@ -0,0 +1,124 @@
|
||||
# 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 # другие методы не поддерживаем
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Mapping
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from requests_app.models import Request, RequestComment
|
||||
@ -11,6 +11,7 @@ from ..employees.serializers import EmployeeBriefSerializer
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
|
||||
class RequestReadSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор чтения заявки.
|
||||
|
||||
@ -54,97 +55,99 @@ class RequestReadSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор записи заявки (создание/обновление).
|
||||
|
||||
Принцип: обычным пользователям нельзя указывать/менять `employee`, `approver`,
|
||||
`status`, `decided_at`, `created_at`, `updated_at`. Эти поля управляются
|
||||
системой или через отдельные экшены (approve/reject/cancel).
|
||||
class RequestWriteSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор записи заявки.
|
||||
|
||||
Обычным пользователям запрещено передавать `employee`, `status`, `approver` —
|
||||
они будут проигнорированы. Админы/обладатели прав могут их указывать.
|
||||
|
||||
Поле `employee` умышленно НЕ делаем read_only, чтобы админ мог его задать.
|
||||
Для обычных пользователей оно подставится автоматически в `create()`/`perform_create()`.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: При попытке изменить запрещённые поля
|
||||
или при неверных данных (валидация модели сработает в `.save()`).
|
||||
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", # только для админов/обладателей прав
|
||||
"employee",
|
||||
"approver",
|
||||
"department",
|
||||
"type",
|
||||
"title",
|
||||
"date_from",
|
||||
"date_to",
|
||||
"comment",
|
||||
"status", # будет проигнорирован (меняется экшенами)
|
||||
"status",
|
||||
"attachment",
|
||||
)
|
||||
read_only_fields = ("id",)
|
||||
|
||||
def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Фильтрует запрещённые к записи поля для обычных пользователей.
|
||||
def _is_power(self) -> bool:
|
||||
"""Определяет, имеет ли пользователь расширенные права на создание/изменение.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Очищенные данные.
|
||||
bool: True для staff или имеющих модельные права; иначе False.
|
||||
"""
|
||||
user: User = self.context["request"].user
|
||||
is_power = (
|
||||
user.is_staff
|
||||
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")
|
||||
)
|
||||
|
||||
# Обычным пользователям запрещаем управлять этими полями напрямую
|
||||
blocked = {"employee", "approver", "status"}
|
||||
if not is_power:
|
||||
for k in list(attrs.keys()):
|
||||
if k in blocked:
|
||||
attrs.pop(k, None)
|
||||
def to_internal_value(self, data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Предвалидационная очистка входа.
|
||||
|
||||
# Если хотите жестко запретить менять status даже админам через write-эндпойнты,
|
||||
# раскомментируйте следующую строку:
|
||||
# attrs.pop("status", None)
|
||||
Для обычного пользователя удаляет `employee`, `status`, `approver` до полевой валидации,
|
||||
чтобы не получить 400 из-за, например, несуществующего PK в `employee`.
|
||||
|
||||
return attrs
|
||||
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:
|
||||
"""Создаёт заявку.
|
||||
"""Создание заявки.
|
||||
|
||||
Обычным пользователям принудительно проставляется `employee=self.request.user`
|
||||
и статус по умолчанию (`STATUS_PENDING` из модели).
|
||||
Обычным пользователям проставляет текущего пользователя; статус берётся из default модели.
|
||||
|
||||
Args:
|
||||
validated_data (dict[str, Any]): Провалидированные данные.
|
||||
|
||||
Returns:
|
||||
Request: Созданная заявка.
|
||||
"""
|
||||
user: User = self.context["request"].user
|
||||
is_power = user.is_staff or user.has_perm("requests_app.add_request")
|
||||
|
||||
if not is_power:
|
||||
validated_data["employee"] = user
|
||||
# статус возьмётся из default модели (STATUS_PENDING)
|
||||
|
||||
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)
|
||||
|
||||
def update(self, instance: Request, validated_data: dict[str, Any]) -> Request:
|
||||
"""Обновляет заявку.
|
||||
|
||||
Обычным пользователям нельзя менять владельца/согласующего/статус.
|
||||
Дополнительно не даём редактировать финальные заявки.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: Если заявка в финальном статусе.
|
||||
"""
|
||||
user: User = self.context["request"].user
|
||||
if getattr(instance, "is_final", False) and not (
|
||||
user.is_staff or user.has_perm("requests_app.change_request")
|
||||
):
|
||||
raise serializers.ValidationError("Финальная заявка недоступна для правок.")
|
||||
|
||||
# Поля employee/approver/status уже вычищены в validate() при необходимости
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
||||
class RequestCommentSerializer(serializers.ModelSerializer):
|
||||
"""Сериализатор комментариев к заявке."""
|
||||
|
||||
@ -1,26 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List # было: Any, Dict, List, Type
|
||||
from typing import Any, List, Optional # было: Any, Dict, List, Type
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
|
||||
from employees.models import EmployeeDepartment
|
||||
from requests_app.models import Request as EmployeeRequest
|
||||
from requests_app.models import RequestComment
|
||||
# from django.shortcuts import get_object_or_404 # <- не используется
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
|
||||
IsAuthenticated)
|
||||
from rest_framework.request import Request as DRFRequest
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import BaseSerializer # <- для типизации get_serializer_class
|
||||
from rest_framework.serializers import \
|
||||
BaseSerializer # <- для типизации get_serializer_class
|
||||
|
||||
from requests_app.models import Request as EmployeeRequest, RequestComment
|
||||
|
||||
from ..permissions import AdminOrActionOrModelPerms
|
||||
from .serializers import (
|
||||
RequestCommentSerializer,
|
||||
RequestReadSerializer,
|
||||
RequestWriteSerializer,
|
||||
)
|
||||
from ..permissions import (AdminOrActionOrModelPerms, AdminOrDeptAllowed,
|
||||
IsSelfOrStaff)
|
||||
from .permissions import (CommentsPermission, DeptCanProcess,
|
||||
DeptChangeRequest, DeptComments, DeptViewRequest,
|
||||
NotFinalOrStaff)
|
||||
from .serializers import (RequestCommentSerializer, RequestReadSerializer,
|
||||
RequestWriteSerializer)
|
||||
|
||||
|
||||
class RequestViewSet(viewsets.ModelViewSet):
|
||||
@ -29,7 +34,7 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
Админы могут всё. Пользователи с модельными правами действуют по правам.
|
||||
Без прав аутентифицированные пользователи работают только со своими заявками.
|
||||
|
||||
Поддерживает экшены:
|
||||
Поддерживаемые экшены:
|
||||
- POST /requests/{id}/approve/
|
||||
- POST /requests/{id}/reject/
|
||||
- POST /requests/{id}/cancel/
|
||||
@ -43,11 +48,40 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
"employee", "approver", "department"
|
||||
).all()
|
||||
parser_classes = (MultiPartParser, FormParser, JSONParser)
|
||||
|
||||
pagination_class = None
|
||||
|
||||
def get_permissions(self) -> list[Any]:
|
||||
"""Подбирает пермишены под текущий action."""
|
||||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||||
# Декларативная карта прав. Поддерживаются:
|
||||
# - строка ("approve": "app.perm")
|
||||
# - список (ИЛИ прав)
|
||||
# - карта по методам для action ("comments": {"GET": "...", "POST": "..."}).
|
||||
required_perms_by_action = {
|
||||
"comments": {
|
||||
"GET": "requests_app.view_requestcomment",
|
||||
"HEAD": "requests_app.view_requestcomment", # на всякий случай
|
||||
"POST": "requests_app.add_requestcomment",
|
||||
},
|
||||
"reject": ["requests_app.can_process_requests", "requests_app.change_request"],
|
||||
"approve": ["requests_app.can_process_requests", "requests_app.change_request"],
|
||||
# cancel — проверяется владением/админством, отдельный код не нужен
|
||||
}
|
||||
|
||||
def get_permissions(self) -> list[BasePermission]:
|
||||
"""Подбирает пермишены под текущий action.
|
||||
|
||||
- comments: владелец ИЛИ staff ИЛИ явные модельные права из required_perms_by_action.
|
||||
- approve/reject: аутентификация + явные права из required_perms_by_action.
|
||||
- cancel: владелец ИЛИ staff.
|
||||
- остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права.
|
||||
"""
|
||||
if self.action == "comments":
|
||||
return [CommentsPermission()]
|
||||
if self.action in {"approve", "reject"}:
|
||||
return [IsAuthenticated(), (AdminOrActionOrModelPerms)()]
|
||||
if self.action == "cancel":
|
||||
return [IsAuthenticated(), IsSelfOrStaff()]
|
||||
if self.action == "destroy":
|
||||
return [IsAuthenticated(), IsSelfOrStaff(), NotFinalOrStaff()]
|
||||
return [(IsSelfOrStaff | AdminOrActionOrModelPerms)()]
|
||||
|
||||
def get_serializer_class(self) -> type[BaseSerializer]:
|
||||
"""Возвращает сериализатор в зависимости от action."""
|
||||
@ -66,36 +100,31 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
def get_queryset(self) -> QuerySet[EmployeeRequest]:
|
||||
"""Ограничивает список заявок с учётом прав и запроса.
|
||||
|
||||
Поведение:
|
||||
- Администратор (is_staff=True):
|
||||
* по умолчанию видит все заявки;
|
||||
* если передать `?view=mine` ИЛИ `?mine=1`, видит только свои.
|
||||
- Пользователь с модельными правами просмотра (`view_request` или
|
||||
`requests_app.can_view_all_requests`): видит все.
|
||||
- Прочие аутентифицированные пользователи: только свои (параметры
|
||||
`view`/`mine` игнорируются).
|
||||
|
||||
Поддерживаются простые фильтры `?type=...&status=...`.
|
||||
"""Список заявок с учётом прав:
|
||||
- staff/глобальные видят всё (или только свои при ?view=mine),
|
||||
- остальные: свои + по отделам, где есть право 'view_request'.
|
||||
"""
|
||||
qs = super().get_queryset()
|
||||
user = self.request.user
|
||||
params = self.request.query_params
|
||||
|
||||
# Явный запрос "только свои" для админов
|
||||
mine_raw = (params.get("mine") or "").lower()
|
||||
want_mine = (params.get("view") == "mine") or (mine_raw in {"1", "true", "yes", "on"})
|
||||
|
||||
if user.is_staff:
|
||||
if user.is_staff or self._can_view_all(user):
|
||||
if want_mine:
|
||||
qs = qs.filter(employee_id=user.id)
|
||||
else:
|
||||
# Если нет прав на просмотр всех — урезаем до своих
|
||||
if not self._can_view_all(user):
|
||||
qs = qs.filter(employee_id=user.id)
|
||||
# отделы, где у пользователя есть dept-право 'view_request'
|
||||
dept_ids_qs = (
|
||||
EmployeeDepartment.objects
|
||||
.filter(employee_id=user.id, is_active=True, role__scoped_permissions__code="view_request")
|
||||
.values_list("department_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
scope = Q(employee_id=user.id) | Q(department_id__in=dept_ids_qs)
|
||||
qs = qs.filter(scope)
|
||||
|
||||
# Доп. фильтры
|
||||
t = (params.get("type") or "").strip()
|
||||
s = (params.get("status") or "").strip()
|
||||
if t:
|
||||
@ -105,16 +134,39 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return qs
|
||||
|
||||
# --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() ---
|
||||
|
||||
def get_object(self):
|
||||
"""Возвращает объект для detail-экшенов.
|
||||
|
||||
Для 'approve', 'reject', 'comments', 'cancel' берём объект из полного
|
||||
набора записей по первичному ключу (в обход get_queryset()), затем
|
||||
вызываем check_object_permissions() — чтобы объектные пермишены отработали.
|
||||
Для остальных действий — стандартное поведение базового класса.
|
||||
|
||||
Returns:
|
||||
EmployeeRequest: Объект заявки.
|
||||
|
||||
Raises:
|
||||
Http404: Если объект не найден.
|
||||
PermissionDenied: Если объектные права не пройдены.
|
||||
"""
|
||||
if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel"}:
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
lookup_val = self.kwargs[lookup_url_kwarg]
|
||||
obj = get_object_or_404(EmployeeRequest, **{self.lookup_field: lookup_val})
|
||||
self.check_object_permissions(self.request, obj)
|
||||
return obj
|
||||
return super().get_object()
|
||||
|
||||
# ---------- CRUD ----------
|
||||
|
||||
def perform_create(self, serializer: RequestWriteSerializer) -> None:
|
||||
"""Создание: сотруднику принудительно ставим себя как автора (если не админ)."""
|
||||
"""Создание: для не-админа принудительно ставим себя как автора."""
|
||||
user = self.request.user
|
||||
is_power = user.is_staff or user.has_perm("requests_app.add_request")
|
||||
data: dict[str, Any] = {}
|
||||
if not is_power:
|
||||
data["employee"] = user
|
||||
serializer.save(**data)
|
||||
extra = {"employee": user} if not is_power else {}
|
||||
serializer.save(**extra)
|
||||
|
||||
def perform_update(self, serializer: RequestWriteSerializer) -> None:
|
||||
"""Обновление: владельцу разрешаем до финального статуса.
|
||||
@ -140,74 +192,78 @@ class RequestViewSet(viewsets.ModelViewSet):
|
||||
def comments(self, request: DRFRequest, pk: int | str | None = None) -> Response:
|
||||
"""Список/создание комментариев для заявки.
|
||||
|
||||
GET: список комментариев
|
||||
POST: создать комментарий { "text": "..." }
|
||||
GET: список комментариев (staff, владелец или право view_requestcomment).
|
||||
POST: создание {"text": "..."} (staff, владелец или право add_requestcomment).
|
||||
"""
|
||||
req_obj = self.get_object()
|
||||
if request.method == "GET":
|
||||
req_obj = self.get_object() # проходит через check_object_permissions()
|
||||
|
||||
if request.method in {"GET", "HEAD"}:
|
||||
qs = RequestComment.objects.filter(request=req_obj).select_related("author")
|
||||
ser = RequestCommentSerializer(qs, many=True, context=self.get_serializer_context())
|
||||
ser = RequestCommentSerializer(
|
||||
qs, many=True, context=self.get_serializer_context()
|
||||
)
|
||||
return Response(ser.data)
|
||||
|
||||
# POST
|
||||
ser = RequestCommentSerializer(data=request.data, context=self.get_serializer_context())
|
||||
ser = RequestCommentSerializer(
|
||||
data=request.data, context=self.get_serializer_context()
|
||||
)
|
||||
ser.is_valid(raise_exception=True)
|
||||
ser.save(request=req_obj, author=request.user)
|
||||
return Response(ser.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# ---------- Экшены статусов ----------
|
||||
# ---------- Экшены статусов (только бизнес-валидация) ----------
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def approve(self, request: DRFRequest, pk: int | str | None = None) -> Response:
|
||||
"""Одобряет заявку. Требует прав can_process_requests/change_request.
|
||||
"""Одобряет заявку.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: Если у пользователя нет достаточных прав.
|
||||
ValidationError: Если заявка уже финальная.
|
||||
"""
|
||||
obj = self.get_object()
|
||||
user = request.user
|
||||
if not (
|
||||
user.is_staff
|
||||
or user.has_perm("requests_app.can_process_requests")
|
||||
or user.has_perm("requests_app.change_request")
|
||||
):
|
||||
raise PermissionDenied("Недостаточно прав для одобрения заявки.")
|
||||
obj.approve(by_user=user)
|
||||
return Response(RequestReadSerializer(obj, context=self.get_serializer_context()).data)
|
||||
obj = self.get_object() # права проверит AdminOrActionOrModelPerms
|
||||
if getattr(obj, "is_final", False):
|
||||
raise ValidationError("Заявка уже в финальном статусе.")
|
||||
obj.approve(by_user=request.user)
|
||||
return Response(
|
||||
RequestReadSerializer(obj, context=self.get_serializer_context()).data
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def reject(self, request: DRFRequest, pk: int | str | None = None) -> Response:
|
||||
"""Отклоняет заявку. Требует прав can_process_requests/change_request.
|
||||
"""Отклоняет заявку.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: Если у пользователя нет достаточных прав.
|
||||
ValidationError: Если заявка уже финальная.
|
||||
"""
|
||||
obj = self.get_object()
|
||||
user = request.user
|
||||
if not (
|
||||
user.is_staff
|
||||
or user.has_perm("requests_app.can_process_requests")
|
||||
or user.has_perm("requests_app.change_request")
|
||||
):
|
||||
raise PermissionDenied("Недостаточно прав для отклонения заявки.")
|
||||
obj.reject(by_user=user)
|
||||
return Response(RequestReadSerializer(obj, context=self.get_serializer_context()).data)
|
||||
obj = self.get_object() # права проверит AdminOrActionOrModelPerms
|
||||
if getattr(obj, "is_final", False):
|
||||
raise ValidationError("Заявка уже в финальном статусе.")
|
||||
obj.reject(by_user=request.user)
|
||||
return Response(
|
||||
RequestReadSerializer(obj, context=self.get_serializer_context()).data
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel(self, request: DRFRequest, pk: int | str | None = None) -> Response:
|
||||
"""Отменяет заявку.
|
||||
|
||||
Владельцу разрешено, если заявка ещё не финальна.
|
||||
Администратору — всегда.
|
||||
"""Отменяет заявку (владелец или админ).
|
||||
|
||||
Raises:
|
||||
PermissionDenied: Если пользователь не владелец/не админ, или заявка финальная для не-админа.
|
||||
PermissionDenied: Если заявка финальная (для не admin).
|
||||
"""
|
||||
obj = self.get_object()
|
||||
user = request.user
|
||||
if not (user.is_staff or obj.employee_id == user.id):
|
||||
raise PermissionDenied("Нельзя отменять чужую заявку.")
|
||||
if obj.is_final and not user.is_staff:
|
||||
obj = self.get_object() # владение/admin проверит IsSelfOrStaff
|
||||
if getattr(obj, "is_final", False) and not request.user.is_staff:
|
||||
raise PermissionDenied("Финальная заявка уже не может быть отменена.")
|
||||
obj.cancel()
|
||||
return Response(RequestReadSerializer(obj, context=self.get_serializer_context()).data)
|
||||
return Response(
|
||||
RequestReadSerializer(obj, context=self.get_serializer_context()).data
|
||||
)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Создание заявки: валидируем write-сериализатором, отвечаем read-сериализатором."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
instance = serializer.instance
|
||||
read = RequestReadSerializer(instance, context=self.get_serializer_context())
|
||||
headers = self.get_success_headers(read.data)
|
||||
return Response(read.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@ -255,4 +255,19 @@ SIMPLE_JWT = {
|
||||
"LEEWAY": 30, # допускаем ±30с рассинхронизации времени
|
||||
}
|
||||
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
|
||||
|
||||
# LOGGING = {
|
||||
# 'version': 1,
|
||||
# 'handlers': {
|
||||
# 'console': {
|
||||
# 'class': 'logging.StreamHandler',
|
||||
# },
|
||||
# },
|
||||
# 'loggers': {
|
||||
# 'your_app': {
|
||||
# 'handlers': ['console'],
|
||||
# 'level': 'DEBUG',
|
||||
# },
|
||||
# },
|
||||
# }
|
||||
@ -454,3 +454,6 @@ def request_comment_delete(request, pk, comment_id):
|
||||
|
||||
default_url = reverse(_detail_or_process_name(request.user), args=[pk])
|
||||
return redirect(_safe_back_url(request, default_url))
|
||||
|
||||
|
||||
|
||||
|
||||
350
backend/requests_app/views_front.py
Normal file
350
backend/requests_app/views_front.py
Normal file
@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import (
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseForbidden,
|
||||
)
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from api.client import get_api_client # ваш клиент
|
||||
from api.decorators import require_api_auth # проверка наличия токена в сессии
|
||||
|
||||
|
||||
API_PATH = "v1/requests/" # относительный путь внутри API (base_url настраивается в settings)
|
||||
|
||||
|
||||
# ===================== Списки =====================
|
||||
|
||||
@require_api_auth
|
||||
def my_requests(request: HttpRequest) -> HttpResponse:
|
||||
"""Список «моих» заявок через GET /api/v1/requests/?view=mine.
|
||||
|
||||
Returns:
|
||||
HttpResponse: HTML со списком заявок.
|
||||
|
||||
Raises:
|
||||
RuntimeError: В случае неожиданных ошибок клиента API (маловероятно).
|
||||
"""
|
||||
api = get_api_client(request)
|
||||
params = {"view": "mine", **request.GET.dict()}
|
||||
resp = api.get(API_PATH, params=params)
|
||||
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.ok:
|
||||
return render(request, "requests_app/my_requests.html", {"items": resp.json or []})
|
||||
|
||||
if resp.status == 403:
|
||||
return HttpResponseForbidden("Нет прав на просмотр ваших заявок.")
|
||||
|
||||
messages.error(request, f"Ошибка API: {resp.status}")
|
||||
return render(request, "requests_app/my_requests.html", {"items": [], "error": resp.text})
|
||||
|
||||
|
||||
@require_api_auth
|
||||
def all_requests(request: HttpRequest) -> HttpResponse:
|
||||
"""Список всех заявок через GET /api/v1/requests/.
|
||||
|
||||
Query-параметры проксируются как есть (?type=...&status=...).
|
||||
|
||||
Returns:
|
||||
HttpResponse: HTML со списком заявок.
|
||||
|
||||
Raises:
|
||||
RuntimeError: В случае неожиданных ошибок клиента API.
|
||||
"""
|
||||
api = get_api_client(request)
|
||||
resp = api.get(API_PATH, params=request.GET.dict())
|
||||
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.ok:
|
||||
return render(request, "requests_app/all_requests.html", {"items": resp.json or []})
|
||||
|
||||
if resp.status == 403:
|
||||
return HttpResponseForbidden("Недостаточно прав для просмотра заявок.")
|
||||
|
||||
messages.error(request, f"Ошибка API: {resp.status}")
|
||||
return render(request, "requests_app/all_requests.html", {"items": [], "error": resp.text})
|
||||
|
||||
|
||||
# ===================== Создание/детали =====================
|
||||
|
||||
@require_api_auth
|
||||
def request_create(request: HttpRequest) -> HttpResponse:
|
||||
"""Создание заявки через POST /api/v1/requests/.
|
||||
|
||||
GET: вывести форму.
|
||||
POST: отправить поля формы в API. Для обычного пользователя API игнорирует `employee/status`
|
||||
и выставляет автора/статус автоматически.
|
||||
|
||||
Returns:
|
||||
HttpResponse: Редирект на детальную страницу при успехе или HTML формы с ошибками.
|
||||
|
||||
Raises:
|
||||
ValueError: Если передан неподдерживаемый метод.
|
||||
"""
|
||||
if request.method == "GET":
|
||||
return render(request, "requests_app/request_form.html")
|
||||
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest("Метод не поддерживается.")
|
||||
|
||||
api = get_api_client(request)
|
||||
|
||||
# Соберите безопасный набор полей (минимум, совпадающий с API write-сериализатором)
|
||||
payload: dict[str, Any] = {
|
||||
"type": request.POST.get("type"),
|
||||
"title": request.POST.get("title") or "",
|
||||
"date_from": request.POST.get("date_from") or None,
|
||||
"date_to": request.POST.get("date_to") or None,
|
||||
"comment": request.POST.get("comment") or "",
|
||||
}
|
||||
|
||||
# Если есть файл — используем form-data, иначе JSON.
|
||||
if upload := request.FILES.get("attachment"):
|
||||
files = {"attachment": (upload.name, upload.file, upload.content_type)}
|
||||
resp = api.post(API_PATH, data=payload, files=files)
|
||||
else:
|
||||
resp = api.post(API_PATH, json=payload)
|
||||
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.status == 201 and isinstance(resp.json, dict):
|
||||
messages.success(request, "Заявка создана.")
|
||||
return redirect("requests_app:request_detail", pk=resp.json["id"])
|
||||
|
||||
if resp.status in (400, 403):
|
||||
# Пытаемся показать ошибки валидатора
|
||||
form_errors: Mapping[str, Any] = resp.json if isinstance(resp.json, dict) else {"__all__": resp.text}
|
||||
return render(
|
||||
request,
|
||||
"requests_app/request_form.html",
|
||||
{"form_errors": form_errors, "form_data": payload},
|
||||
)
|
||||
|
||||
messages.error(request, f"Ошибка API: {resp.status}")
|
||||
return render(
|
||||
request,
|
||||
"requests_app/request_form.html",
|
||||
{"form_errors": {"__all__": resp.text or "Неизвестная ошибка"}},
|
||||
)
|
||||
|
||||
|
||||
@require_api_auth
|
||||
def request_detail(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
"""Детальная страница заявки (GET /api/v1/requests/{pk}/) + попытка подтянуть комментарии.
|
||||
|
||||
Args:
|
||||
pk (int): Идентификатор заявки.
|
||||
|
||||
Returns:
|
||||
HttpResponse: HTML с деталями заявки.
|
||||
|
||||
Raises:
|
||||
RuntimeError: В случае неожиданных ошибок клиента API.
|
||||
"""
|
||||
api = get_api_client(request)
|
||||
|
||||
# Основной объект заявки
|
||||
item_resp = api.get(f"{API_PATH}{pk}/")
|
||||
if item_resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if item_resp.status == 404:
|
||||
return render(request, "requests_app/request_detail.html", {"item": None, "error": "Заявка не найдена."})
|
||||
if item_resp.status == 403:
|
||||
return HttpResponseForbidden("Недостаточно прав для просмотра заявки.")
|
||||
if not item_resp.ok:
|
||||
messages.error(request, f"Ошибка API: {item_resp.status}")
|
||||
return render(request, "requests_app/request_detail.html", {"item": None, "error": item_resp.text})
|
||||
|
||||
# Комментарии (если доступ есть)
|
||||
comments: list[dict[str, Any]] = []
|
||||
comm_resp = api.get(f"{API_PATH}{pk}/comments/")
|
||||
if comm_resp.ok and isinstance(comm_resp.json, list):
|
||||
comments = comm_resp.json
|
||||
|
||||
return render(
|
||||
request,
|
||||
"requests_app/request_detail.html",
|
||||
{"item": item_resp.json, "comments": comments},
|
||||
)
|
||||
|
||||
|
||||
# ===================== Обработка статусов =====================
|
||||
|
||||
@require_api_auth
|
||||
def request_process(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
"""Обработка заявки через POST /api/v1/requests/{pk}/(approve|reject)/.
|
||||
|
||||
Ожидает в POST параметр 'action' со значением 'approve' или 'reject'.
|
||||
|
||||
Args:
|
||||
pk (int): Идентификатор заявки.
|
||||
|
||||
Returns:
|
||||
HttpResponse: Редирект на детальную страницу.
|
||||
|
||||
Raises:
|
||||
ValueError: Если action неизвестен.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest("Только POST.")
|
||||
|
||||
action = (request.POST.get("action") or "").strip().lower()
|
||||
if action not in {"approve", "reject"}:
|
||||
raise ValueError("action должен быть 'approve' или 'reject'.")
|
||||
|
||||
api = get_api_client(request)
|
||||
resp = api.post(f"{API_PATH}{pk}/{action}/")
|
||||
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.ok:
|
||||
messages.success(request, f"Действие '{action}' выполнено.")
|
||||
elif resp.status in (400, 403):
|
||||
messages.error(request, f"Не удалось выполнить '{action}': {resp.json or resp.text}")
|
||||
else:
|
||||
messages.error(request, f"Ошибка API ({resp.status}) при '{action}'.")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
|
||||
|
||||
@require_api_auth
|
||||
def request_cancel(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
"""Отмена заявки через POST /api/v1/requests/{pk}/cancel/.
|
||||
|
||||
Args:
|
||||
pk (int): Идентификатор заявки.
|
||||
|
||||
Returns:
|
||||
HttpResponse: Редирект на детальную страницу.
|
||||
|
||||
Raises:
|
||||
RuntimeError: В случае неожиданных ошибок клиента API.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest("Только POST.")
|
||||
|
||||
api = get_api_client(request)
|
||||
resp = api.post(f"{API_PATH}{pk}/cancel/")
|
||||
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.ok:
|
||||
messages.success(request, "Заявка отменена.")
|
||||
elif resp.status in (400, 403):
|
||||
messages.error(request, f"Не удалось отменить: {resp.json or resp.text}")
|
||||
else:
|
||||
messages.error(request, f"Ошибка API ({resp.status}) при отмене.")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
|
||||
|
||||
# ===================== Комментарии =====================
|
||||
|
||||
@require_api_auth
|
||||
def request_comment_add(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
"""Добавление комментария через POST /api/v1/requests/{pk}/comments/.
|
||||
|
||||
Тело: {"text": "..."}.
|
||||
|
||||
Args:
|
||||
pk (int): Идентификатор заявки.
|
||||
|
||||
Returns:
|
||||
HttpResponse: Редирект на детальную страницу заявки.
|
||||
|
||||
Raises:
|
||||
ValueError: Если метод не POST или текст пустой.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest("Только POST.")
|
||||
|
||||
text = (request.POST.get("text") or "").strip()
|
||||
if not text:
|
||||
messages.error(request, "Текст комментария не может быть пустым.")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
|
||||
api = get_api_client(request)
|
||||
resp = api.post(f"{API_PATH}{pk}/comments/", json={"text": text})
|
||||
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.status == 201:
|
||||
messages.success(request, "Комментарий добавлен.")
|
||||
elif resp.status == 403:
|
||||
messages.error(request, "Нет прав для добавления комментариев.")
|
||||
elif resp.status == 400:
|
||||
messages.error(request, f"Ошибка валидации: {resp.json or resp.text}")
|
||||
else:
|
||||
messages.error(request, f"Ошибка API ({resp.status}) при добавлении комментария.")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
|
||||
|
||||
@require_api_auth
|
||||
def request_comment_delete(request: HttpRequest, pk: int, comment_id: int) -> HttpResponse:
|
||||
"""Удаление комментария.
|
||||
|
||||
Пытается вызвать DELETE в одном из известных эндпоинтов.
|
||||
Сначала: /api/v1/requests/{pk}/comments/{comment_id}/
|
||||
Затем: /api/v1/comments/{comment_id}/ (если первый путь не поддерживается).
|
||||
|
||||
Args:
|
||||
pk (int): Идентификатор заявки.
|
||||
comment_id (int): Идентификатор комментария.
|
||||
|
||||
Returns:
|
||||
HttpResponse: Редирект на детальную страницу заявки.
|
||||
|
||||
Raises:
|
||||
ValueError: Если метод не POST.
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponseBadRequest("Только POST.")
|
||||
|
||||
api = get_api_client(request)
|
||||
|
||||
# Вариант 1: nested под заявкой
|
||||
resp = api.delete(f"{API_PATH}{pk}/comments/{comment_id}/")
|
||||
if resp.status == 401:
|
||||
messages.info(request, "Войдите заново.")
|
||||
return redirect(reverse("auth_front:login"))
|
||||
|
||||
if resp.status in (200, 204):
|
||||
messages.success(request, "Комментарий удалён.")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
|
||||
# Вариант 2: общий ресурс комментариев
|
||||
if resp.status == 404:
|
||||
resp2 = api.delete(f"v1/comments/{comment_id}/")
|
||||
if resp2.status in (200, 204):
|
||||
messages.success(request, "Комментарий удалён.")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
if resp2.status == 403:
|
||||
return HttpResponseForbidden("Нет прав на удаление комментария.")
|
||||
messages.error(request, f"Не удалось удалить комментарий: {resp2.status}")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
|
||||
if resp.status == 403:
|
||||
return HttpResponseForbidden("Нет прав на удаление комментария.")
|
||||
messages.error(request, f"Не удалось удалить комментарий: {resp.status}")
|
||||
return redirect("requests_app:request_detail", pk=pk)
|
||||
@ -783,7 +783,6 @@ class TestSerialization:
|
||||
|
||||
d = make_document(uploaded_by=author)
|
||||
file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"]
|
||||
print(file_url)
|
||||
|
||||
# обычный Django client (без APIClient) для скачивания
|
||||
resp = client.get(file_url)
|
||||
|
||||
@ -277,10 +277,6 @@ class TestDepartmentRole:
|
||||
|
||||
def test_create_update_delete_own_department(self, make_user, auth, urls):
|
||||
"""В своём отделе → 201/200/204; в другом отделе → 403."""
|
||||
print(
|
||||
"TEST PUBLISH CODE =",
|
||||
getattr(DeptPerm, "CREATE_POST", "publish_department_post"),
|
||||
)
|
||||
|
||||
u = make_user("role@example.com")
|
||||
d1 = _mk_dept("D1")
|
||||
|
||||
@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from rest_framework.test import APIClient
|
||||
from requests_app.models import Request as Req
|
||||
|
||||
from .contract import MODEL_VIEW_PERMISSION
|
||||
|
||||
@ -79,6 +80,7 @@ def make_user() -> Callable[..., models.Model]:
|
||||
password: str = "pass12345",
|
||||
is_staff: bool = False,
|
||||
is_superuser: bool = False,
|
||||
is_active=True,
|
||||
phone: Optional[str] = None,
|
||||
**extra: object,
|
||||
) -> models.Model:
|
||||
@ -118,6 +120,8 @@ def make_user() -> Callable[..., models.Model]:
|
||||
user.is_staff = True
|
||||
if is_superuser:
|
||||
user.is_superuser = True
|
||||
if is_active:
|
||||
user.is_active = True
|
||||
user.save()
|
||||
return user
|
||||
|
||||
@ -154,35 +158,73 @@ def auth_client() -> Callable[[models.Model], APIClient]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grant_model_perm() -> Callable[[models.Model, str], None]:
|
||||
"""Выдаёт пользователю модельное право (по умолчанию из MODEL_VIEW_PERMISSION).
|
||||
|
||||
Возвращает функцию grant(user, perm_label='app_label.codename').
|
||||
"""
|
||||
|
||||
def _grant(user: models.Model, perm_label: str = MODEL_VIEW_PERMISSION) -> None:
|
||||
"""Назначает пользователю право `perm_label`.
|
||||
|
||||
Args:
|
||||
user (models.Model): Экземпляр пользователя.
|
||||
perm_label (str): Право в формате 'app_label.codename' (по умолчанию MODEL_VIEW_PERMISSION).
|
||||
|
||||
Raises:
|
||||
AssertionError: Если указанное право не найдено.
|
||||
ValueError: Если строка права не в формате 'app_label.codename'.
|
||||
"""
|
||||
if "." not in perm_label:
|
||||
raise ValueError("perm_label должен быть в формате 'app_label.codename'")
|
||||
app_label, codename = perm_label.split(".", 1)
|
||||
perm = Permission.objects.filter(
|
||||
content_type__app_label=app_label,
|
||||
codename=codename,
|
||||
).first()
|
||||
assert perm is not None, (
|
||||
f"Permission '{perm_label}' не найден. Убедись, что приложение в INSTALLED_APPS "
|
||||
"и применены миграции (manage.py migrate)."
|
||||
def grant_model_perm(db):
|
||||
def _grant(user, perm_str: str):
|
||||
app_label, codename = perm_str.split(".")
|
||||
perm = Permission.objects.get(
|
||||
content_type__app_label=app_label, codename=codename
|
||||
)
|
||||
user.user_permissions.add(perm)
|
||||
# save() не требуется: изменение M2M уже записано
|
||||
# сбиваем кэш прав на текущем экземпляре
|
||||
if hasattr(user, "_perm_cache"):
|
||||
delattr(user, "_perm_cache")
|
||||
# возвращаем свежий экземпляр из БД
|
||||
return get_user_model().objects.get(pk=user.pk)
|
||||
|
||||
return _grant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_request(db):
|
||||
"""Фабрика: создаёт заявку с минимальными полями.
|
||||
|
||||
Если передан финальный статус (APPROVED/REJECTED), гарантирует заполненный approver,
|
||||
чтобы пройти CHECK-констрейнт БД `request_approver_required_on_decision`.
|
||||
"""
|
||||
|
||||
def _make(
|
||||
*,
|
||||
employee: models.Model,
|
||||
type_: str = Req.TYPE_VACATION,
|
||||
status: str | None = None,
|
||||
comment: str = "",
|
||||
approver: models.Model | None = None,
|
||||
) -> Req:
|
||||
"""Создаёт `requests_app.Request`.
|
||||
|
||||
Args:
|
||||
employee (models.Model): Владелец заявки.
|
||||
type_ (str): Тип заявки.
|
||||
status (str | None): Начальный статус (если None — возьмётся дефолт модели).
|
||||
comment (str): Комментарий.
|
||||
approver (models.Model | None): Согласующий; обязателен для финальных статусов.
|
||||
|
||||
Returns:
|
||||
Req: Созданный объект.
|
||||
|
||||
Raises:
|
||||
ValueError: Если для финального статуса не удалось определить approver.
|
||||
"""
|
||||
obj = Req.objects.create(employee=employee, type=type_, comment=comment)
|
||||
|
||||
if status:
|
||||
obj.status = status
|
||||
|
||||
# Для «решённых» статусов БД требует approver.
|
||||
final_statuses = {
|
||||
getattr(Req, "STATUS_APPROVED", "approved"),
|
||||
getattr(Req, "STATUS_REJECTED", "rejected"),
|
||||
}
|
||||
if status in final_statuses:
|
||||
obj.approver = approver or employee # в тестах допустимо
|
||||
# Опционально поставим время решения, если модель его использует
|
||||
if hasattr(obj, "decided_at") and not obj.decided_at:
|
||||
from django.utils import timezone
|
||||
|
||||
obj.decided_at = timezone.now()
|
||||
|
||||
obj.save()
|
||||
|
||||
return obj
|
||||
|
||||
return _make
|
||||
|
||||
@ -8,6 +8,7 @@ from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from requests_app.models import Request as Req
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@ -34,57 +35,12 @@ def _results(payload: Any) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_request(db):
|
||||
"""Фабрика: создаёт заявку с минимальными полями.
|
||||
|
||||
Если передан финальный статус (APPROVED/REJECTED), гарантирует заполненный approver,
|
||||
чтобы пройти CHECK-констрейнт БД `request_approver_required_on_decision`.
|
||||
"""
|
||||
|
||||
def _make(
|
||||
*,
|
||||
employee: models.Model,
|
||||
type_: str = Req.TYPE_VACATION,
|
||||
status: str | None = None,
|
||||
comment: str = "",
|
||||
approver: models.Model | None = None,
|
||||
) -> Req:
|
||||
"""Создаёт `requests_app.Request`.
|
||||
|
||||
Args:
|
||||
employee (models.Model): Владелец заявки.
|
||||
type_ (str): Тип заявки.
|
||||
status (str | None): Начальный статус (если None — возьмётся дефолт модели).
|
||||
comment (str): Комментарий.
|
||||
approver (models.Model | None): Согласующий; обязателен для финальных статусов.
|
||||
|
||||
Returns:
|
||||
Req: Созданный объект.
|
||||
|
||||
Raises:
|
||||
ValueError: Если для финального статуса не удалось определить approver.
|
||||
"""
|
||||
obj = Req.objects.create(employee=employee, type=type_, comment=comment)
|
||||
|
||||
if status:
|
||||
obj.status = status
|
||||
|
||||
# Для «решённых» статусов БД требует approver.
|
||||
final_statuses = {getattr(Req, "STATUS_APPROVED", "approved"),
|
||||
getattr(Req, "STATUS_REJECTED", "rejected")}
|
||||
if status in final_statuses:
|
||||
obj.approver = approver or employee # в тестах допустимо
|
||||
# Опционально поставим время решения, если модель его использует
|
||||
if hasattr(obj, "decided_at") and not obj.decided_at:
|
||||
from django.utils import timezone
|
||||
obj.decided_at = timezone.now()
|
||||
|
||||
obj.save()
|
||||
|
||||
return obj
|
||||
|
||||
return _make
|
||||
def _reload(user):
|
||||
# сбрасываем кэш прав на текущем экземпляре (если уже вычислялся)
|
||||
if hasattr(user, "_perm_cache"):
|
||||
delattr(user, "_perm_cache")
|
||||
# подменяем объект свежим из БД — это важно для force_authenticate
|
||||
return get_user_model().objects.get(pk=user.pk)
|
||||
|
||||
|
||||
|
||||
@ -92,6 +48,7 @@ def make_request(db):
|
||||
# 1) СПИСКИ / ВИДИМОСТЬ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_unauth_401(api_client: APIClient) -> None:
|
||||
"""Неаутентифицированный доступ к списку → 401."""
|
||||
resp = api_client.get(API_BASE)
|
||||
@ -175,6 +132,7 @@ def test_list_manager_with_model_perm_sees_all(
|
||||
# 2) ДЕТАЛЬНЫЙ ПРОСМОТР
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detail_regular_user_own_and_forbidden_foreign(
|
||||
auth_client, regular_user: models.Model, make_user, make_request
|
||||
) -> None:
|
||||
@ -215,17 +173,26 @@ def test_detail_admin_and_manager_can_see_any(
|
||||
# 3) СОЗДАНИЕ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_regular_user_forces_employee_and_default_status(
|
||||
auth_client, regular_user: models.Model
|
||||
) -> None:
|
||||
"""POST: обычному пользователю принудительно проставляется employee=текущий и дефолтный статус."""
|
||||
client = auth_client(regular_user)
|
||||
payload = {"type": Req.TYPE_VACATION, "comment": "Отпуск на недельку", "employee": 999, "status": Req.STATUS_APPROVED}
|
||||
payload = {
|
||||
"type": Req.TYPE_VACATION,
|
||||
"comment": "Отпуск на недельку",
|
||||
"employee": 999,
|
||||
"status": Req.STATUS_APPROVED,
|
||||
}
|
||||
resp = client.post(API_BASE, data=payload, format="json")
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
# Поле employee — текущий пользователь
|
||||
assert str(data["employee"]["id"]) == str(regular_user.id) or data["employee"]["id"] == regular_user.id
|
||||
assert (
|
||||
str(data["employee"]["id"]) == str(regular_user.id)
|
||||
or data["employee"]["id"] == regular_user.id
|
||||
)
|
||||
# Статус должен быть не финальный (дефолт модели — pending)
|
||||
assert data["status"] in (Req.STATUS_DRAFT, Req.STATUS_PENDING)
|
||||
|
||||
@ -246,6 +213,7 @@ def test_create_admin_can_set_employee(
|
||||
# 4) ОБНОВЛЕНИЕ / 5) УДАЛЕНИЕ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_update_own_pending_ok_and_final_forbidden(
|
||||
auth_client, regular_user: models.Model, make_request
|
||||
) -> None:
|
||||
@ -254,8 +222,12 @@ def test_update_own_pending_ok_and_final_forbidden(
|
||||
final = make_request(employee=regular_user, status=Req.STATUS_APPROVED)
|
||||
|
||||
client = auth_client(regular_user)
|
||||
ok = client.patch(f"{API_BASE}{pending.id}/", data={"comment": "обновлено"}, format="json")
|
||||
deny = client.patch(f"{API_BASE}{final.id}/", data={"comment": "нельзя"}, format="json")
|
||||
ok = client.patch(
|
||||
f"{API_BASE}{pending.id}/", data={"comment": "обновлено"}, format="json"
|
||||
)
|
||||
deny = client.patch(
|
||||
f"{API_BASE}{final.id}/", data={"comment": "нельзя"}, format="json"
|
||||
)
|
||||
|
||||
assert ok.status_code == 200
|
||||
assert deny.status_code in (400, 403)
|
||||
@ -268,7 +240,9 @@ def test_update_foreign_hidden(
|
||||
other = make_user(email="z@example.com")
|
||||
foreign = make_request(employee=other)
|
||||
client = auth_client(regular_user)
|
||||
resp = client.patch(f"{API_BASE}{foreign.id}/", data={"comment": "try"}, format="json")
|
||||
resp = client.patch(
|
||||
f"{API_BASE}{foreign.id}/", data={"comment": "try"}, format="json"
|
||||
)
|
||||
assert resp.status_code in (403, 404)
|
||||
|
||||
|
||||
@ -291,6 +265,7 @@ def test_delete_own_pending_ok_final_forbidden(
|
||||
# 6) ЭКШЕНЫ СТАТУСОВ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_actions_permissions_and_effects(
|
||||
auth_client, admin_user: models.Model, make_user, grant_model_perm, make_request
|
||||
) -> None:
|
||||
@ -334,6 +309,7 @@ def test_actions_permissions_and_effects(
|
||||
# 7) КОММЕНТАРИИ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_comments_forbidden_for_regular_user(
|
||||
auth_client,
|
||||
regular_user: models.Model,
|
||||
@ -365,16 +341,16 @@ def test_comments_allowed_for_admin_and_manager(
|
||||
grant_model_perm,
|
||||
make_request,
|
||||
) -> None:
|
||||
"""Админ и пользователь с модельными правами могут видеть и оставлять комментарии.
|
||||
|
||||
Проверяем оба актёра:
|
||||
- admin (is_staff=True)
|
||||
- manager с правом просмотра/изменения заявок.
|
||||
"""Проверяет доступ к комментариям:
|
||||
- Админ видит и создаёт.
|
||||
- Менеджер с ТОЛЬКО чтением видит, но НЕ создаёт.
|
||||
- Менеджер с чтением+добавлением — и видит, и создаёт.
|
||||
- Менеджер с ТОЛЬКО добавлением — создаёт (чтение невлияет на добавление).
|
||||
"""
|
||||
owner = make_user(email="owner-comments@example.com")
|
||||
req = make_request(employee=owner)
|
||||
|
||||
# Админ
|
||||
# --- Админ ---
|
||||
aclient = auth_client(admin_user)
|
||||
get_a = aclient.get(f"{API_BASE}{req.id}/comments/")
|
||||
assert get_a.status_code == 200, "Админ должен видеть список комментариев"
|
||||
@ -386,32 +362,60 @@ def test_comments_allowed_for_admin_and_manager(
|
||||
)
|
||||
assert post_a.status_code == 201, "Админ должен уметь создавать комментарий"
|
||||
|
||||
# Менеджер с модельными правами
|
||||
manager = make_user(email="manager-comments@example.com")
|
||||
grant_model_perm(manager, "requests_app.view_requestcomment")
|
||||
grant_model_perm(manager, "requests_app.add_requestcomment")
|
||||
# --- Менеджер: только право чтения комментариев ---
|
||||
manager_view_only = make_user(email="manager-comments-view@example.com")
|
||||
grant_model_perm(manager_view_only, "requests_app.view_requestcomment")
|
||||
manager_view_only = _reload(manager_view_only)
|
||||
|
||||
mclient_v = auth_client(manager_view_only)
|
||||
get_m_v = mclient_v.get(f"{API_BASE}{req.id}/comments/")
|
||||
assert (
|
||||
get_m_v.status_code == 200
|
||||
), "С правом view_requestcomment список должен быть доступен"
|
||||
|
||||
mclient = auth_client(manager)
|
||||
get_m = mclient.get(f"{API_BASE}{req.id}/comments/")
|
||||
|
||||
assert get_m.status_code == 200, "Пользователь с правами должен видеть список"
|
||||
data = get_m.json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
post_m = mclient.post(
|
||||
post_m_v = mclient_v.post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "от менеджера"},
|
||||
data={"text": "попытка без add"},
|
||||
format="json",
|
||||
)
|
||||
assert post_m.status_code == 201, "Пользователь с правами должен создавать комментарии"
|
||||
body = post_m.json()
|
||||
assert body.get("text") == "от менеджера"
|
||||
assert (
|
||||
post_m_v.status_code == 403
|
||||
), "Одно чтение не даёт права создавать комментарий"
|
||||
|
||||
# --- Менеджер: добавляем право на добавление, теперь должно быть можно ---
|
||||
grant_model_perm(manager_view_only, "requests_app.add_requestcomment")
|
||||
manager_view_only = _reload(manager_view_only)
|
||||
|
||||
post_m_va = auth_client(manager_view_only).post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "от менеджера после выдачи add"},
|
||||
format="json",
|
||||
)
|
||||
assert (
|
||||
post_m_va.status_code == 201
|
||||
), "При наличии view+add создание должно быть разрешено"
|
||||
assert post_m_va.json().get("text") == "от менеджера после выдачи add"
|
||||
|
||||
# --- Менеджер: только право добавления (без чтения) — ок ---
|
||||
manager_add_only = make_user(email="manager-comments-add@example.com")
|
||||
grant_model_perm(manager_add_only, "requests_app.add_requestcomment")
|
||||
manager_view_only = _reload(manager_view_only)
|
||||
|
||||
post_m_a = auth_client(manager_add_only).post(
|
||||
f"{API_BASE}{req.id}/comments/",
|
||||
data={"text": "только add, без view"},
|
||||
format="json",
|
||||
)
|
||||
assert (
|
||||
post_m_a.status_code == 201
|
||||
), "Без прав на чтение комментарий свободно создается"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 8) ФИЛЬТРЫ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flt,expected_types",
|
||||
[
|
||||
@ -450,9 +454,8 @@ def test_filters_type_status_applied_within_scope(
|
||||
# 9) БЕЗОПАСНОСТЬ / ПРОЧЕЕ
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
def test_invalid_filter_does_not_crash(
|
||||
auth_client, admin_user: models.Model
|
||||
) -> None:
|
||||
|
||||
def test_invalid_filter_does_not_crash(auth_client, admin_user: models.Model) -> None:
|
||||
"""Странные значения фильтров не приводят к 500."""
|
||||
client = auth_client(admin_user)
|
||||
resp = client.get(f"{API_BASE}?type=__sql__' OR 1=1 --&status=")
|
||||
|
||||
273
backend/tests/api/v1/requests_app/test_requests_departments.py
Normal file
273
backend/tests/api/v1/requests_app/test_requests_departments.py
Normal file
@ -0,0 +1,273 @@
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
|
||||
import pytest
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
# Константа API — как и в существующих тестах
|
||||
API_BASE = "/api/v1/requests/"
|
||||
|
||||
|
||||
from employees.models import (
|
||||
Department,
|
||||
DepartmentRole,
|
||||
DepartmentPermission,
|
||||
EmployeeDepartment,
|
||||
)
|
||||
|
||||
def _grant_dept_perm(user, dept, code: str) -> None:
|
||||
"""Выдаёт пользователю департаментное право `code` для отдела `dept`.
|
||||
|
||||
Делает так:
|
||||
- создаёт/находит DepartmentPermission(code)
|
||||
- создаёт/находит DepartmentRole(department=dept, name='auto-{code}')
|
||||
- добавляет право в role.scoped_permissions
|
||||
- создаёт/активирует EmployeeDepartment(user, dept, role=эта_роль)
|
||||
"""
|
||||
perm, _ = DepartmentPermission.objects.get_or_create(
|
||||
code=code, defaults={"name": code}
|
||||
)
|
||||
role, _ = DepartmentRole.objects.get_or_create(
|
||||
department=dept, name=f"auto-{code}"
|
||||
)
|
||||
role.scoped_permissions.add(perm)
|
||||
|
||||
link, _ = EmployeeDepartment.objects.get_or_create(
|
||||
employee=user, department=dept, defaults={"is_active": True}
|
||||
)
|
||||
link.is_active = True
|
||||
link.role = role
|
||||
link.save(update_fields=["is_active", "role"])
|
||||
|
||||
|
||||
# ЗАМЕНИТЕ фикстуры two_departments / dept_dataset на версии ниже
|
||||
# (главное — создаём EmployeeDepartment для владельцев):
|
||||
|
||||
import pytest
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
def _random_email(prefix: str) -> str:
|
||||
return f"{prefix}-{get_random_string(6).lower()}@example.com"
|
||||
|
||||
@pytest.fixture
|
||||
def two_departments(db):
|
||||
d1 = Department.objects.create(name=f"Dept A {get_random_string(4)}")
|
||||
d2 = Department.objects.create(name=f"Dept B {get_random_string(4)}")
|
||||
return d1, d2
|
||||
|
||||
@pytest.fixture
|
||||
def dept_dataset(db, make_user, make_request, two_departments):
|
||||
"""owner1 в dept1 + его заявка; owner2 в dept2 + его заявка.
|
||||
Привязка к отделам — через EmployeeDepartment (is_active=True).
|
||||
"""
|
||||
d1, d2 = two_departments
|
||||
owner1 = make_user(email=_random_email("owner1"))
|
||||
owner2 = make_user(email=_random_email("owner2"))
|
||||
|
||||
EmployeeDepartment.objects.get_or_create(
|
||||
employee=owner1, department=d1, defaults={"is_active": True}
|
||||
)
|
||||
EmployeeDepartment.objects.get_or_create(
|
||||
employee=owner2, department=d2, defaults={"is_active": True}
|
||||
)
|
||||
|
||||
req1 = make_request(employee=owner1)
|
||||
req2 = make_request(employee=owner2)
|
||||
# Если у Request есть FK department (есть) — выставим для консистентности:
|
||||
if hasattr(req1, "department_id"):
|
||||
req1.department = d1
|
||||
req1.save(update_fields=["department"])
|
||||
if hasattr(req2, "department_id"):
|
||||
req2.department = d2
|
||||
req2.save(update_fields=["department"])
|
||||
|
||||
return {"dept1": d1, "dept2": d2, "owner1": owner1, "owner2": owner2, "req1": req1, "req2": req2}
|
||||
|
||||
# ---------- Тесты видимости ----------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_viewer_sees_only_own_department_requests(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Роль/право отдела 'view_request': видит заявки своего отдела и не видит чужой."""
|
||||
data = dept_dataset
|
||||
manager = make_user(email=_random_email("manager-view"))
|
||||
# Привязываем менеджера к dept1, если у пользователя есть поле department
|
||||
if hasattr(manager, "department_id"):
|
||||
setattr(manager, "department", data["dept1"])
|
||||
manager.save(update_fields=["department"])
|
||||
|
||||
_grant_dept_perm(manager, data["dept1"], "view_request")
|
||||
|
||||
client = auth_client(manager)
|
||||
resp = client.get(API_BASE)
|
||||
assert resp.status_code == 200, resp.content
|
||||
items = resp.json()
|
||||
ids = {x["id"] for x in items}
|
||||
|
||||
assert data["req1"].id in ids, "должен видеть заявки своего отдела"
|
||||
assert data["req2"].id not in ids, "не должен видеть заявки другого отдела"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_viewer_cannot_retrieve_other_department_request(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Роль/право отдела 'view_request': детальный просмотр чужого отдела запрещён."""
|
||||
data = dept_dataset
|
||||
manager = make_user(email=_random_email("manager-view2"))
|
||||
if hasattr(manager, "department_id"):
|
||||
setattr(manager, "department", data["dept1"])
|
||||
manager.save(update_fields=["department"])
|
||||
|
||||
_grant_dept_perm(manager, data["dept1"], "view_request")
|
||||
|
||||
client = auth_client(manager)
|
||||
# Чужой отдел
|
||||
resp = client.get(f"{API_BASE}{data['req2'].id}/")
|
||||
assert resp.status_code == 403, resp.content
|
||||
|
||||
|
||||
# ---------- Статусные экшены (approve/reject) ----------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_processor_can_approve_own_department_request(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Пользователь с департаментным правом обработки может approve в своём отделе."""
|
||||
data = dept_dataset
|
||||
processor = make_user(email=_random_email("processor"))
|
||||
if hasattr(processor, "department_id"):
|
||||
setattr(processor, "department", data["dept1"])
|
||||
processor.save(update_fields=["department"])
|
||||
|
||||
for code in ("can_process_requests", "change_request"):
|
||||
_grant_dept_perm(processor, data["dept1"], code)
|
||||
|
||||
client = auth_client(processor)
|
||||
resp = client.post(f"{API_BASE}{data['req1'].id}/approve/")
|
||||
assert resp.status_code in (200, 201), resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_processor_cannot_approve_other_department_request(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Пользователь с департаментным правом обработки НЕ может approve в чужом отделе."""
|
||||
data = dept_dataset
|
||||
processor = make_user(email=_random_email("processor2"))
|
||||
if hasattr(processor, "department_id"):
|
||||
setattr(processor, "department", data["dept1"])
|
||||
processor.save(update_fields=["department"])
|
||||
|
||||
for code in ("can_process_requests", "change_request"):
|
||||
_grant_dept_perm(processor, data["dept1"], code)
|
||||
|
||||
client = auth_client(processor)
|
||||
resp = client.post(f"{API_BASE}{data['req2'].id}/approve/")
|
||||
assert resp.status_code == 403, resp.content
|
||||
|
||||
|
||||
# ---------- Комментарии по отделам ----------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_comment_viewer_can_read_comments_in_department(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Право отдела 'view_requestcomment' даёт доступ к GET /comments/ в своём отделе."""
|
||||
data = dept_dataset
|
||||
viewer = make_user(email=_random_email("comm-view"))
|
||||
if hasattr(viewer, "department_id"):
|
||||
setattr(viewer, "department", data["dept1"])
|
||||
viewer.save(update_fields=["department"])
|
||||
|
||||
_grant_dept_perm(viewer, data["dept1"], "view_requestcomment")
|
||||
|
||||
client = auth_client(viewer)
|
||||
resp = client.get(f"{API_BASE}{data['req1'].id}/comments/")
|
||||
assert resp.status_code == 200, resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_comment_adder_can_post_in_department(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Право отдела 'add_requestcomment' даёт доступ к POST /comments/ в своём отделе."""
|
||||
data = dept_dataset
|
||||
adder = make_user(email=_random_email("comm-add"))
|
||||
if hasattr(adder, "department_id"):
|
||||
setattr(adder, "department", data["dept1"])
|
||||
adder.save(update_fields=["department"])
|
||||
|
||||
_grant_dept_perm(adder, data["dept1"], "add_requestcomment")
|
||||
|
||||
client = auth_client(adder)
|
||||
resp = client.post(
|
||||
f"{API_BASE}{data['req1'].id}/comments/", data={"text": "ok"}, format="json"
|
||||
)
|
||||
assert resp.status_code == 201, resp.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_dept_comment_viewer_forbidden_in_other_department(
|
||||
auth_client, make_user, dept_dataset
|
||||
):
|
||||
"""Право отдела 'view_requestcomment' НЕ раскрывает комментарии чужого отдела."""
|
||||
data = dept_dataset
|
||||
viewer = make_user(email=_random_email("comm-view2"))
|
||||
if hasattr(viewer, "department_id"):
|
||||
setattr(viewer, "department", data["dept1"])
|
||||
viewer.save(update_fields=["department"])
|
||||
|
||||
_grant_dept_perm(viewer, data["dept1"], "view_requestcomment")
|
||||
|
||||
client = auth_client(viewer)
|
||||
resp = client.get(f"{API_BASE}{data['req2'].id}/comments/")
|
||||
assert resp.status_code == 403, resp.content
|
||||
|
||||
|
||||
# ---------- Создание/валидация department ----------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_regular_user_cannot_set_foreign_department_on_create(
|
||||
auth_client, regular_user, two_departments
|
||||
):
|
||||
"""Обычный пользователь не должен уметь подставлять чужой department при создании.
|
||||
|
||||
Ожидаем: либо игнор (проставится свой отдел), либо 400/403.
|
||||
"""
|
||||
d1, d2 = two_departments
|
||||
# Привяжем пользователя к dept1, если поле есть:
|
||||
if hasattr(regular_user, "department_id"):
|
||||
setattr(regular_user, "department", d1)
|
||||
regular_user.save(update_fields=["department"])
|
||||
|
||||
client = auth_client(regular_user)
|
||||
payload = {
|
||||
"type": "vacation",
|
||||
"title": "Попытка в чужой отдел",
|
||||
"comment": "…",
|
||||
# пробуем подменить отдел
|
||||
"department": getattr(d2, "id", None),
|
||||
}
|
||||
resp = client.post(API_BASE, data=payload, format="json")
|
||||
|
||||
# Допустимы два корректных варианта поведения: запрет или игнор чужого отдела.
|
||||
if resp.status_code in (400, 403):
|
||||
return
|
||||
assert resp.status_code == 201, resp.content
|
||||
data = resp.json()
|
||||
# Если заявка создалась — проверяем, что отдел НЕ чужой
|
||||
if (
|
||||
"department" in data
|
||||
and isinstance(data["department"], dict)
|
||||
and "id" in data["department"]
|
||||
):
|
||||
assert data["department"]["id"] != getattr(d2, "id", -1)
|
||||
Loading…
x
Reference in New Issue
Block a user