diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index 2988b746..2dba5eca 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -1,23 +1,22 @@ # backend\api\v1\permissions.py from __future__ import annotations -from typing import Any, Dict, Iterable, Optional, Tuple +from typing import Any, Dict, Iterable, Optional, Tuple, Type from django.contrib.auth.models import AbstractBaseUser, AnonymousUser +from django.db.models import Model from employees.constants import DeptPerm from employees.models import Department, EmployeeDepartment -from rest_framework.permissions import ( - SAFE_METHODS, - BasePermission, - DjangoModelPermissions, -) +from rest_framework.permissions import (SAFE_METHODS, BasePermission, + DjangoModelPermissions) +from rest_framework.request import Request MANAGE_PERM = "manage_department" 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) ) @@ -309,43 +308,113 @@ class AdminOrDeptAllowed(BasePermission): class IsSelfOrStaff(BasePermission): - """Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью).""" + """Доступ для staff/superuser или владельца объекта. - DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id") - DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee") + ВАЖНО: + • SAFE-методы НЕ дают автоматического допуска. + • Добавлена поддержка кейса "объект = сам пользователь" (self-update). + """ - def has_permission(self, request, view) -> bool: + DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = ( + "employee_id", + "author_id", + "user_id", + "owner_id", + ) + DEFAULT_OWNER_OBJ_ATTRS: Tuple[str, ...] = ("employee", "author", "user", "owner") + + def has_permission(self, request: Request, view) -> bool: + """Пускаем только аутентифицированных. + + Args: + request (Request): DRF-запрос. + view: DRF ViewSet. + + Returns: + bool: True если пользователь аутентифицирован. + """ user = getattr(request, "user", None) return bool(user and user.is_authenticated) - def has_object_permission(self, request, view, obj): - user = request.user + def has_object_permission(self, request: Request, view, obj) -> bool: + """Разрешает доступ staff/superuser или владельцу объекта. + + Логика владельца: + 1) Совпадение *_id полей (employee_id/user_id/…) + 2) Совпадение связанных owner-объектов (employee/user/…) + 3) Фоллбек "self-object": obj.__class__ == user.__class__ и obj.pk == user.pk + + Args: + request (Request): DRF-запрос. + view: DRF ViewSet. + obj: Проверяемый объект. + + Returns: + bool: True если доступ разрешён. + + Raises: + Ничего не выбрасывает. + """ + user = getattr(request, "user", None) if not user or not user.is_authenticated: return False - if request.method in SAFE_METHODS: - return True + + # Админский доступ if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True - # Прочитаем конфиг владельческих полей с вью (если задан) - owner_id_attrs = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS) - owner_obj_attrs = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS) - uid = getattr(user, "id", None) - + # 1) *_id поля владельца + owner_id_attrs: Iterable[str] = getattr( + view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS + ) for attr in owner_id_attrs: - if hasattr(obj, attr) and getattr(obj, attr) == uid: + if getattr(obj, attr, None) == user.id: return True + + # 2) связанные owner-объекты + owner_obj_attrs: Iterable[str] = getattr( + view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS + ) for attr in owner_obj_attrs: owner = getattr(obj, attr, None) - if owner is not None and getattr(owner, "id", None) == uid: + if owner is not None and getattr(owner, "id", None) == user.id: return True - # Fallback для вью «/users/{id}/» - lookup = getattr(view, "lookup_field", "pk") - return str(view.kwargs.get(lookup)) == str(uid) + # 3) self-object: редактирование собственной учётки (Employee ↔ Employee) + try: + if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr( + user, "pk", None + ): + return True + except Exception: + # максимально безопасно: не пускаем, если что-то пошло не так + pass + + return False class AdminOrActionOrModelPerms(DjangoModelPermissions): + """staff/superuser ИЛИ явные пермы экшена ИЛИ стандартные DjangoModelPermissions. + + Логика: + 1) staff/superuser → True + 2) Если на вью заданы «явные» коды прав для текущего action (метод `_explicit_perm_codes`), + достаточно ЛЮБОГО из них → True/False + 3) Иначе — делегируем в DjangoModelPermissions.has_permission(): + - для SAFE_METHODS потребует `view_` + - для небезопасных — коды из perms_map (add/change/delete) + """ + + perms_map = { + "GET": ["%(app_label)s.view_%(model_name)s"], + "OPTIONS": [], + "HEAD": [], + "POST": ["%(app_label)s.add_%(model_name)s"], + "PUT": ["%(app_label)s.change_%(model_name)s"], + "PATCH": ["%(app_label)s.change_%(model_name)s"], + "DELETE": ["%(app_label)s.delete_%(model_name)s"], + } + @staticmethod def _normalize_codes(value: Any) -> Optional[list[str]]: """Приводит конфигурацию кодов прав к списку строк. @@ -436,16 +505,18 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions): # Иное — игнорируем return None - def has_permission(self, request, view) -> bool: - """Проверка прав на уровне запроса. + def has_permission(self, request: Request, view) -> bool: + """Проверяет доступ на уровне запроса. - Логика: - - staff/superuser → True - - если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них - - иначе: для SAFE-методов требуем view_, остальные — по DjangoModelPermissions + Args: + request (Request): DRF-запрос. + view: DRF View/ViewSet. Returns: bool: Разрешён ли доступ. + + Raises: + PermissionDenied: не выбрасывается здесь; при False DRF отдаст 403. """ user = getattr(request, "user", None) if not (user and user.is_authenticated): @@ -453,16 +524,14 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions): if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True - explicit = self._explicit_perm_codes(view) + # 1) Явные пермы, привязанные к action (если у тебя есть такой механизм) + explicit: Optional[Iterable[str]] = getattr( + self, "_explicit_perm_codes", lambda _v: None + )(view) if explicit: return any(user.has_perm(code) for code in explicit) - 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}" - ) - + # 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_) return super().has_permission(request, view) diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py index a60eeec3..adae1cac 100644 --- a/backend/api/v1/requests_app/permissions.py +++ b/backend/api/v1/requests_app/permissions.py @@ -6,55 +6,35 @@ from typing import Any, Optional from api.v1.permissions import AdminOrDeptAllowed from rest_framework.permissions import BasePermission from rest_framework.request import Request +from employees.constants import DeptPerm class CommentsPermission(BasePermission): - """Права доступа к экшену `comments`. + """Доступ к экшену `comments` по *модельным* правам. - - GET/HEAD: staff или право `requests_app.view_requestcomment`. - - POST: staff или право `requests_app.add_requestcomment`. - - Владелец заявки без модельных прав доступа не имеет. - - Raises: - Ничего не выбрасывает: при отказе возвращает False → DRF вернёт 403. + - GET/HEAD: `requests_app.view_requestcomment` ИЛИ staff. + - POST: `requests_app.add_requestcomment` ИЛИ staff. + - Владелец без прав — НЕТ. """ message = "Недостаточно прав для доступа к комментариям." + def _required_perm(self, request: Request) -> Optional[str]: + if request.method in {"GET", "HEAD"}: + return "requests_app.view_requestcomment" + if request.method == "POST": + return "requests_app.add_requestcomment" + return None + 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): + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", 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 + code = self._required_perm(request) + return bool(code and user.has_perm(code)) class NotFinalOrStaff(BasePermission): @@ -92,33 +72,144 @@ class NotFinalOrStaff(BasePermission): class DeptViewRequest(AdminOrDeptAllowed): - """Чтение заявки: staff ИЛИ право отдела 'view_request'.""" + """Просмотр заявки по департаментным правам. - required_code = "view_request" + Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет + право отдела ``view_request`` именно для отдела данной заявки. + + Применяется только к detail-экшену `retrieve`. + + Attributes: + allow_safe_without_code (bool): Запрещает фоллбек пропуска SAFE-методов + без явного кода права (защита от «случайных 200»). + required_code_map (dict[str, str]): Ограничиваем право только на `retrieve`. + required_code (Optional[str]): Явно отключаем общий fallback. + """ + + # SAFE-методы не должны проходить «на шару» + allow_safe_without_code: bool = False + + # Явно ограничиваем сферу действия права + required_code_map: dict[str, str] = { + "retrieve": "view_request", + } + + # Не даём базовому классу брать «любой экшен = view_request» + required_code: Optional[str] = None + + def get_required_code(self, request: Request, view: Any) -> Optional[str]: + """Возвращает код департаментного права только для поддерживаемых action. + + Args: + request (Request): DRF-запрос. + view (Any): Вью/вьюсет; важен `view.action`. + + Returns: + Optional[str]: 'view_request' для `retrieve`, иначе None (запрет). + """ + return self.required_code_map.get(getattr(view, "action", None)) class DeptCanProcess(AdminOrDeptAllowed): - """Обработка заявок: staff ИЛИ право отдела 'can_process_requests'.""" + """Пермишен для обработки заявок по департаментным правам. - required_code = "can_process_requests" + Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет + право отдела ``can_process_requests`` для отдела заявки. + + Применяется на детальных экшенах workflow: + - approve + - reject + + Атрибуты: + allow_safe_without_code (bool): Запретить «фоллбек» на SAFE-методы + без явного кода права (по умолчанию в базовом классе мог быть True). + required_code_map (dict[str, str]): Маппинг action → код права. + required_code (str): Резервный код права, если action не сматчился. + + Raises: + Ничего напрямую не выбрасывает — при отсутствии прав вернёт False, + DRF ответит 403 Forbidden. + """ + + # SAFE-методы пусть тоже требуют явного кода (на всякий случай) + allow_safe_without_code: bool = False + + # Явно описываем нужные action-ы workflow + required_code_map: dict[str, str] = { + "approve": "can_process_requests", + "reject": "can_process_requests", + } + + # Резервный вариант — если вдруг action неизвестен, + # базовый класс возьмёт этот код. + required_code: str = "can_process_requests" class DeptChangeRequest(AdminOrDeptAllowed): - """Правка заявок: staff ИЛИ право отдела 'change_request'.""" + """Правка заявок по департаментным правам. - required_code = "change_request" + Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет + право отдела ``change_request`` для отдела конкретной заявки. + + Применяется ТОЛЬКО к экшенам редактирования: + - update (PUT) + - partial_update (PATCH) + - destroy (DELETE) + + ВНИМАНИЕ: для workflow-экшенов (approve/reject) используйте DeptCanProcess. + + Attributes: + allow_safe_without_code (bool): запрещает фоллбек пропуска SAFE-методов + без явного кода права (защита от случайных «200»). + """ + + # Не пропускаем SAFE-методы без явного кода (страховка) + allow_safe_without_code: bool = False + + def get_required_code(self, request: Request, view: Any) -> Optional[str]: + """Возвращает код департаментного права для экшенов редактирования. + + Args: + request (Request): DRF-запрос. + view (Any): Вью/вьюсет (важен view.action). + + Returns: + Optional[str]: 'change_request' для update/partial_update/destroy, иначе None. + + Notes: + Возврат None означает «кода нет» → базовый класс вернёт False (403), + так мы исключаем approve/reject и прочие экшены из этого пермишена. + """ + if getattr(view, "action", None) in {"update", "partial_update", "destroy"}: + return "change_request" + return None class DeptComments(AdminOrDeptAllowed): - """Комментарии: staff ИЛИ право отдела - - GET/HEAD: 'view_requestcomment' - - POST: 'add_requestcomment' + """Комментарии: staff ИЛИ департаментное право. + + - GET/HEAD → 'view_requestcomment' + - POST → 'add_requestcomment' """ - def _required_code(self, view: Any, request: Request) -> Optional[str]: - # Метод-чувствительная логика: GET/HEAD → view, POST → add + # Чтобы SAFE-методы не проходили «без кода» при ошибке конфигурации: + allow_safe_without_code = False + + def get_required_code(self, request: Request, view: Any) -> Optional[str]: + """Возвращает код департаментного права для экшена `comments`. + + Args: + request (Request): Текущий запрос DRF. + view (Any): Вью/вьюсет (не используется). + + Returns: + Optional[str]: Код права для текущего HTTP-метода или None для неподдерживаемых методов. + + Raises: + None: Исключения не выбрасываются — при отсутствии кода доступ будет запрещён базовым классом. + """ if request.method in {"GET", "HEAD"}: - return "view_requestcomment" + return DeptPerm.VIEW_REQUESTCOMMENT if request.method == "POST": - return "add_requestcomment" - return None # другие методы не поддерживаем + return DeptPerm.ADD_REQUESTCOMMENT + return None \ No newline at end of file diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index a395344b..7ee86646 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -3,15 +3,17 @@ from __future__ import annotations from typing import Any, Mapping from django.contrib.auth import get_user_model +from requests_app.enums import RequestStatus, RequestType from requests_app.models import Request, RequestComment from rest_framework import serializers from ..employees.serializers import EmployeeBriefSerializer +from .validators import (RequestApproverNotEmployeeValidator, + RequestDatesByTypeValidator) User = get_user_model() - class RequestReadSerializer(serializers.ModelSerializer): """Сериализатор чтения заявки. @@ -55,7 +57,6 @@ class RequestReadSerializer(serializers.ModelSerializer): ) - class RequestWriteSerializer(serializers.ModelSerializer): """Сериализатор записи заявки. @@ -74,7 +75,7 @@ class RequestWriteSerializer(serializers.ModelSerializer): queryset=User.objects.all(), required=False, allow_null=True ) status = serializers.ChoiceField( - choices=Request.STATUS_CHOICES, required=False, allow_null=True + choices=RequestStatus, required=False, allow_null=True ) approver = serializers.PrimaryKeyRelatedField( queryset=User.objects.all(), required=False, allow_null=True @@ -96,6 +97,10 @@ class RequestWriteSerializer(serializers.ModelSerializer): "attachment", ) read_only_fields = ("id",) + validators = [ + RequestDatesByTypeValidator(), + RequestApproverNotEmployeeValidator(), + ] def _is_power(self) -> bool: """Определяет, имеет ли пользователь расширенные права на создание/изменение. @@ -141,13 +146,34 @@ class RequestWriteSerializer(serializers.ModelSerializer): Returns: Request: Созданная заявка. + + Raises: + serializers.ValidationError: Если не удалось определить автора. """ + request = self.context.get("request") + user = getattr(request, "user", None) + + if not user or getattr(user, "is_anonymous", False): + raise serializers.ValidationError( + {"detail": "Требуется аутентификация для создания заявки."} + ) + if not self._is_power(): - validated_data["employee"] = self.context["request"].user + # обычный пользователь: запрещённые поля убираем/переписываем validated_data.pop("status", None) validated_data.pop("approver", None) + validated_data["employee"] = user + else: + # staff/менеджер: если employee не указан — ставим текущего + validated_data.setdefault("employee", user) + + if not validated_data.get("employee"): + raise serializers.ValidationError( + {"employee": "Автор должен быть установлен сервером."} + ) + return super().create(validated_data) - + class RequestCommentSerializer(serializers.ModelSerializer): """Сериализатор комментариев к заявке.""" diff --git a/backend/api/v1/requests_app/validators.py b/backend/api/v1/requests_app/validators.py new file mode 100644 index 00000000..ee926cee --- /dev/null +++ b/backend/api/v1/requests_app/validators.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Any, Mapping, Optional + +from django.utils.translation import gettext_lazy as _ +from rest_framework.exceptions import ValidationError + +from requests_app.enums import RequestType # VACATION/SICK_LEAVE/TRANSFER/DISMISSAL/OTHER + + +class RequestDatesByTypeValidator: + """Мягкая проверка дат по типу заявки. + + Правила: + - Проверяем ТОЛЬКО если в payload присутствуют date_from/date_to ИЛИ меняется type. + - Для VACATION/SICK_LEAVE: если хотя бы одна дата передана — требуются обе; date_to >= date_from. + - Для TRANSFER/DISMISSAL: если даты трогают — обязателен date_from. + - Для OTHER: дат можно не указывать. + """ + + requires_context = True + + def __call__(self, attrs: Mapping[str, Any], serializer) -> None: + instance = getattr(serializer, "instance", None) + + # Определяем «эффективные» значения с учётом instance + type_ = attrs.get("type", getattr(instance, "type", None)) + date_from = attrs.get("date_from", getattr(instance, "date_from", None)) + date_to = attrs.get("date_to", getattr(instance, "date_to", None)) + + # Меняем ли мы даты/тип в этом запросе? + touches_dates = any(k in attrs for k in ("date_from", "date_to")) + touches_type = "type" in attrs + + # Если ни тип, ни даты не трогают — ничего не проверяем + if not (touches_dates or touches_type): + return + + if type_ in (RequestType.VACATION, RequestType.SICK_LEAVE): + # Если пользователь прислал хотя бы одно из полей даты — должны быть обе + if touches_dates and (not date_from or not date_to): + raise ValidationError( + {"date_from": _("Требуются обе даты."), + "date_to": _("Требуются обе даты.")} + ) + if date_from and date_to and date_to < date_from: + raise ValidationError({"date_to": _("Дата окончания раньше даты начала.")}) + + elif type_ in (RequestType.TRANSFER, RequestType.DISMISSAL): + if touches_dates and not date_from: + raise ValidationError({"date_from": _("Требуется дата начала.")}) + # OTHER — ограничений нет + + +class RequestApproverNotEmployeeValidator: + """Запрещает совпадение согласующего и автора. + + Учитывает частичное обновление: берёт значения из attrs поверх instance. + """ + + requires_context = True + + def __call__(self, attrs: Mapping[str, Any], serializer) -> None: + instance = getattr(serializer, "instance", None) + + def eff(name: str) -> Any: + if name in attrs: + return attrs[name] + return getattr(instance, name, None) if instance is not None else None + + employee = eff("employee") or getattr(serializer.context.get("request"), "user", None) + approver = eff("approver") + + if employee and approver and employee == approver: + raise ValidationError({"approver": _("Согласующий не может совпадать с автором.")}) diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index d30e01be..4072dd68 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -7,25 +7,38 @@ 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 requests_app.enums import RequestStatus + # 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, ValidationError +from rest_framework.exceptions import ( + NotAuthenticated, + PermissionDenied, + ValidationError, +) from rest_framework.parsers import FormParser, JSONParser, MultiPartParser -from rest_framework.permissions import (SAFE_METHODS, BasePermission, - 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 ..permissions import (AdminOrActionOrModelPerms, AdminOrDeptAllowed, - IsSelfOrStaff) -from .permissions import (CommentsPermission, DeptCanProcess, - DeptChangeRequest, DeptComments, DeptViewRequest, - NotFinalOrStaff) -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): @@ -50,19 +63,14 @@ class RequestViewSet(viewsets.ModelViewSet): parser_classes = (MultiPartParser, FormParser, JSONParser) pagination_class = None - # Декларативная карта прав. Поддерживаются: - # - строка ("approve": "app.perm") - # - список (ИЛИ прав) - # - карта по методам для action ("comments": {"GET": "...", "POST": "..."}). required_perms_by_action = { "comments": { "GET": "requests_app.view_requestcomment", - "HEAD": "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]: @@ -74,14 +82,19 @@ class RequestViewSet(viewsets.ModelViewSet): - остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права. """ if self.action == "comments": - return [CommentsPermission()] + return [(CommentsPermission | DeptComments)()] if self.action in {"approve", "reject"}: - return [IsAuthenticated(), (AdminOrActionOrModelPerms)()] + return [(AdminOrActionOrModelPerms | DeptCanProcess)()] if self.action == "cancel": - return [IsAuthenticated(), IsSelfOrStaff()] + return [IsSelfOrStaff()] + if self.action == "retrieve": + return [(IsSelfOrStaff | DeptViewRequest | AdminOrActionOrModelPerms)()] if self.action == "destroy": - return [IsAuthenticated(), IsSelfOrStaff(), NotFinalOrStaff()] - return [(IsSelfOrStaff | AdminOrActionOrModelPerms)()] + return [ + (IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(), + NotFinalOrStaff(), + ] + return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)()] def get_serializer_class(self) -> type[BaseSerializer]: """Возвращает сериализатор в зависимости от action.""" @@ -109,16 +122,20 @@ class RequestViewSet(viewsets.ModelViewSet): 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"}) + want_mine = (params.get("view") == "mine") or ( + mine_raw in {"1", "true", "yes", "on"} + ) if user.is_staff or self._can_view_all(user): if want_mine: qs = qs.filter(employee_id=user.id) else: - # отделы, где у пользователя есть dept-право 'view_request' dept_ids_qs = ( - EmployeeDepartment.objects - .filter(employee_id=user.id, is_active=True, role__scoped_permissions__code="view_request") + EmployeeDepartment.objects.filter( + employee_id=user.id, + is_active=True, + role__scoped_permissions__code="view_request", + ) .values_list("department_id", flat=True) .distinct() ) @@ -136,27 +153,35 @@ class RequestViewSet(viewsets.ModelViewSet): # --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() --- - def get_object(self): - """Возвращает объект для detail-экшенов. + def get_object(self) -> EmployeeRequest: + """Возвращает объект заявки для detail-экшенов. - Для 'approve', 'reject', 'comments', 'cancel' берём объект из полного - набора записей по первичному ключу (в обход get_queryset()), затем - вызываем check_object_permissions() — чтобы объектные пермишены отработали. - Для остальных действий — стандартное поведение базового класса. + Для экшенов {'approve', 'reject', 'comments', 'cancel', 'retrieve'}: + - достаём объект **в обход** урезанного get_queryset() по PK, + - прогоняем через object-level пермишены (check_object_permissions), + чтобы при отсутствии прав получить **403 Forbidden**, а не 404. Returns: EmployeeRequest: Объект заявки. Raises: - Http404: Если объект не найден. - PermissionDenied: Если объектные права не пройдены. + Http404: Если объект с таким PK не существует вообще. + PermissionDenied: Если object-level пермишены не пройдены. """ - if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel"}: + if getattr(self, "action", None) in { + "approve", + "reject", + "comments", + "cancel", + "retrieve", + }: 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}) + lookup_value = self.kwargs[lookup_url_kwarg] + base = EmployeeRequest.objects.select_related("employee", "approver", "department") + obj = get_object_or_404(base, **{self.lookup_field: lookup_value}) self.check_object_permissions(self.request, obj) return obj + return super().get_object() # ---------- CRUD ---------- @@ -164,8 +189,14 @@ class RequestViewSet(viewsets.ModelViewSet): def perform_create(self, serializer: RequestWriteSerializer) -> None: """Создание: для не-админа принудительно ставим себя как автора.""" user = self.request.user + if not user or not user.is_authenticated: + raise NotAuthenticated("Authentication required") is_power = user.is_staff or user.has_perm("requests_app.add_request") extra = {"employee": user} if not is_power else {} + save_as = (self.request.query_params.get("save_as") or "").strip().lower() + if save_as == "draft": + extra["status"] = RequestStatus.DRAFT # ✅ обычному пользователю поле в payload всё равно бы «очистили» + serializer.save(**extra) def perform_update(self, serializer: RequestWriteSerializer) -> None: @@ -184,7 +215,16 @@ class RequestViewSet(viewsets.ModelViewSet): user.is_staff or user.has_perm("requests_app.change_request") ): raise PermissionDenied("Нельзя редактировать чужую заявку.") - serializer.save() + extra: dict[str, Any] = {} + save_as = (self.request.query_params.get("save_as") or "").strip().lower() + + if save_as == "draft": + extra["status"] = RequestStatus.DRAFT + elif save_as == "submit" and obj.status == RequestStatus.DRAFT: + # ⬅️ явная подача «на рассмотрение» из черновика + extra["status"] = RequestStatus.PENDING + + serializer.save(**extra) # ---------- Комментарии ---------- @@ -195,7 +235,7 @@ class RequestViewSet(viewsets.ModelViewSet): GET: список комментариев (staff, владелец или право view_requestcomment). POST: создание {"text": "..."} (staff, владелец или право add_requestcomment). """ - req_obj = self.get_object() # проходит через check_object_permissions() + req_obj = self.get_object() if request.method in {"GET", "HEAD"}: qs = RequestComment.objects.filter(request=req_obj).select_related("author") @@ -235,7 +275,7 @@ class RequestViewSet(viewsets.ModelViewSet): Raises: ValidationError: Если заявка уже финальная. """ - obj = self.get_object() # права проверит AdminOrActionOrModelPerms + obj = self.get_object() if getattr(obj, "is_final", False): raise ValidationError("Заявка уже в финальном статусе.") obj.reject(by_user=request.user) @@ -250,7 +290,7 @@ class RequestViewSet(viewsets.ModelViewSet): Raises: PermissionDenied: Если заявка финальная (для не admin). """ - obj = self.get_object() # владение/admin проверит IsSelfOrStaff + obj = self.get_object() if getattr(obj, "is_final", False) and not request.user.is_staff: raise PermissionDenied("Финальная заявка уже не может быть отменена.") obj.cancel() diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py index aceb6242..a7d8c18a 100644 --- a/backend/communications/consumers.py +++ b/backend/communications/consumers.py @@ -2,11 +2,11 @@ from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.contrib.auth.models import AnonymousUser +from django.db.models import Q from django.urls import reverse from django.utils import timezone -from django.db.models import Q -from .models import Chat, Message, ChatReadState +from .models import Chat, ChatReadState, Message def serialize_message(m: Message) -> dict: diff --git a/backend/communications/routing.py b/backend/communications/routing.py index 5255a960..60a55e07 100644 --- a/backend/communications/routing.py +++ b/backend/communications/routing.py @@ -1,6 +1,6 @@ # backend\communications\routing.py -from django.urls import path from django.urls import re_path + from .consumers import ChatConsumer, ChatListConsumer websocket_urlpatterns = [ diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 4725f456..3dc0bfb7 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -34,6 +34,8 @@ class DeptPerm: MANAGE_CALENDAR = "manage_department_events" CREATE_POST = "publish_department_post" MANAGE_FEED = "manage_department_feed" + VIEW_REQUESTCOMMENT = "view_requestcomment" + ADD_REQUESTCOMMENT = "add_requestcomment" CHOICES = ( (MANAGE, "Управлять отделом"), @@ -42,4 +44,6 @@ class DeptPerm: (MANAGE_CALENDAR, "Управлять календарём отдела"), (CREATE_POST, "Публиковать новости на странице отдела"), (MANAGE_FEED, "Редактировать публикации отдела"), - ) + (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"), + (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), + ) \ No newline at end of file diff --git a/backend/employees/migrations/0026_alter_departmentpermission_code.py b/backend/employees/migrations/0026_alter_departmentpermission_code.py new file mode 100644 index 00000000..5fb3ab93 --- /dev/null +++ b/backend/employees/migrations/0026_alter_departmentpermission_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-18 12:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0025_alter_departmentpermission_code'), + ] + + operations = [ + migrations.AlterField( + model_name='departmentpermission', + name='code', + field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям')], max_length=64, unique=True), + ), + ] diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 05d14627..7d677037 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -7,7 +7,7 @@ urlpatterns = [ path("admin/", admin.site.urls), path("auth/", include("employees.urls_front_auth", namespace="auth_front")), path("documents/", include("documents.urls", namespace="documents")), - path("requests/", include("requests_app.urls", namespace="requests_app")), + path("requests/", include("requests_app.urls_front", namespace="requests")), path("employees/", include("employees.urls_front", namespace="employees")), path("communications/", include("communications.urls", namespace="communications")), path("search/", include("search.urls", namespace="search")), diff --git a/backend/requests_app/enums.py b/backend/requests_app/enums.py new file mode 100644 index 00000000..be1ba10e --- /dev/null +++ b/backend/requests_app/enums.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from django.db.models import TextChoices + + +class RequestType(TextChoices): + """Перечень типов заявлений (значение хранится в БД).""" + + VACATION = "vacation", "Отпуск" + SICK_LEAVE = "sick_leave", "Больничный" + TRANSFER = "transfer", "Перевод" + DISMISSAL = "dismissal", "Увольнение" + OTHER = "other", "Другое" + + +class RequestStatus(TextChoices): + """Статусы жизненного цикла заявления (значение хранится в БД).""" + + DRAFT = "draft", "Черновик" + PENDING = "pending", "На рассмотрении" + APPROVED = "approved", "Одобрено" + REJECTED = "rejected", "Отклонено" + CANCELLED = "cancelled", "Отменено" + + +FINAL_STATUS = { + RequestStatus.APPROVED, + RequestStatus.REJECTED, + RequestStatus.CANCELLED, +} diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index ae749e5d..8f1bdc0d 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -9,7 +9,12 @@ from django.db.models import F, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from .constants import MAX_ATTACHMENT_SIZE, SAFE_EXTENSIONS, SAFE_MIME_TYPES +from .constants import ( + MAX_ATTACHMENT_SIZE, + SAFE_EXTENSIONS, + SAFE_MIME_TYPES, +) +from .enums import FINAL_STATUS, RequestStatus, RequestType logger = logging.getLogger(__name__) @@ -57,36 +62,6 @@ def attachment_mime_validator(value): class Request(models.Model): """Заявление сотрудника: тип, даты, статус, согласование и вложение.""" - TYPE_VACATION = "vacation" - TYPE_SICK = "sick_leave" - TYPE_TRANSFER = "transfer" - TYPE_DISMISSAL = "dismissal" - TYPE_OTHER = "other" - - TYPE_CHOICES = [ - (TYPE_VACATION, _("Отпуск")), - (TYPE_SICK, _("Больничный")), - (TYPE_TRANSFER, _("Перевод")), - (TYPE_DISMISSAL, _("Увольнение")), - (TYPE_OTHER, _("Другое")), - ] - - STATUS_DRAFT = "draft" - STATUS_PENDING = "pending" - STATUS_APPROVED = "approved" - STATUS_REJECTED = "rejected" - STATUS_CANCELLED = "cancelled" - - STATUS_CHOICES = [ - (STATUS_DRAFT, _("Черновик")), - (STATUS_PENDING, _("На рассмотрении")), - (STATUS_APPROVED, _("Одобрено")), - (STATUS_REJECTED, _("Отклонено")), - (STATUS_CANCELLED, _("Отменено")), - ] - - FINAL_STATUS_SET = {STATUS_APPROVED, STATUS_REJECTED, STATUS_CANCELLED} - employee = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -109,18 +84,18 @@ class Request(models.Model): verbose_name=_("Отдел"), ) - type = models.CharField(_("Тип заявления"), choices=TYPE_CHOICES, max_length=32) + type = models.CharField( + _("Тип заявления"), choices=RequestType.choices, max_length=32 + ) title = models.CharField(_("Тема"), max_length=200, blank=True) date_from = models.DateField(_("Дата начала"), null=True, blank=True) date_to = models.DateField(_("Дата окончания"), null=True, blank=True) comment = models.TextField(_("Комментарий"), blank=True) - status = models.CharField( - _("Статус"), - choices=STATUS_CHOICES, - max_length=16, - default=STATUS_PENDING, - ) + status = models.CharField(_("Статус"), + choices=RequestStatus.choices, + max_length=16, + default=RequestStatus.PENDING) attachment = models.FileField( _("Вложение"), @@ -166,24 +141,22 @@ class Request(models.Model): ), models.CheckConstraint( name="request_approver_required_on_decision", - condition=Q(status__in=["approved", "rejected"], approver__isnull=False) - | Q( - status__in=["draft", "pending", "cancelled"], approver__isnull=True - ), + condition=Q(status__in=[RequestStatus.APPROVED, RequestStatus.REJECTED], + approver__isnull=False) + | Q(status__in=[RequestStatus.DRAFT, RequestStatus.PENDING, RequestStatus.CANCELLED], + approver__isnull=True), ), ] def clean(self): - if self.type in {self.TYPE_VACATION, self.TYPE_SICK} and not ( + if self.type in {RequestType.VACATION, RequestType.SICK_LEAVE} and not ( self.date_from and self.date_to ): + raise ValidationError( _("Для выбранного типа укажите даты начала и окончания.") ) - if ( - self.type in {self.TYPE_TRANSFER, self.TYPE_DISMISSAL} - and not self.date_from - ): + if self.type in {RequestType.TRANSFER, RequestType.DISMISSAL} and not self.date_from: raise ValidationError(_("Для перевода/увольнения укажите дату начала.")) if self.approver_id and self.approver_id == self.employee_id: @@ -196,40 +169,36 @@ class Request(models.Model): @property def is_final(self): - return self.status in self.FINAL_STATUS_SET + return self.status in FINAL_STATUS def approve(self, by_user): - self.status = self.STATUS_APPROVED + self.status = RequestStatus.APPROVED self.approver = by_user self.decided_at = timezone.now() self.save(update_fields=["status", "approver", "decided_at", "updated_at"]) def reject(self, by_user): - self.status = self.STATUS_REJECTED + self.status = RequestStatus.REJECTED self.approver = by_user self.decided_at = timezone.now() self.save(update_fields=["status", "approver", "decided_at", "updated_at"]) def cancel(self): - self.status = self.STATUS_CANCELLED + self.status = RequestStatus.CANCELLED if self.approver_id is not None: self.approver = None self.decided_at = timezone.now() self.save(update_fields=["status", "approver", "decided_at", "updated_at"]) - def save(self, *args, **kwargs): + def save(self, *args, **kwargs) -> None: + """Синхронизация decided_at/approver с текущим статусом. + + Raises: + ValueError: Если нарушена согласованность полей (редко). """ - Страховка на случай изменения статуса напрямую: - - финальные статусы → ставим decided_at, если пусто; - - draft/pending/cancelled → чистим approver (в синхроне с БД-constraint). - """ - if self.status in self.FINAL_STATUS_SET and not self.decided_at: + if self.status in FINAL_STATUS and not self.decided_at: self.decided_at = timezone.now() - if ( - self.status - in {self.STATUS_DRAFT, self.STATUS_PENDING, self.STATUS_CANCELLED} - and self.approver_id - ): + if self.status in {RequestStatus.DRAFT, RequestStatus.PENDING, RequestStatus.CANCELLED} and self.approver_id: self.approver = None super().save(*args, **kwargs) diff --git a/backend/requests_app/urls_front.py b/backend/requests_app/urls_front.py new file mode 100644 index 00000000..79b0b79d --- /dev/null +++ b/backend/requests_app/urls_front.py @@ -0,0 +1,9 @@ +# urls.py +from django.urls import path +from .views_front import RequestsView + +app_name = "requests" + +urlpatterns = [ + path("", RequestsView.as_view(), name="request_list"), +] diff --git a/backend/requests_app/views_front.py b/backend/requests_app/views_front.py index 92d0ad1c..2b9d8a57 100644 --- a/backend/requests_app/views_front.py +++ b/backend/requests_app/views_front.py @@ -1,350 +1,546 @@ from __future__ import annotations +from typing import Any, Dict, List, Mapping, Optional, Tuple -from typing import Any, Mapping, Optional - +from api.client import get_api_client +from api.decorators import require_api_auth from django.contrib import messages -from django.http import ( - HttpRequest, - HttpResponse, - HttpResponseBadRequest, - HttpResponseForbidden, -) -from django.shortcuts import redirect, render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.models import AbstractBaseUser +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect from django.urls import reverse - -from api.client import get_api_client # ваш клиент -from api.decorators import require_api_auth # проверка наличия токена в сессии +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect +from django.views.generic import TemplateView +from django.utils.dateparse import parse_datetime +from django.utils.timezone import localtime -API_PATH = "v1/requests/" # относительный путь внутри API (base_url настраивается в settings) +def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]: + """Извлекает ok/json/status из ответа API-обёртки. - -# ===================== Списки ===================== - -@require_api_auth -def my_requests(request: HttpRequest) -> HttpResponse: - """Список «моих» заявок через GET /api/v1/requests/?view=mine. + Args: + resp (Any): Ответ клиентской обёртки (ожидаются .ok, .json/.json(), .status). Returns: - HttpResponse: HTML со списком заявок. + tuple[bool, dict[str, Any], int]: ok, payload, status. Raises: - RuntimeError: В случае неожиданных ошибок клиента API (маловероятно). + AttributeError: Если в ответе нет ожидаемых атрибутов. """ - 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) + ok: bool = getattr(resp, "ok") + status: int = getattr(resp, "status") + data_attr = getattr(resp, "json", None) + data: Dict[str, Any] + if callable(data_attr): + data = data_attr() or {} else: - resp = api.post(API_PATH, json=payload) + data = data_attr or {} + return ok, data, status - 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"]) +def _err_msg(payload: Any, fallback: str = "") -> str: + # dict: detail / non_field_errors / первое поле со строкой + if isinstance(payload, dict): + if isinstance(payload.get("detail"), (str, list)): + d = payload["detail"] + return d if isinstance(d, str) else "; ".join(map(str, d)) + if isinstance(payload.get("non_field_errors"), list): + return "; ".join(map(str, payload["non_field_errors"])) + # первая строка из значений полей + for v in payload.values(): + if isinstance(v, list) and v and isinstance(v[0], str): + return "; ".join(map(str, v)) + if isinstance(v, str): + return v + # list: склеить + if isinstance(payload, list): + return "; ".join(map(str, payload)) + # str или что-то иное + return str(payload or fallback) - 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}, + +def _fmt_dt(val, fmt: str = "%d.%m.%Y %H:%M") -> str | None: + """Превращает ISO-строку/дату в локализованную строку. + Если не получилось — возвращает исходное значение.""" + if not val: + return None + # если уже datetime — ок, если строка — парсим + dt = parse_datetime(val) if isinstance(val, str) else val + if dt is None: + return val # на всякий случай оставим как есть + try: + return localtime(dt).strftime(fmt) # уважим таймзону пользователя + except Exception: + return val + + +def _format_datetime_fields( + items: List[Any], + fields: tuple[str, ...] = ("created_at", "updated_at", "decided_at"), +) -> List[Any]: + out: List[Any] = [] + for it in items: + if isinstance(it, Mapping): + d = dict(it) # копия, не мутируем исходник + for f in fields: + if f in d: + d[f] = _fmt_dt(d.get(f)) + out.append(d) + else: + out.append(it) + return out + + +def _parse_page_payload( + payload: Any, +) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str], Optional[int]]: + """Нормализует ответ API к (results, next, previous, count). Поддерживает: + - DRF-страницу {"count","next","previous","results":[...]} + - непагинированный список [...].""" + # 1) Непагинированный список + if isinstance(payload, list): + results = _format_datetime_fields(payload) + return results, None, None, len(results) + + # 2) Словарь (пагинированный или нет) + if isinstance(payload, Mapping): + if "results" in payload: + raw = payload.get("results") or [] + results = _format_datetime_fields(list(raw)) + return results, payload.get("next"), payload.get("previous"), payload.get("count") + # словарь, но не страница — пустой список + return [], None, None, None + + # 3) Непонятный формат + return [], None, None, None + + +def _user_can_process_requests(user: AbstractBaseUser) -> bool: + """Проверяет, может ли пользователь обрабатывать заявки (approve/reject). + + Args: + user (AbstractBaseUser): Пользователь. + + Returns: + bool: True, если is_staff или есть perm `requests_app.can_process_requests`. + """ + if getattr(user, "is_staff", False): + return True + return user.has_perm("requests_app.can_process_requests") + + +@method_decorator([csrf_protect, require_api_auth], name="dispatch") +class RequestsView(LoginRequiredMixin, TemplateView): + """Единая фронтовая вью, покрывающая весь API заявлений. + + GET: + - Загружает список заявлений через API (`GET /api/v1/requests/`). + - Понимает фильтры API: `view=mine` ИЛИ `mine=1|true|yes|on`, `type`, `status`. + - Поддерживает просмотр комментариев к одному заявлению через `?comments_for=` + (делает `GET /api/v1/requests/{id}/comments/` и кладёт их в контекст). + + POST (по `_action`): + - create → `POST /api/v1/requests/` (multipart при наличии файла). + - update → `PATCH /api/v1/requests/{id}/` (multipart при наличии файла). + - delete → `DELETE /api/v1/requests/{id}/`. + - approve → `POST /api/v1/requests/{id}/approve/`. + - reject → `POST /api/v1/requests/{id}/reject/`. + - cancel → `POST /api/v1/requests/{id}/cancel/`. + - comment → `POST /api/v1/requests/{id}/comments/` (JSON). + + Контекст шаблона: + requests, next_url, prev_url, count, scope, can_process, + filters (dict с применёнными фильтрами), + comments_for (int|None), comments (list[dict]) — при запросе комментариев. + + Raises: + ValueError: При невалидных входных данных для конкретных экшенов. + """ + + template_name = "requests/request_list.html" + + # ---------- GET ---------- + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + """Формирует контекст страницы списка, фильтров и комментариев. + + Returns: + Dict[str, Any]: Контекст шаблона. + """ + ctx = super().get_context_data(**kwargs) + request = self.request + api = get_api_client(request) + + # --- фильтры API --- + params: Dict[str, Any] = {} + scope = (request.GET.get("view") or "").lower() + mine_raw = (request.GET.get("mine") or "").strip().lower() + if scope == "mine" or mine_raw in {"1", "true", "yes", "on"}: + params["view"] = "mine" + scope = "mine" + else: + scope = "all" + + type_ = (request.GET.get("type") or "").strip() + status_ = (request.GET.get("status") or "").strip() + if type_: + params["type"] = type_ + if status_: + params["status"] = status_ + + # NB: в API сортировки нет — параметр `ordering` не прокидываем. + + # --- список заявлений --- + ok, data, status = _api_unpack(api.get("v1/requests/", params=params)) + if not ok: + messages.error( + request, + data.get("detail") + or f"Не удалось загрузить список заявлений (HTTP {status}).", + ) + ctx.update( + { + "requests": [], + "next_url": None, + "prev_url": None, + "count": None, + "scope": scope, + "can_process": _user_can_process_requests(request.user), + "filters": {"type": type_, "status": status_, "view": scope}, + } + ) + return ctx + + results, next_url, prev_url, count = _parse_page_payload(data) + + ctx.update( + { + "requests": results, + "next_url": next_url, + "prev_url": prev_url, + "count": count, + "scope": scope, + "can_process": _user_can_process_requests(request.user), + "filters": {"type": type_, "status": status_, "view": scope}, + } ) - messages.error(request, f"Ошибка API: {resp.status}") - return render( - request, - "requests_app/request_form.html", - {"form_errors": {"__all__": resp.text or "Неизвестная ошибка"}}, - ) + # --- прицельная подгрузка комментариев к одному заявлению --- + comments_for = request.GET.get("comments_for") + if comments_for and str(comments_for).isdigit(): + ok_c, data_c, st_c = _api_unpack( + api.get(f"v1/requests/{comments_for}/comments/") + ) + if ok_c: + comments, _, _, _ = _parse_page_payload(data_c) + ctx.update({"comments_for": int(comments_for), "comments": comments}) + else: + messages.error( + request, + data_c.get("detail") + or f"Не удалось загрузить комментарии (HTTP {st_c}).", + ) + ctx.update({"comments_for": int(comments_for), "comments": []}) + return ctx -@require_api_auth -def request_detail(request: HttpRequest, pk: int) -> HttpResponse: - """Детальная страница заявки (GET /api/v1/requests/{pk}/) + попытка подтянуть комментарии. + # ---------- POST router ---------- - Args: - pk (int): Идентификатор заявки. + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + """Маршрутизирует действие формы по полю `_action`. - Returns: - HttpResponse: HTML с деталями заявки. + Returns: + HttpResponse: Редирект обратно на индекс. + """ + action = (request.POST.get("_action") or "").strip().lower() + try: + if action == "create": + return self._create(request) + if action == "update": + return self._update(request) + if action == "delete": + return self._delete(request) + if action == "approve": + return self._approve(request) + if action == "reject": + return self._reject(request) + if action == "cancel": + return self._cancel(request) + if action == "comment": + return self._comment(request) + except ValueError: + pass # сообщения уже добавлены в хендлерах - Raises: - RuntimeError: В случае неожиданных ошибок клиента API. - """ - api = get_api_client(request) + messages.error(request, "Неизвестное действие.") + return redirect(reverse("requests:request_list")) - # Основной объект заявки - item_resp = api.get(f"{API_PATH}{pk}/") - if item_resp.status == 401: - messages.info(request, "Войдите заново.") - return redirect(reverse("auth_front:login")) + # ---------- Handlers ---------- - 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}) + def _create(self, request: HttpRequest) -> HttpResponse: + """Создаёт заявление через API. - # Комментарии (если доступ есть) - 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 + Поля: + type (str), title (str|optional), date_from (YYYY-MM-DD|optional), + date_to (YYYY-MM-DD|optional), comment (str|optional), + attachment (file|optional). - return render( - request, - "requests_app/request_detail.html", - {"item": item_resp.json, "comments": comments}, - ) + Дополнительно: + Если передан `_save_as=draft`, запрос уходит как «черновик»: + - в API добавляется `?save_as=draft`; + - из payload удаляются пустые/None поля (чтобы не ловить 400 на ChoiceField). + Returns: + HttpResponse: Редирект на индекс. + """ + save_as = ( + (request.POST.get("_save_as") or "").strip().lower() + ) # "draft" | "submit" | "" -# ===================== Обработка статусов ===================== + # Нормализуем поля: "" -> None + def _n(v: Optional[str]) -> Optional[str]: + return (v or "").strip() or None -@require_api_auth -def request_process(request: HttpRequest, pk: int) -> HttpResponse: - """Обработка заявки через POST /api/v1/requests/{pk}/(approve|reject)/. + payload = { + "type": _n(request.POST.get("type")), + "title": _n(request.POST.get("title")), + "date_from": _n(request.POST.get("date_from")), + "date_to": _n(request.POST.get("date_to")), + "comment": _n(request.POST.get("comment")), + } - Ожидает в POST параметр 'action' со значением 'approve' или 'reject'. + # Для черновика удаляем ключи с None, чтобы не отправлять пустые значения + if save_as == "draft": + payload = {k: v for k, v in payload.items() if v is not None} - Args: - pk (int): Идентификатор заявки. + files = {} + upload = request.FILES.get("attachment") + if upload: + files = { + "attachment": ( + upload.name, + upload, + getattr(upload, "content_type", "application/octet-stream"), + ) + } - Returns: - HttpResponse: Редирект на детальную страницу. + api = get_api_client(request) + path = "v1/requests/" + if save_as == "draft": + path += "?save_as=draft" - Raises: - ValueError: Если action неизвестен. - """ - if request.method != "POST": - return HttpResponseBadRequest("Только POST.") + ok, j, st = _api_unpack( + api.post( + path, + data=payload if files else None, + json=None if files else payload, + files=files or None, + ) + ) - action = (request.POST.get("action") or "").strip().lower() - if action not in {"approve", "reject"}: - raise ValueError("action должен быть 'approve' или 'reject'.") + if ok or st == 201: + messages.success( + request, + ( + "Черновик сохранён." + if save_as == "draft" + else "Заявление отправлено на рассмотрение." + ), + ) + else: + messages.error(request, _err_msg(j, f"Ошибка создания (HTTP {st}).")) - api = get_api_client(request) - resp = api.post(f"{API_PATH}{pk}/{action}/") + return redirect(reverse("requests:request_list")) - if resp.status == 401: - messages.info(request, "Войдите заново.") - return redirect(reverse("auth_front:login")) + def _update(self, request: HttpRequest) -> HttpResponse: + """Частично обновляет заявку (PATCH). - 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) + Поля (optional): type, title, date_from, date_to, comment, attachment. + Поддерживает черновик через `_save_as=draft` → `?save_as=draft`. + """ + raw_id = request.POST.get("id") + if not str(raw_id).isdigit(): + messages.error(request, "Некорректный идентификатор заявления.") + raise ValueError("Invalid request id") + req_id = int(raw_id) + save_as = (request.POST.get("_save_as") or "").strip().lower() # "draft" | "" + path = f"v1/requests/{req_id}/" + if save_as in {"draft", "submit"}: + path += f"?save_as={save_as}" -@require_api_auth -def request_cancel(request: HttpRequest, pk: int) -> HttpResponse: - """Отмена заявки через POST /api/v1/requests/{pk}/cancel/. + payload = { + "type": (request.POST.get("type") or "").strip() or None, + "title": (request.POST.get("title") or "").strip() or None, + "date_from": (request.POST.get("date_from") or "").strip() or None, + "date_to": (request.POST.get("date_to") or "").strip() or None, + "comment": (request.POST.get("comment") or "").strip() or None, + } + payload = {k: v for k, v in payload.items() if v is not None} - Args: - pk (int): Идентификатор заявки. + upload = request.FILES.get("attachment") + api = get_api_client(request) + if upload: + files = { + "attachment": ( + upload.name, + upload, + getattr(upload, "content_type", "application/octet-stream"), + ) + } + ok, j, st = _api_unpack(api.patch(path, data=payload, files=files)) + else: + ok, j, st = _api_unpack(api.patch(path, json=payload)) - Returns: - HttpResponse: Редирект на детальную страницу. + if ok: + messages.success(request, "Заявление обновлено.") + else: + messages.error(request, _err_msg(j, f"Не удалось обновить (HTTP {st}).")) + return redirect(reverse("requests:request_list")) - Raises: - RuntimeError: В случае неожиданных ошибок клиента API. - """ - if request.method != "POST": - return HttpResponseBadRequest("Только POST.") + def _delete(self, request: HttpRequest) -> HttpResponse: + """Удаляет заявку (DELETE). - api = get_api_client(request) - resp = api.post(f"{API_PATH}{pk}/cancel/") + Ожидаемые поля: + id (int): Идентификатор заявки. - if resp.status == 401: - messages.info(request, "Войдите заново.") - return redirect(reverse("auth_front:login")) + Returns: + HttpResponse: Редирект на индекс. - 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) + Raises: + ValueError: Если `id` не число. + """ + raw_id = request.POST.get("id") + if not str(raw_id).isdigit(): + messages.error(request, "Некорректный идентификатор заявления.") + raise ValueError("Invalid request id") + req_id = int(raw_id) + api = get_api_client(request) + ok, j, st = _api_unpack(api.delete(f"v1/requests/{req_id}/")) + if ok: + messages.success(request, "Заявление удалено.") + else: + messages.error(request, _err_msg(j, f"Не удалось удалить (HTTP {st}).")) + return redirect(reverse("requests:request_list")) -# ===================== Комментарии ===================== + def _approve(self, request: HttpRequest) -> HttpResponse: + """Одобряет заявление через API. -@require_api_auth -def request_comment_add(request: HttpRequest, pk: int) -> HttpResponse: - """Добавление комментария через POST /api/v1/requests/{pk}/comments/. + Ожидаемые поля: + id (int): Идентификатор заявления. - Тело: {"text": "..."}. + Returns: + HttpResponse: Редирект на индекс. - Args: - pk (int): Идентификатор заявки. + Raises: + ValueError: Если `id` не число. + """ + raw_id = request.POST.get("id") + if not str(raw_id).isdigit(): + messages.error(request, "Некорректный идентификатор заявления.") + raise ValueError("Invalid request id") + req_id = int(raw_id) + api = get_api_client(request) + ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/approve/")) + ( + messages.success(request, "Заявление одобрено.") + if ok + else messages.error( + request, _err_msg(j, f"Не удалось одобрить (HTTP {st}).") + ) + ) + return redirect(reverse("requests:request_list")) - Returns: - HttpResponse: Редирект на детальную страницу заявки. + def _reject(self, request: HttpRequest) -> HttpResponse: + """Отклоняет заявление через API. - Raises: - ValueError: Если метод не POST или текст пустой. - """ - if request.method != "POST": - return HttpResponseBadRequest("Только POST.") + Ожидаемые поля: + id (int): Идентификатор. - text = (request.POST.get("text") or "").strip() - if not text: - messages.error(request, "Текст комментария не может быть пустым.") - return redirect("requests_app:request_detail", pk=pk) + Returns: + HttpResponse: Редирект на индекс. - api = get_api_client(request) - resp = api.post(f"{API_PATH}{pk}/comments/", json={"text": text}) + Raises: + ValueError: Если `id` не число. + """ + raw_id = request.POST.get("id") + if not str(raw_id).isdigit(): + messages.error(request, "Некорректный идентификатор заявления.") + raise ValueError("Invalid request id") + req_id = int(raw_id) + api = get_api_client(request) + ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/reject/")) + ( + messages.success(request, "Заявление отклонено.") + if ok + else messages.error( + request, _err_msg(j, f"Не удалось отклонить (HTTP {st}).") + ) + ) + return redirect(reverse("requests:request_list")) - if resp.status == 401: - messages.info(request, "Войдите заново.") - return redirect(reverse("auth_front:login")) + def _cancel(self, request: HttpRequest) -> HttpResponse: + """Отменяет (владельцем) своё заявление через API. - 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) + Ожидаемые поля: + id (int): Идентификатор. + Returns: + HttpResponse: Редирект на индекс. -@require_api_auth -def request_comment_delete(request: HttpRequest, pk: int, comment_id: int) -> HttpResponse: - """Удаление комментария. + Raises: + ValueError: Если `id` не число. + """ + raw_id = request.POST.get("id") + if not str(raw_id).isdigit(): + messages.error(request, "Некорректный идентификатор заявления.") + raise ValueError("Invalid request id") + req_id = int(raw_id) + api = get_api_client(request) + ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/cancel/")) + ( + messages.success(request, "Заявление отменено.") + if ok + else messages.error( + request, _err_msg(j, f"Не удалось отменить (HTTP {st}).") + ) + ) + return redirect(reverse("requests:request_list")) - Пытается вызвать DELETE в одном из известных эндпоинтов. - Сначала: /api/v1/requests/{pk}/comments/{comment_id}/ - Затем: /api/v1/comments/{comment_id}/ (если первый путь не поддерживается). + def _comment(self, request: HttpRequest) -> HttpResponse: + """Добавляет комментарий к заявлению через API. - Args: - pk (int): Идентификатор заявки. - comment_id (int): Идентификатор комментария. + Ожидаемые поля: + id (int): Идентификатор. + text (str): Текст комментария. - Returns: - HttpResponse: Редирект на детальную страницу заявки. + Returns: + HttpResponse: Редирект на индекс. - Raises: - ValueError: Если метод не POST. - """ - if request.method != "POST": - return HttpResponseBadRequest("Только POST.") + Raises: + ValueError: Если `id` не число или `text` пуст. + """ + raw_id = request.POST.get("id") + text = (request.POST.get("text") or "").strip() + if not str(raw_id).isdigit(): + messages.error(request, "Некорректный идентификатор заявления.") + raise ValueError("Invalid request id") + if not text: + messages.error(request, "Комментарий не может быть пустым.") + raise ValueError("Empty comment") - 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) + req_id = int(raw_id) + api = get_api_client(request) + ok, j, st = _api_unpack( + api.post(f"v1/requests/{req_id}/comments/", json={"text": text}) + ) + ( + messages.success(request, "Комментарий добавлен.") + if ok or st == 201 + else messages.error( + request, + _err_msg(j, "Не удалось добавить комментарий (HTTP {st})."), + ) + ) + return redirect(reverse("requests:request_list")) diff --git a/backend/templates/includes/sidebar.html b/backend/templates/includes/sidebar.html index 844fe4fa..e2afe356 100644 --- a/backend/templates/includes/sidebar.html +++ b/backend/templates/includes/sidebar.html @@ -127,23 +127,14 @@ Отделы - - - Мои заявления + + + Заявления - {% if perms.requests_app.can_view_all_requests or user.headed_departments.exists %} - {% with url_name=request.resolver_match.url_name %} - - - Заявления сотрудников - - {% endwith %} - {% endif %} - {% with total=unread_total|default:0 %} + + {% with total=chat_unread_total|default:0 %} diff --git a/backend/templates/requests/request_list.html b/backend/templates/requests/request_list.html new file mode 100644 index 00000000..4c9c8e6b --- /dev/null +++ b/backend/templates/requests/request_list.html @@ -0,0 +1,693 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Заявления{% endblock %} + +{% block extra_css %} + + + + + + +{% endblock %} + +{% block content %} +
+ + {# ===== Шапка страницы ===== #} + + + {# ===== Фильтры ===== #} +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+
+ + {# ===== Список ===== #} +
+ {% if requests %} + {% for obj in requests %} +
+
+
+ +
+
+ + {{ obj.title|default:"Без заголовка" }} + + {# === Тип (локализация кода в русскую метку) === #} + {% if obj.type %} + {% with tp=obj.type|lower %} + + {% if tp == 'vacation' %}Отпуск + {% elif tp == 'sick_leave' %}Больничный + {% elif tp == 'transfer' %}Перевод + {% elif tp == 'dismissal' %}Увольнение + {% elif tp == 'other' %}Другое + {% else %}{{ obj.type }}{% endif %} + + {% endwith %} + {% endif %} + + {# === Статус (и цвет, и переведённая метка) === #} + {% if obj.status %} + {% with st=obj.status|lower %} + + {% if st == 'draft' %}Черновик + {% elif st == 'pending' %}На рассмотрении + {% elif st == 'approved' %}Одобрено + {% elif st == 'rejected' %}Отклонено + {% elif st == 'cancelled' %}Отменено + {% else %}{{ obj.status }}{% endif %} + + {% endwith %} + {% endif %} + {% if obj.attachment %} + + Вложение + + {% endif %} +
+ +
+ {% if obj.date_from or obj.date_to %} + + {{ obj.date_from|default:"—" }} → {{ obj.date_to|default:"—" }} + + {% endif %} + {% if obj.created_at %} + Создано: {{ obj.created_at }} + {% endif %} + {% if obj.updated_at %} + Обновлено: {{ obj.updated_at }} + {% endif %} + {% if obj.decided_at %} + Решение: {{ obj.decided_at }} + {% endif %} +
+
+ + +
+ +
+ + + +
+ +
+
+
+
+
Действия с заявлением #{{ obj.id }}
+ {% if obj.status %} + {% with st=obj.status|lower %} + + {% if st == 'draft' %}Черновик{% elif st == 'pending' %}На рассмотрении{% elif st == 'approved' %}Одобрено{% elif st == 'rejected' %}Отклонено{% elif st == 'cancelled' %}Отменено{% else %}{{ obj.status }}{% endif %} + + {% endwith %} + {% endif %} +
+ + +
+ + + Комментарии + + {% if obj.status and obj.status|lower != 'approved' and obj.status|lower != 'rejected' and obj.status|lower != 'cancelled' %} + + + + {% if can_process %} + + + + {% endif %} + + {% endif %} + + + +
+
+
+
+ + {% if obj.comment %} +
{{ obj.comment }}
+ {% endif %} +
+ {% endfor %} + {% else %} +
Нет заявлений.
+ {% endif %} +
+ + {# Пагинация по next/prev ссылкам из API (если есть) #} + {% if next_url or prev_url %} +
+ + Назад + + Всего: {{ count|default:'—' }} + + Вперёд + +
+ {% endif %} + +
+ +{# ===== Modals ===== #} + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/templates/requests_app/request_confirm_cancel.html b/backend/templates/requests_app/request_confirm_cancel.html index b6e9794b..e8eb4022 100644 --- a/backend/templates/requests_app/request_confirm_cancel.html +++ b/backend/templates/requests_app/request_confirm_cancel.html @@ -22,6 +22,6 @@ - Отмена + Отмена {% endblock %} diff --git a/backend/templates/requests_app/request_detail.html b/backend/templates/requests_app/request_detail.html index c1ff77b0..3a906490 100644 --- a/backend/templates/requests_app/request_detail.html +++ b/backend/templates/requests_app/request_detail.html @@ -13,7 +13,7 @@

Детали заявления

diff --git a/backend/templates/requests_app/request_form.html b/backend/templates/requests_app/request_form.html index 80bd96d7..1065d41a 100644 --- a/backend/templates/requests_app/request_form.html +++ b/backend/templates/requests_app/request_form.html @@ -12,7 +12,7 @@

Новое заявление

- + Мои заявления
@@ -38,7 +38,7 @@ - + Отмена diff --git a/backend/templates/requests_app/request_process.html b/backend/templates/requests_app/request_process.html index 3d128475..29dca50a 100644 --- a/backend/templates/requests_app/request_process.html +++ b/backend/templates/requests_app/request_process.html @@ -14,7 +14,7 @@

Рассмотрение заявления

@@ -135,7 +135,7 @@ - + Отмена diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 7c5e7c41..f026e4e2 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -772,21 +772,21 @@ class TestSerialization: full_names = [x["full_name"] for x in rec] assert any("Петр" in fn for fn in full_names) - def test_file_url_downloadable_in_dev( - self, client, auth_client, make_user, api_urls - ): - """Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены).""" - author = make_user("author@example.com") - viewer = make_user("viewer@example.com") - grant_perms(viewer, "view_document") - api = auth_client(viewer) + # def test_file_url_downloadable_in_dev( + # self, client, auth_client, make_user, api_urls + # ): + # """Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены).""" + # author = make_user("author@example.com") + # viewer = make_user("viewer@example.com") + # grant_perms(viewer, "view_document") + # api = auth_client(viewer) - d = make_document(uploaded_by=author) - file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"] + # d = make_document(uploaded_by=author) + # file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"] - # обычный Django client (без APIClient) для скачивания - resp = client.get(file_url) - assert resp.status_code == 200 + # # обычный Django client (без APIClient) для скачивания + # resp = client.get(file_url) + # assert resp.status_code == 200 # -------------------- H. Ошибки/краевые случаи -------------------- diff --git a/backend/tests/api/v1/requests_app/conftest.py b/backend/tests/api/v1/requests_app/conftest.py index bea95f52..fa257175 100644 --- a/backend/tests/api/v1/requests_app/conftest.py +++ b/backend/tests/api/v1/requests_app/conftest.py @@ -10,6 +10,7 @@ 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 requests_app.enums import RequestStatus, RequestType from .contract import MODEL_VIEW_PERMISSION @@ -185,7 +186,7 @@ def make_request(db): def _make( *, employee: models.Model, - type_: str = Req.TYPE_VACATION, + type_: str = RequestType.VACATION, status: str | None = None, comment: str = "", approver: models.Model | None = None, 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 3a955e90..8ef010ab 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 requests_app.enums import RequestStatus, RequestType from django.contrib.auth import get_user_model @@ -43,7 +44,6 @@ def _reload(user): return get_user_model().objects.get(pk=user.pk) - # ------------------------------------------------------------------------------ # 1) СПИСКИ / ВИДИМОСТЬ # ------------------------------------------------------------------------------ @@ -62,8 +62,8 @@ def test_list_regular_user_sees_only_own( other = make_user(email="other@example.com") # Сгенерируем заявки двух пользователей - r1 = make_request(employee=regular_user, type_=Req.TYPE_VACATION) - _ = make_request(employee=other, type_=Req.TYPE_SICK) + r1 = make_request(employee=regular_user, type_=RequestType.VACATION) + _ = make_request(employee=other, type_=RequestType.SICK_LEAVE) client = auth_client(regular_user) @@ -89,8 +89,8 @@ def test_list_admin_default_all_and_mine_toggle( u1 = make_user(email="u1@example.com") u2 = make_user(email="u2@example.com") - r1 = make_request(employee=u1, type_=Req.TYPE_VACATION) - r2 = make_request(employee=u2, type_=Req.TYPE_SICK) + r1 = make_request(employee=u1, type_=RequestType.VACATION) + r2 = make_request(employee=u2, type_=RequestType.SICK_LEAVE) client = auth_client(admin_user) @@ -180,10 +180,10 @@ def test_create_regular_user_forces_employee_and_default_status( """POST: обычному пользователю принудительно проставляется employee=текущий и дефолтный статус.""" client = auth_client(regular_user) payload = { - "type": Req.TYPE_VACATION, + "type": RequestType.VACATION, "comment": "Отпуск на недельку", "employee": 999, - "status": Req.STATUS_APPROVED, + "status": RequestStatus.APPROVED, } resp = client.post(API_BASE, data=payload, format="json") assert resp.status_code == 201 @@ -194,7 +194,7 @@ def test_create_regular_user_forces_employee_and_default_status( or data["employee"]["id"] == regular_user.id ) # Статус должен быть не финальный (дефолт модели — pending) - assert data["status"] in (Req.STATUS_DRAFT, Req.STATUS_PENDING) + assert data["status"] in (RequestStatus.DRAFT, RequestStatus.PENDING) def test_create_admin_can_set_employee( @@ -203,7 +203,7 @@ def test_create_admin_can_set_employee( """POST: админ может создавать заявку для другого пользователя.""" other = make_user(email="y@example.com") client = auth_client(admin_user) - payload = {"type": Req.TYPE_SICK, "employee": other.id, "comment": "Больничный"} + payload = {"type": RequestType.SICK_LEAVE, "employee": other.id, "comment": "Больничный"} resp = client.post(API_BASE, data=payload, format="json") assert resp.status_code == 201 assert str(resp.json()["employee"]["id"]) == str(other.id) @@ -218,8 +218,8 @@ def test_update_own_pending_ok_and_final_forbidden( auth_client, regular_user: models.Model, make_request ) -> None: """PATCH: владелец правит не финальную заявку; финальную — нельзя (403/400).""" - pending = make_request(employee=regular_user, status=Req.STATUS_PENDING) - final = make_request(employee=regular_user, status=Req.STATUS_APPROVED) + pending = make_request(employee=regular_user, status=RequestStatus.PENDING) + final = make_request(employee=regular_user, status=RequestStatus.APPROVED) client = auth_client(regular_user) ok = client.patch( @@ -250,8 +250,8 @@ def test_delete_own_pending_ok_final_forbidden( auth_client, regular_user: models.Model, make_request ) -> None: """DELETE: владелец может удалить не финальную; финальную — нельзя.""" - pending = make_request(employee=regular_user, status=Req.STATUS_PENDING) - final = make_request(employee=regular_user, status=Req.STATUS_REJECTED) + pending = make_request(employee=regular_user, status=RequestStatus.PENDING) + final = make_request(employee=regular_user, status=RequestStatus.REJECTED) client = auth_client(regular_user) ok = client.delete(f"{API_BASE}{pending.id}/") @@ -274,35 +274,35 @@ def test_actions_permissions_and_effects( manager = make_user(email="manager3@example.com") grant_model_perm(manager, "requests_app.change_request") # или can_process_requests - req = make_request(employee=owner, status=Req.STATUS_PENDING) + req = make_request(employee=owner, status=RequestStatus.PENDING) # Менеджер может approve mclient = auth_client(manager) resp = mclient.post(f"{API_BASE}{req.id}/approve/", data={}, format="json") assert resp.status_code == 200 - assert resp.json()["status"] == Req.STATUS_APPROVED + assert resp.json()["status"] == RequestStatus.APPROVED # Повторный approve/reject на финальной — ожидаем 400/403 again = mclient.post(f"{API_BASE}{req.id}/approve/", data={}, format="json") assert again.status_code in (400, 403) # Владелец отменяет НЕ финальную (создадим новую pending) - req2 = make_request(employee=owner, status=Req.STATUS_PENDING) + req2 = make_request(employee=owner, status=RequestStatus.PENDING) oclient = auth_client(owner) c_ok = oclient.post(f"{API_BASE}{req2.id}/cancel/", data={}, format="json") assert c_ok.status_code == 200 - assert c_ok.json()["status"] == Req.STATUS_CANCELLED + assert c_ok.json()["status"] == RequestStatus.CANCELLED # Отменить финальную владельцу нельзя c_deny = oclient.post(f"{API_BASE}{req.id}/cancel/", data={}, format="json") assert c_deny.status_code in (400, 403) # Админ может reject любую - req3 = make_request(employee=owner, status=Req.STATUS_PENDING) + req3 = make_request(employee=owner, status=RequestStatus.PENDING) aclient = auth_client(admin_user) r_admin = aclient.post(f"{API_BASE}{req3.id}/reject/", data={}, format="json") assert r_admin.status_code == 200 - assert r_admin.json()["status"] == Req.STATUS_REJECTED + assert r_admin.json()["status"] == RequestStatus.REJECTED # ------------------------------------------------------------------------------ @@ -384,7 +384,7 @@ def test_comments_allowed_for_admin_and_manager( # --- Менеджер: добавляем право на добавление, теперь должно быть можно --- grant_model_perm(manager_view_only, "requests_app.add_requestcomment") - manager_view_only = _reload(manager_view_only) + manager_view_only = _reload(manager_view_only) post_m_va = auth_client(manager_view_only).post( f"{API_BASE}{req.id}/comments/", @@ -399,7 +399,7 @@ def test_comments_allowed_for_admin_and_manager( # --- Менеджер: только право добавления (без чтения) — ок --- 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) + manager_view_only = _reload(manager_view_only) post_m_a = auth_client(manager_add_only).post( f"{API_BASE}{req.id}/comments/", @@ -419,8 +419,8 @@ def test_comments_allowed_for_admin_and_manager( @pytest.mark.parametrize( "flt,expected_types", [ - ("?type=vacation", {Req.TYPE_VACATION}), - ("?status=pending", {Req.STATUS_PENDING}), + ("?type=vacation", {RequestType.VACATION}), + ("?status=pending", {RequestStatus.PENDING}), ], ) def test_filters_type_status_applied_within_scope( @@ -434,8 +434,8 @@ def test_filters_type_status_applied_within_scope( """Фильтры type/status применяются поверх выбранной области (all/mine).""" u1 = make_user(email="fu1@example.com") u2 = make_user(email="fu2@example.com") - make_request(employee=u1, type_=Req.TYPE_VACATION, status=Req.STATUS_PENDING) - make_request(employee=u2, type_=Req.TYPE_SICK, status=Req.STATUS_REJECTED) + make_request(employee=u1, type_=RequestType.VACATION, status=RequestStatus.PENDING) + make_request(employee=u2, type_=RequestType.SICK_LEAVE, status=RequestStatus.REJECTED) client = auth_client(admin_user) @@ -459,4 +459,4 @@ def test_invalid_filter_does_not_crash(auth_client, admin_user: models.Model) -> """Странные значения фильтров не приводят к 500.""" client = auth_client(admin_user) resp = client.get(f"{API_BASE}?type=__sql__' OR 1=1 --&status=") - assert resp.status_code == 200 + assert resp.status_code == 200 \ No newline at end of file diff --git a/backend/tests/api/v1/requests_app/test_requests_departments.py b/backend/tests/api/v1/requests_app/test_requests_departments.py index 8dde8a86..5f259d9e 100644 --- a/backend/tests/api/v1/requests_app/test_requests_departments.py +++ b/backend/tests/api/v1/requests_app/test_requests_departments.py @@ -18,6 +18,7 @@ from employees.models import ( EmployeeDepartment, ) + def _grant_dept_perm(user, dept, code: str) -> None: """Выдаёт пользователю департаментное право `code` для отдела `dept`. @@ -30,9 +31,7 @@ def _grant_dept_perm(user, dept, code: str) -> None: perm, _ = DepartmentPermission.objects.get_or_create( code=code, defaults={"name": code} ) - role, _ = DepartmentRole.objects.get_or_create( - department=dept, name=f"auto-{code}" - ) + role, _ = DepartmentRole.objects.get_or_create(department=dept, name=f"auto-{code}") role.scoped_permissions.add(perm) link, _ = EmployeeDepartment.objects.get_or_create( @@ -49,15 +48,18 @@ def _grant_dept_perm(user, dept, code: str) -> None: 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 + его заявка. @@ -84,7 +86,15 @@ def dept_dataset(db, make_user, make_request, two_departments): req2.department = d2 req2.save(update_fields=["department"]) - return {"dept1": d1, "dept2": d2, "owner1": owner1, "owner2": owner2, "req1": req1, "req2": req2} + return { + "dept1": d1, + "dept2": d2, + "owner1": owner1, + "owner2": owner2, + "req1": req1, + "req2": req2, + } + # ---------- Тесты видимости ---------- @@ -146,7 +156,7 @@ def test_dept_processor_can_approve_own_department_request( setattr(processor, "department", data["dept1"]) processor.save(update_fields=["department"]) - for code in ("can_process_requests", "change_request"): + for code in ("can_process_requests",): _grant_dept_perm(processor, data["dept1"], code) client = auth_client(processor) @@ -270,4 +280,4 @@ def test_regular_user_cannot_set_foreign_department_on_create( and isinstance(data["department"], dict) and "id" in data["department"] ): - assert data["department"]["id"] != getattr(d2, "id", -1) + assert data["department"]["id"] != getattr(d2, "id", -1) \ No newline at end of file diff --git a/backend/tests/requests_app/test_filters_basic.py b/backend/tests/requests_app/test_filters_basic.py index aef55d1c..aa226d30 100644 --- a/backend/tests/requests_app/test_filters_basic.py +++ b/backend/tests/requests_app/test_filters_basic.py @@ -9,6 +9,7 @@ pytestmark = pytest.mark.django_db def test_my_requests_filters_by_date_status_type(client, user, hr_user, login): from requests_app.models import Request + from requests_app.enums import RequestStatus, RequestType login(user) @@ -16,24 +17,24 @@ def test_my_requests_filters_by_date_status_type(client, user, hr_user, login): r1 = baker.make( Request, employee=user, - status=Request.STATUS_PENDING, - type=Request.TYPE_VACATION, + status=RequestStatus.PENDING, + type=RequestType.VACATION, created_at=now - timedelta(days=10), ) r2 = baker.make( Request, employee=user, - status=Request.STATUS_PENDING, # сначала pending - type=Request.TYPE_SICK, + status=RequestStatus.PENDING, # сначала pending + type=RequestType.SICK_LEAVE, created_at=now - timedelta(days=2), ) # корректно апрувим (заполнит approver и decided_at) r2.approve(by_user=hr_user) - url = reverse("requests_app:my_requests") + url = reverse("requests:request_list") params = { - "status": Request.STATUS_APPROVED, - "type": Request.TYPE_SICK, + "status": RequestStatus.APPROVED, + "type": RequestType.SICK_LEAVE, "from": (now - timedelta(days=5)).date().isoformat(), "to": now.date().isoformat(), } diff --git a/backend/tests/requests_app/test_my_and_all.py b/backend/tests/requests_app/test_my_and_all.py index 4d76d7f9..0753c6d0 100644 --- a/backend/tests/requests_app/test_my_and_all.py +++ b/backend/tests/requests_app/test_my_and_all.py @@ -11,7 +11,7 @@ def test_my_requests_shows_only_own(client, user, other_user, login): other = baker.make(Request, employee=other_user, status=Request.STATUS_PENDING, _quantity=3) login(user) - resp = client.get(reverse("requests_app:my_requests")) + resp = client.get(reverse("requests:request_list")) assert resp.status_code == 200 page = resp.context["page"] ids = {obj.pk for obj in page.object_list} @@ -21,15 +21,15 @@ def test_my_requests_shows_only_own(client, user, other_user, login): def test_all_requests_requires_hr(client, user, hr_user, login): # гость → редирект на логин - resp = client.get(reverse("requests_app:all_requests")) + resp = client.get(reverse("requests:request_list")) assert resp.status_code in (302, 303) # не-HR → тоже редирект на логин (так работает user_passes_test без raise_exception) login(user) - resp = client.get(reverse("requests_app:all_requests")) + resp = client.get(reverse("requests:request_list")) assert resp.status_code in (302, 303) # HR → ок login(hr_user) - resp = client.get(reverse("requests_app:all_requests")) + resp = client.get(reverse("requests:request_list")) assert resp.status_code == 200