diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py index c4f32c09..ea39541c 100644 --- a/backend/api/v1/requests_app/permissions.py +++ b/backend/api/v1/requests_app/permissions.py @@ -14,11 +14,8 @@ class IsRecipientOfRequest(BasePermission): """Проверяет, что пользователь является получателем заявки. Доступ разрешается только если пользователь указан в recipients - данной заявки. Это базовая проверка которая должна комбинироваться - с проверкой прав (AdminOrActionOrModelPerms или DeptCanProcess). - - Для staff/superuser проверка пропускается - (они могут обрабатывать любые заявки). + данной заявки. Люди в копии и сотрудники с отдельными правами + не могут принимать решение, если заявка им не адресована напрямую. """ message = "Вы не являетесь получателем этой заявки." @@ -29,28 +26,16 @@ class IsRecipientOfRequest(BasePermission): def has_object_permission( self, request: Request, view: Any, obj: Any ) -> bool: - user = request.user - - # Staff/superuser могут обрабатывать любые заявки - if getattr(user, "is_staff", False) or getattr( - user, "is_superuser", False - ): - return True - - # Проверяем, является ли пользователь получателем заявки - # (только recipients, не cc_users - люди в копии не принимают решения) - return obj.recipients.filter(id=user.id).exists() + # Решение можно принимать только по заявке в статусе pending + # и только прямому получателю (не cc_users). + return ( + getattr(obj, "status", None) == "pending" + and obj.recipients.filter(id=request.user.id).exists() + ) class CanViewRequest(BasePermission): - """Проверяет, может ли пользователь просматривать заявку. - - Доступ разрешается если пользователь: - - staff/superuser - - автор заявки (employee) - - получатель заявки (recipients) - - в копии заявки (cc_users) - """ + """Проверяет, может ли пользователь просматривать заявку.""" message = "У вас нет доступа к этой заявке." @@ -62,11 +47,9 @@ class CanViewRequest(BasePermission): ) -> bool: user = request.user - # Staff/superuser могут просматривать любые заявки - if getattr(user, "is_staff", False) or getattr( - user, "is_superuser", False - ): - return True + # Черновик - только авторское состояние. + if getattr(obj, "status", None) == "draft": + return getattr(obj, "employee_id", None) == user.id # Автор заявки if getattr(obj, "employee_id", None) == user.id: @@ -83,37 +66,27 @@ class CanViewRequest(BasePermission): return False -class CommentsPermission(BasePermission): - """Комментарии: владелец, получатель, модельные права, staff, head. +class IsRequestAuthor(BasePermission): + """Разрешает доступ только автору заявки.""" - GET/HEAD: владелец, получатель, cc, view_requestcomment OR staff OR head. - POST: владелец, получатель, cc, add_requestcomment OR staff OR head. - """ - - 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 + message = "Доступ разрешён только автору заявки." def has_permission(self, request: Request, view: Any) -> bool: return bool(getattr(request.user, "is_authenticated", False)) - def _is_head_for_request(self, user, obj) -> bool: - dept = getattr(obj, "department", None) - if dept is not None and getattr(dept, "head_id", None) == user.id: - return True - emp_id = getattr(obj, "employee_id", None) - if not emp_id: - return False - return Department.objects.filter( - head_id=user.id, - employeedepartment__employee_id=emp_id, - employeedepartment__is_active=True, - ).exists() + def has_object_permission( + self, request: Request, view: Any, obj: Any + ) -> bool: + return getattr(obj, "employee_id", None) == request.user.id + + +class CommentsPermission(BasePermission): + """Комментарии доступны только участникам заявки.""" + + message = "Недостаточно прав для доступа к комментариям." + + def has_permission(self, request: Request, view: Any) -> bool: + return bool(getattr(request.user, "is_authenticated", False)) def _is_participant(self, user, obj) -> bool: """Проверяет, является ли пользователь участником заявки.""" @@ -132,97 +105,27 @@ class CommentsPermission(BasePermission): and obj.cc_users.filter(id=user.id).exists() ): return True - # Согласующий - if getattr(obj, "approver_id", None) == user.id: - return True return False def has_object_permission( self, request: Request, view: Any, obj: Any ) -> bool: - import logging - - logger = logging.getLogger(__name__) - - user = request.user - - # Логируем начало проверки - logger.info( - f"[CommentsPermission] Проверка доступа: " - f"user={user.id}, request_id={getattr(obj, 'id', '?')}, " - f"method={request.method}" - ) - - if getattr(user, "is_staff", False) or getattr( - user, "is_superuser", False - ): - logger.info( - "[CommentsPermission] ✅ Доступ разрешен: staff/superuser" - ) - return True - - # Участник заявки (владелец, получатель, cc, approver) - if self._is_participant(user, obj): - logger.info( - "[CommentsPermission] ✅ Доступ разрешен: участник заявки" - ) - return True - - if self._is_head_for_request(user, obj): - logger.info("[CommentsPermission] ✅ Доступ разрешен: head отдела") - return True - - code = self._required_perm(request) - has_perm = bool(code and user.has_perm(code)) - logger.info( - f"[CommentsPermission] Проверка модельных прав: " - f"perm={code}, has_perm={has_perm}" - ) - - if not has_perm: - logger.warning( - f"[CommentsPermission] ❌ Доступ запрещен: " - f"user={user.id}, request_id={getattr(obj, 'id', '?')}" - ) - - return has_perm + if getattr(obj, "status", None) == "draft": + return False + return self._is_participant(request.user, obj) 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 + return not bool(getattr(obj, "is_final", False)) class DeptViewRequest(AdminOrDeptAllowed): diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index b6d3d808..d1b21d83 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from django.db import models from employees.models import Department from requests_app.enums import RequestStatus +from requests_app.enums import RequestType from requests_app.models import Request from rest_framework import serializers @@ -89,6 +90,7 @@ class RequestReadSerializer(serializers.ModelSerializer): recipient_count = serializers.SerializerMethodField() cc_count = serializers.SerializerMethodField() is_recipient = serializers.SerializerMethodField() + can_decide = serializers.SerializerMethodField() # Используем аннотированное поле из queryset comments_count = serializers.IntegerField(read_only=True, allow_null=True) @@ -120,6 +122,7 @@ class RequestReadSerializer(serializers.ModelSerializer): "recipient_count", "cc_count", "is_recipient", + "can_decide", "comments_count", ) read_only_fields = ( @@ -137,6 +140,7 @@ class RequestReadSerializer(serializers.ModelSerializer): "recipient_count", "cc_count", "is_recipient", + "can_decide", "comments_count", ) @@ -174,6 +178,17 @@ class RequestReadSerializer(serializers.ModelSerializer): return False return obj.is_recipient(user) + @extend_schema_field(serializers.BooleanField()) + def get_can_decide(self, obj): + """Может ли текущий пользователь принять решение по заявке.""" + request = self.context.get("request") + if not request or not hasattr(request, "user"): + return False + user = request.user + if not user or not user.is_authenticated: + return False + return obj.recipients.filter(id=user.id).exists() + class RequestWriteSerializer(serializers.ModelSerializer): """Сериализатор записи заявки. @@ -195,6 +210,9 @@ class RequestWriteSerializer(serializers.ModelSerializer): employee = serializers.PrimaryKeyRelatedField( queryset=User.objects.all(), required=False, allow_null=True ) + type = serializers.ChoiceField( + choices=RequestType, required=False, allow_blank=True + ) status = serializers.ChoiceField( choices=RequestStatus, required=False, allow_null=True ) @@ -250,19 +268,26 @@ class RequestWriteSerializer(serializers.ModelSerializer): ] def _is_power(self) -> bool: - """Определяет, имеет ли пользователь расширенные права. + """В обычном UI/API нет привилегированных режимов редактирования.""" + return False - На создание и изменение. + def _save_as_mode(self) -> str: + """Режим сохранения из query params: draft / submit / ''.""" + request = self.context.get("request") + if not request: + return "" + return (request.query_params.get("save_as") or "").strip().lower() - Returns: - bool: True для staff или имеющих модельные права; иначе False. - """ - user = self.context["request"].user - return bool( - getattr(user, "is_staff", False) - or user.has_perm("requests_app.add_request") - or user.has_perm("requests_app.change_request") - or user.has_perm("requests_app.can_process_requests") + def _is_draft_mode(self) -> bool: + """Черновик допускает неполные данные до явной отправки.""" + save_as = self._save_as_mode() + if save_as == "draft": + return True + if save_as == "submit": + return False + return ( + self.instance is not None + and getattr(self.instance, "status", None) == RequestStatus.DRAFT ) def to_internal_value(self, data: Mapping[str, Any]) -> dict[str, Any]: @@ -299,11 +324,17 @@ class RequestWriteSerializer(serializers.ModelSerializer): return super().to_internal_value(data) def validate(self, attrs): - """Валидация получателей и отделов""" + """Валидация получателей и отделов.""" + if self._is_draft_mode(): + return attrs + sent_to_all = attrs.get( "sent_to_all_department", getattr(self.instance, "sent_to_all_department", False), ) + type_ = attrs.get("type", getattr(self.instance, "type", "")) + date_from = attrs.get("date_from", getattr(self.instance, "date_from", None)) + date_to = attrs.get("date_to", getattr(self.instance, "date_to", None)) recipient_ids = attrs.get("recipient_ids") if recipient_ids is None: @@ -321,6 +352,11 @@ class RequestWriteSerializer(serializers.ModelSerializer): else: departments = [] + if not type_: + raise serializers.ValidationError( + {"type": "Укажите тип заявления"} + ) + # Если sent_to_all_department=True, должны быть указаны отделы if sent_to_all and not departments: raise serializers.ValidationError( @@ -345,6 +381,24 @@ class RequestWriteSerializer(serializers.ModelSerializer): {"recipient_ids": "Автор не может быть получателем"} ) + if type_ in (RequestType.VACATION, RequestType.SICK_LEAVE): + if not (date_from and date_to): + raise serializers.ValidationError( + { + "date_from": "Требуются обе даты.", + "date_to": "Требуются обе даты.", + } + ) + if date_to < date_from: + raise serializers.ValidationError( + {"date_to": "Дата окончания раньше даты начала."} + ) + elif type_ in (RequestType.TRANSFER, RequestType.DISMISSAL): + if not date_from: + raise serializers.ValidationError( + {"date_from": "Требуется дата начала."} + ) + return attrs def _set_recipients(self, request_obj, recipient_ids, is_cc=False): @@ -398,14 +452,10 @@ class RequestWriteSerializer(serializers.ModelSerializer): f"cc_user_ids={cc_user_ids}" ) - if not self._is_power(): - # обычный пользователь: запрещённые поля убираем/переписываем - validated_data.pop("status", None) - validated_data.pop("approver", None) - validated_data["employee"] = user - else: - # staff/менеджер: если employee не указан — ставим текущего - validated_data.setdefault("employee", user) + validated_data.pop("approver", None) + validated_data["employee"] = user + if self._is_draft_mode() and not validated_data.get("type"): + validated_data["type"] = "" if not validated_data.get("employee"): raise serializers.ValidationError( diff --git a/backend/api/v1/requests_app/validators.py b/backend/api/v1/requests_app/validators.py index ac21da8d..987de2fe 100644 --- a/backend/api/v1/requests_app/validators.py +++ b/backend/api/v1/requests_app/validators.py @@ -7,6 +7,7 @@ from rest_framework.exceptions import ValidationError # VACATION/SICK_LEAVE/TRANSFER/DISMISSAL/OTHER from requests_app.enums import RequestType +from requests_app.enums import RequestStatus class RequestDatesByTypeValidator: @@ -23,7 +24,24 @@ class RequestDatesByTypeValidator: requires_context = True + def _is_draft_mode(self, serializer) -> bool: + request = serializer.context.get("request") + save_as = ( + (getattr(getattr(request, "query_params", None), "get", lambda *_: "")("save_as") or "") + .strip() + .lower() + ) + if save_as == "draft": + return True + if save_as == "submit": + return False + instance = getattr(serializer, "instance", None) + return getattr(instance, "status", None) == RequestStatus.DRAFT + def __call__(self, attrs: Mapping[str, Any], serializer) -> None: + if self._is_draft_mode(serializer): + return + instance = getattr(serializer, "instance", None) # Определяем «эффективные» значения с учётом instance @@ -70,7 +88,24 @@ class RequestApproverNotEmployeeValidator: requires_context = True + def _is_draft_mode(self, serializer) -> bool: + request = serializer.context.get("request") + save_as = ( + (getattr(getattr(request, "query_params", None), "get", lambda *_: "")("save_as") or "") + .strip() + .lower() + ) + if save_as == "draft": + return True + if save_as == "submit": + return False + instance = getattr(serializer, "instance", None) + return getattr(instance, "status", None) == RequestStatus.DRAFT + def __call__(self, attrs: Mapping[str, Any], serializer) -> None: + if self._is_draft_mode(serializer): + return + instance = getattr(serializer, "instance", None) def eff(name: str) -> Any: diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 42f5f3c1..d74e2bb5 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -6,10 +6,6 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Count, IntegerField, OuterRef, Q, QuerySet, Subquery, Value from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 # раскомментируйте импорт -from employees.models import ( - Department, - EmployeeDepartment, -) # добавлен Department from requests_app.enums import RequestStatus from requests_app.models import Request as EmployeeRequest from communications import comments_helpers @@ -31,15 +27,12 @@ from rest_framework.serializers import ( BaseSerializer, ) # <- для типизации get_serializer_class -from ..permissions import AdminOrActionOrModelPerms, IsSelfOrStaff +from ..permissions import IsSelfOrStaff from .permissions import ( CanViewRequest, CommentsPermission, - DeptCanProcess, - DeptChangeRequest, - DeptComments, - DeptViewRequest, IsRecipientOfRequest, + IsRequestAuthor, NotFinalOrStaff, ) from .serializers import ( @@ -51,9 +44,8 @@ from .serializers import ( class RequestViewSet(viewsets.ModelViewSet): """ViewSet для заявок сотрудников. - Админы могут всё. Пользователи с модельными правами действуют по правам. - Без прав аутентифицированные пользователи работают только - со своими заявками. + Обычный UI/API работает по строгим правилам участников: + автор, прямые получатели и пользователи в копии. Поддерживаемые экшены: - POST /requests/{id}/approve/ @@ -72,60 +64,27 @@ class RequestViewSet(viewsets.ModelViewSet): # Используем глобальную пагинацию из settings (PageNumberPagination, # PAGE_SIZE=20) - 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", - ], - } - def get_permissions(self) -> list[BasePermission]: """Подбирает пермишены под текущий action. - - comments: владелец ИЛИ staff ИЛИ явные модельные права - из required_perms_by_action. - - approve/reject: аутентификация + явные права - из required_perms_by_action. - - cancel: владелец ИЛИ staff. - - остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права. + - comments/retrieve/delete_comment: только участники заявки. + - approve/reject: только прямой получатель заявки. + - cancel: только автор заявки. + - остальное (CRUD): только автор заявки. """ if self.action == "comments": - return [(CommentsPermission | DeptComments)()] + return [CommentsPermission()] + if self.action == "delete_comment": + return [CommentsPermission()] if self.action in {"approve", "reject"}: - # Права на обработку + обязательно должен быть получателем заявки - # (staff/superuser пропускаются в IsRecipientOfRequest) - return [ - (AdminOrActionOrModelPerms | DeptCanProcess)(), - IsRecipientOfRequest(), - ] + return [IsRecipientOfRequest()] if self.action == "cancel": - return [IsSelfOrStaff()] + return [IsRequestAuthor()] if self.action == "retrieve": - # Автор, получатель, в копии, по отделу, staff/admin - return [ - (CanViewRequest | DeptViewRequest | AdminOrActionOrModelPerms)() - ] + return [CanViewRequest()] if self.action == "destroy": - return [ - ( - IsSelfOrStaff - | AdminOrActionOrModelPerms - | DeptChangeRequest - )(), - NotFinalOrStaff(), - ] - return [ - (IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)() - ] + return [IsRequestAuthor(), NotFinalOrStaff()] + return [IsRequestAuthor()] def get_serializer_class(self) -> type[BaseSerializer]: """Возвращает сериализатор в зависимости от action.""" @@ -135,21 +94,12 @@ class RequestViewSet(viewsets.ModelViewSet): # ---------- Queryset с учётом прав ---------- - def _can_view_all(self, user) -> bool: - """Проверяет, может ли пользователь видеть все заявки.""" - return bool( - user.is_staff - or user.has_perm("requests_app.can_view_all_requests") - or user.has_perm("requests_app.view_request") - ) - def get_queryset(self) -> QuerySet[EmployeeRequest]: - """Список заявок с учётом прав и получателей: - - staff/глобальные видят всё (или только свои при ?view=mine), - - автор видит свои заявки, - - получатели (recipients/cc_users) видят адресованные им, - - сотрудники отделов видят заявки отделов (с правами), - - при sent_to_all_department видят все из отделов. + """Список заявок с учётом строгих правил участия. + + TODO: Отдельно переопределить семантику sent_to_all_department + для обычного UI/API. В этом проходе фиксируем только пользовательскую + адресацию: автор, recipients и cc_users. """ qs = super().get_queryset() user = self.request.user @@ -160,99 +110,19 @@ class RequestViewSet(viewsets.ModelViewSet): mine_raw in {"1", "true", "yes", "on"} ) - # Новый параметр для фильтрации "мне адресовано" + incoming_scope = ( + Q(recipients=user) | Q(cc_users=user) + ) & ~Q(status=RequestStatus.DRAFT) + addressed_to_me = params.get("addressed_to_me") == "true" - - if user.is_staff or self._can_view_all(user): - if want_mine: - qs = qs.filter(employee_id=user.id) - elif addressed_to_me: - # Заявки, где я получатель - my_dept_ids = EmployeeDepartment.objects.filter( - employee=user, is_active=True - ).values_list("department_id", flat=True) - - scope = ( - Q(recipients=user) - | Q(cc_users=user) - | Q( - sent_to_all_department=True, departments__in=my_dept_ids - ) - ) - qs = qs.filter(scope).distinct() + if addressed_to_me: + scope = incoming_scope + elif want_mine: + scope = Q(employee_id=user.id) else: - # Обычный пользователь - scope = Q(employee_id=user.id) # Свои заявки + scope = Q(employee_id=user.id) | incoming_scope - # Заявки, где я получатель (основной или CC) - scope |= Q(recipients=user) | Q(cc_users=user) - - # Заявки отделов с sent_to_all_department - my_dept_ids = EmployeeDepartment.objects.filter( - employee=user, is_active=True - ).values_list("department_id", flat=True) - - if my_dept_ids: - scope |= Q( - sent_to_all_department=True, departments__in=my_dept_ids - ) - - # Департаментные права (как было) - view_dept_ids = ( - EmployeeDepartment.objects.filter( - employee_id=user.id, - is_active=True, - role__scoped_permissions__code="view_request", - ) - .values_list("department_id", flat=True) - .distinct() - ) - proc_dept_ids = ( - EmployeeDepartment.objects.filter( - employee_id=user.id, - is_active=True, - role__scoped_permissions__code="can_process_requests", - ) - .values_list("department_id", flat=True) - .distinct() - ) - head_dept_ids = Department.objects.filter( - head_id=user.id - ).values_list("id", flat=True) - - combined_ids = ( - set(view_dept_ids) | set(proc_dept_ids) | set(head_dept_ids) - ) - - if combined_ids: - # Заявки этих отделов (новое поле departments) - scope |= Q(departments__in=combined_ids) - - # Заявки сотрудников этих отделов - dept_emp_ids = ( - EmployeeDepartment.objects.filter( - department_id__in=list(combined_ids), - is_active=True, - ) - .values_list("employee_id", flat=True) - .distinct() - ) - if dept_emp_ids: - scope |= Q(employee_id__in=list(dept_emp_ids)) - - # Фильтр "только адресованные мне" - if addressed_to_me: - scope = ( - Q(recipients=user) - | Q(cc_users=user) - | Q( - sent_to_all_department=True, departments__in=my_dept_ids - ) - ) - elif want_mine: - scope = Q(employee_id=user.id) - - qs = qs.filter(scope).distinct() + qs = qs.filter(scope).distinct() # Применяем фильтры # type/status/employee_id/created_from/created_to/date_from/date_to для @@ -355,12 +225,11 @@ class RequestViewSet(viewsets.ModelViewSet): # ---------- CRUD ---------- 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 {} + extra = {"employee": user} save_as = ( (self.request.query_params.get("save_as") or "").strip().lower() ) @@ -375,19 +244,11 @@ class RequestViewSet(viewsets.ModelViewSet): """Обновление: владельцу разрешаем до финального статуса. Raises: - PermissionDenied: Если заявка финальная и нет прав, - либо правка чужой заявки без прав. + PermissionDenied: Если заявка финальная или чужая. """ obj = self.get_object() - user = self.request.user - if obj.is_final and not ( - user.is_staff or user.has_perm("requests_app.change_request") - ): + if obj.is_final: raise PermissionDenied("Финальная заявка недоступна для правок.") - if (obj.employee_id != user.id) and not ( - user.is_staff or user.has_perm("requests_app.change_request") - ): - raise PermissionDenied("Нельзя редактировать чужую заявку.") extra: dict[str, Any] = {} save_as = ( (self.request.query_params.get("save_as") or "").strip().lower() @@ -409,10 +270,8 @@ class RequestViewSet(viewsets.ModelViewSet): ) -> Response: """Список/создание комментариев для заявки. - GET: список комментариев (staff, владелец - или право view_requestcomment). - POST: создание {"text": "..."} (staff, владелец - или право add_requestcomment). + GET: список комментариев участникам заявки. + POST: создание {"text": "..."} участниками заявки. """ import logging from .serializers import EmployeeBriefSerializer @@ -511,7 +370,7 @@ class RequestViewSet(viewsets.ModelViewSet): DELETE /api/v1/requests/{pk}/comments/{comment_id}/ - Права: только автор комментария или staff. + Права: только автор комментария. """ import logging from communications.models import Message @@ -532,8 +391,7 @@ class RequestViewSet(viewsets.ModelViewSet): status=status.HTTP_404_NOT_FOUND, ) - # Проверка прав: только автор или staff - if not (request.user.is_staff or message.author == request.user): + if message.author != request.user: return Response( {"detail": "You don't have permission to delete this comment"}, status=status.HTTP_403_FORBIDDEN, @@ -562,7 +420,12 @@ class RequestViewSet(viewsets.ModelViewSet): Raises: ValidationError: Если заявка уже финальная. """ - obj = self.get_object() # права проверит AdminOrActionOrModelPerms + obj = self.get_object() + if obj.status != RequestStatus.PENDING: + raise ValidationError( + "Решение можно принять только по заявке в статусе " + "'На рассмотрении'." + ) if getattr(obj, "is_final", False): raise ValidationError("Заявка уже в финальном статусе.") obj.approve(by_user=request.user) @@ -582,6 +445,11 @@ class RequestViewSet(viewsets.ModelViewSet): ValidationError: Если заявка уже финальная. """ obj = self.get_object() + if obj.status != RequestStatus.PENDING: + raise ValidationError( + "Решение можно принять только по заявке в статусе " + "'На рассмотрении'." + ) if getattr(obj, "is_final", False): raise ValidationError("Заявка уже в финальном статусе.") obj.reject(by_user=request.user) @@ -595,13 +463,13 @@ class RequestViewSet(viewsets.ModelViewSet): def cancel( self, request: DRFRequest, pk: int | str | None = None ) -> Response: - """Отменяет заявку (владелец или админ). + """Отменяет заявку. Raises: - PermissionDenied: Если заявка финальная (для не admin). + PermissionDenied: Если заявка финальная. """ obj = self.get_object() - if getattr(obj, "is_final", False) and not request.user.is_staff: + if getattr(obj, "is_final", False): raise PermissionDenied( "Финальная заявка уже не может быть отменена." ) diff --git a/backend/employees/ldap/tests/unit/test_group_service.py b/backend/employees/ldap/tests/unit/test_group_service.py index b9d54db1..ceac92f7 100644 --- a/backend/employees/ldap/tests/unit/test_group_service.py +++ b/backend/employees/ldap/tests/unit/test_group_service.py @@ -158,7 +158,17 @@ class TestGroupServiceMembers: # Mock ORM mock_group = Mock() - mock_group.member = [] + member_state = [] + + def list_members(): + return list(member_state) + + def add_member(dn): + if dn not in member_state: + member_state.append(dn) + + mock_group.list_members.side_effect = list_members + mock_group.add_member.side_effect = add_member with patch( 'employees.ldap.services.group_service.LdapGroup' ) as MockLdapGroup: @@ -168,8 +178,10 @@ class TestGroupServiceMembers: service.add_members(group_dn, member_dns) # Assert - assert mock_group.member == member_dns - mock_group.save.assert_called_once() + assert member_state == member_dns + assert mock_group.add_member.call_count == len(member_dns) + mock_group.add_member.assert_any_call(member_dns[0]) + mock_group.add_member.assert_any_call(member_dns[1]) @pytest.mark.django_db def test_remove_members( @@ -186,7 +198,17 @@ class TestGroupServiceMembers: # Mock ORM — группа содержит удаляемого участника mock_group = Mock() - mock_group.member = ["CN=User1,OU=Users,DC=example,DC=com"] + member_state = ["CN=User1,OU=Users,DC=example,DC=com"] + + def list_members(): + return list(member_state) + + def remove_member(dn): + if dn in member_state: + member_state.remove(dn) + + mock_group.list_members.side_effect = list_members + mock_group.remove_member.side_effect = remove_member with patch( 'employees.ldap.services.group_service.LdapGroup' ) as MockLdapGroup: @@ -196,8 +218,8 @@ class TestGroupServiceMembers: service.remove_members(group_dn, member_dns) # Assert - assert mock_group.member == [] - mock_group.save.assert_called_once() + assert member_state == [] + mock_group.remove_member.assert_called_once_with(member_dns[0]) @pytest.mark.django_db def test_replace_members( @@ -214,7 +236,12 @@ class TestGroupServiceMembers: # Mock ORM — группа с текущими участниками mock_group = Mock() - mock_group.member = ['CN=OldUser,OU=Users,DC=example,DC=com'] + old_members = ['CN=OldUser,OU=Users,DC=example,DC=com'] + mock_group.list_members.return_value = list(old_members) + mock_group.sync_members.return_value = { + "added": member_dns, + "removed": old_members, + } with patch( 'employees.ldap.services.group_service.LdapGroup' ) as MockLdapGroup: @@ -224,8 +251,8 @@ class TestGroupServiceMembers: service.replace_members(group_dn, member_dns) # Assert - assert mock_group.member == member_dns - mock_group.save.assert_called_once() + mock_group.list_members.assert_called_once() + mock_group.sync_members.assert_called_once_with(member_dns) @pytest.mark.django_db def test_list_members( diff --git a/backend/requests_app/templatetags/req_tags.py b/backend/requests_app/templatetags/req_tags.py index 95e07bbc..dc8e132f 100644 --- a/backend/requests_app/templatetags/req_tags.py +++ b/backend/requests_app/templatetags/req_tags.py @@ -46,3 +46,28 @@ def format_iso_date(value, format_string="d.m.Y"): except (ValueError, AttributeError): # Если не удалось распарсить, возвращаем первые 10 символов return str(value)[:10] if value else "" + + +@register.filter +def can_decide_request(request_obj, user): + """Проверяет, является ли пользователь прямым получателем заявки.""" + if not user or not getattr(user, "is_authenticated", False): + return False + + recipients = getattr(request_obj, "recipients", None) + if recipients is None and isinstance(request_obj, dict): + recipients = request_obj.get("recipients") + + if recipients is None: + return False + + if hasattr(recipients, "filter"): + return recipients.filter(id=user.id).exists() + + for recipient in recipients: + if isinstance(recipient, dict) and recipient.get("id") == user.id: + return True + if getattr(recipient, "id", None) == user.id: + return True + + return False diff --git a/backend/static/js/components/requestListHandler.js b/backend/static/js/components/requestListHandler.js index e690ab87..86bdeca0 100644 --- a/backend/static/js/components/requestListHandler.js +++ b/backend/static/js/components/requestListHandler.js @@ -9,7 +9,6 @@ * @param {string} options.apiListUrl - URL API для списка заявлений * @param {string} options.detailUrlTemplate - Шаблон URL для детального просмотра: "/requests/{id}/" * @param {number} options.userId - ID текущего пользователя - * @param {boolean} options.canProcess - Может ли пользователь обрабатывать заявления * @param {Object} options.headers - HTTP заголовки для запросов * @returns {Object} API с методами load, destroy */ @@ -18,7 +17,6 @@ export function initRequestListHandler(options) { apiListUrl, detailUrlTemplate, userId, - canProcess, headers = {}, } = options; @@ -435,8 +433,8 @@ export function initRequestListHandler(options) { `; - } else if (req.employee?.id !== userId && canProcess && req.status === "pending") { - // Если pending и не автор и есть права - показываем кнопки действий + } else if (req.employee?.id !== userId && req.can_decide && req.status === "pending") { + // Если pending и заявка адресована текущему пользователю - показываем кнопки действий actionButtons = ` - - - - - -``` +# Requests Detail View Guide -#### Кастомизация - -Изменение ID модального окна: -```javascript -const modal = new RequestDetailModal('myModalId', 'myContentId'); -``` - ---- - -## 🔒 Безопасность - -### Проверки прав доступа - -1. **Просмотр заявления:** - - Автор может видеть свою заявку - - Адресат может видеть адресованную ему заявку - - Администратор может видеть любую заявку - - Другой пользователь получит 403 - -2. **Добавление комментария:** - - Требует аутентификации (402 если нет) - - Автоматическая привязка к текущему пользователю - -3. **Удаление комментария:** - - Может удалить только автор комментария - - Администраторы также могут удалять (проверка на backend) - -4. **Изменение статуса:** - - Требует права `requests_app.can_process_requests` - - Видно только администраторам - - API проверяет права на каждый запрос - -### Защита от атак - -- **CSRF:** Все POST/DELETE запросы используют CSRF токен -- **XSS:** Текст комментариев экранируется в шаблоне -- **SQL Injection:** Использование Django ORM -- **Rate Limiting:** Защита от спама (настраивается в settings) - ---- - -## 🛠️ Техническая информация +Статус: актуальный guide по normal UI/API для просмотра заявления. -### Зависимости +Этот документ заменяет старое описание detail view, в котором были зафиксированы устаревшие допущения про административные права и смену статуса. В обычном UI заявления теперь работают по strict participant-only rules. -**Backend:** -- Django 5.2.4+ -- Django REST Framework -- Django Channels (для будущих WebSocket уведомлений) +## Что считается участием -**Frontend:** -- Bootstrap 5 -- Vanilla JavaScript ES6+ -- emoji-picker (опционально) +Заявление в обычном интерфейсе доступно только: -### Структура файлов +- автору +- прямым получателям +- пользователям в копии -``` -backend/ -├── requests_app/ -│ ├── views_front.py # RequestDetailView -│ ├── urls_front.py # URL маршруты -│ ├── notification_signals.py # Уведомления -│ └── models.py # Модель Request -├── api/v1/ -│ └── requests_app/ -│ └── views.py # RequestViewSet (API) -├── templates/requests_app/ -│ ├── request_detail.html # Шаблон деталей -│ └── request_list_full.html # Шаблон списка -└── static/js/modules/ - └── requestDetail.js # JavaScript модуль -``` +Администратор сам по себе не получает исключений в обычном UI/API. Повышенные полномочия считаются допустимыми только в Django admin или в отдельном специальном admin-mode, если он будет реализован отдельно. -### Интеграция с уведомлениями - -Все события автоматически создают уведомления: -- **Создание заявления:** всем адресатам -- **Добавление комментария:** создателю и адресатам -- **Смена статуса:** создателю заявления +## Видимость detail view -**URL в уведомлениях:** `/requests/{id}/` +Detail view и связанные запросы должны подчиняться тем же правилам, что и список: ---- +- автор видит заявление +- прямой получатель видит заявление +- пользователь в копии видит заявление +- посторонний пользователь не видит заявление +- `staff` и `superuser` без участия в заявлении не видят его только из-за роли -## 📋 Примеры использования +Отдельный случай: -### Пример 1: Просмотр заявления +- `draft` видит только автор -**HTML:** -```html - -``` +## Решения по заявлению -**Результат:** Модальное окно с полной информацией о заявлении #123 +Решение (`approve` / `reject`) в normal UI/API может принять только прямой получатель. -### Пример 2: Добавление комментария в модали +Не могут принимать решение: -**Форма автоматически обрабатывает:** -1. Валидацию текста (не пусто) -2. Отправку через API -3. Обновление списка комментариев -4. Обновление счетчика +- автор, если он не добавлен в `recipients` +- пользователь в копии +- посторонний пользователь +- `staff` / `superuser`, если они не являются прямыми получателями -### Пример 3: Изменение статуса +Дополнительные ограничения: -**Только для администраторов видна форма:** -```html -
- - -
-``` +- решение доступно только для `pending` +- по `draft` решение недоступно +- после финального решения второе решение не допускается ---- +## Комментарии -## 🐛 Решение проблем +Комментарии в normal UI/API доступны участникам: -### Проблема: Модаль не открывается +- автору +- прямым получателям +- пользователям в копии -**Решение:** -1. Проверить консоль браузера (F12) на ошибки -2. Убедиться что элемент с id="requestDetailModal" существует -3. Проверить что requestDetail.js загружен +Неучастники комментарии не читают и не пишут. -### Проблема: Комментарии не загружаются +Отдельный случай: -**Решение:** -1. Проверить API endpoint: GET `/api/v1/requests/{id}/comments/` -2. Проверить права доступа (403?) -3. Проверить CORS в браузере +- у `draft` комментарии отключены, потому что черновик принадлежит только автору до отправки -### Проблема: Нельзя менять статус +## Что должен показывать фронтенд -**Решение:** -1. Проверить что вы администратор (can_process=true) -2. Проверить что заявление в статусе "pending" -3. Проверить права на backend: `can_process_requests` +Фронтенд не должен пытаться заново вычислять права через role-based эвристики. Источник истины для decision actions приходит из API. -### Проблема: Emoji picker не работает +Ожидаемый контракт: -**Решение:** -1. Подключить библиотеку emoji-picker -2. Проверить что форма имеет класс `message-field` -3. Проверить консоль на ошибки загрузки модуля +- detail доступен только участнику +- approve/reject показываются только при `status === "pending"` и `can_decide === true` +- для пользователей в копии detail и comments доступны, но approve/reject скрыты +- для `draft` не показываются approve/reject и comments ---- +## Compose и detail связаны -## 📈 Статистика производительности +Текущий compose-flow заявлений в normal UI: -- Загрузка модали: ~500ms (в зависимости от сети) -- Добавление комментария: ~200ms -- Изменение статуса: ~300ms -- Размер модуля requestDetail.js: ~8 KB +- адресация только через пользователей +- прямые получатели находятся в `recipient_ids` +- копия находится в `cc_user_ids` +- черновик можно сохранять неполным +- отправка требует обязательные поля ---- +Следствие для detail view: -## 🔄 История версий +- прямой получатель и копия остаются разными ролями +- видимость и право решения нельзя смешивать -### v1.0 (26 декабря 2025) -- ✅ Первая версия -- ✅ Полная система комментариев -- ✅ Управление статусом -- ✅ Поддержка emoji -- ✅ Обработка ошибок +## Источники истины -### Планируемые улучшения (v2.0) -- 🔄 WebSocket для уведомлений в реальном времени -- 🔄 Редактирование комментариев -- 🔄 Упоминания (@username) -- 🔄 Вложения в комментариях -- 🔄 Поиск по комментариям +По frontend UI: ---- +- `docs/guides/frontend/README.md` +- `frontend/src/app/requests/page.tsx` -## 📞 Поддержка и обратная связь +По backend логике: -Для сообщений об ошибках или предложений по улучшению: -1. Создать Issue в GitHub -2. Описать проблему/предложение -3. Приложить скриншоты или видео если применимо +- `backend/api/v1/requests_app/permissions.py` +- `backend/api/v1/requests_app/views.py` +- `backend/api/v1/requests_app/serializers.py` ---- +## Что больше не считается актуальным -**Дата обновления:** 26 декабря 2025 г. -**Версия документации:** 1.0 -**Автор:** Development Team +Больше неактуальны утверждения: + +- что статус может менять администратор по умолчанию +- что `can_process_requests` достаточно для принятия решения +- что detail view ориентирован на admin-driven workflow + +Если понадобится отдельный elevated admin mode для заявлений, он должен быть описан отдельно и не должен подменять этот guide.