diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 29215c59..ae96ff8a 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -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) diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index beec4922..2988b746 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -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_`; - * для остальных методов используем стандартную карту `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_, остальные — по 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_ 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", diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py new file mode 100644 index 00000000..a60eeec3 --- /dev/null +++ b/backend/api/v1/requests_app/permissions.py @@ -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 # другие методы не поддерживаем diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index 9aebdeb6..a395344b 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -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): """Сериализатор комментариев к заявке.""" diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index d199aa29..d30e01be 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -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) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index c171b2e3..71fc9f2d 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -255,4 +255,19 @@ SIMPLE_JWT = { "LEEWAY": 30, # допускаем ±30с рассинхронизации времени } -API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api") \ No newline at end of file +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', +# }, +# }, +# } \ No newline at end of file diff --git a/backend/requests_app/views.py b/backend/requests_app/views.py index 47d606f0..1bd73ff2 100644 --- a/backend/requests_app/views.py +++ b/backend/requests_app/views.py @@ -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)) + + + diff --git a/backend/requests_app/views_front.py b/backend/requests_app/views_front.py new file mode 100644 index 00000000..92d0ad1c --- /dev/null +++ b/backend/requests_app/views_front.py @@ -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) diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 405a5a7b..7c5e7c41 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -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) diff --git a/backend/tests/api/v1/feed/test_posts.py b/backend/tests/api/v1/feed/test_posts.py index 3f8262bc..3303c504 100644 --- a/backend/tests/api/v1/feed/test_posts.py +++ b/backend/tests/api/v1/feed/test_posts.py @@ -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") diff --git a/backend/tests/api/v1/requests_app/conftest.py b/backend/tests/api/v1/requests_app/conftest.py index 86c8e5ff..bea95f52 100644 --- a/backend/tests/api/v1/requests_app/conftest.py +++ b/backend/tests/api/v1/requests_app/conftest.py @@ -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 diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index e69de109..3a955e90 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -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=") diff --git a/backend/tests/api/v1/requests_app/test_requests_departments.py b/backend/tests/api/v1/requests_app/test_requests_departments.py new file mode 100644 index 00000000..8dde8a86 --- /dev/null +++ b/backend/tests/api/v1/requests_app/test_requests_departments.py @@ -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)