chore: checkpoint workspace changes

This commit is contained in:
lizerk 2026-04-13 16:00:53 +03:00
parent b170ba9f43
commit 5ab638892b
19 changed files with 1016 additions and 2215 deletions

View File

@ -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):

View File

@ -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(

View File

@ -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:

View File

@ -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(
"Финальная заявка уже не может быть отменена."
)

View File

@ -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(

View File

@ -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

View File

@ -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) {
</span>
</a>
`;
} 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 = `
<button type="button" class="btn btn-ghost btn-sm text-success"
data-bs-toggle="modal" data-bs-target="#reqApproveModal"

View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load static req_tags %}
{% block title %}Заявление #{{ request_obj.id }}{% endblock %}
@ -92,7 +92,7 @@
</div>
{# Кнопки действий #}
{% if can_process and request_obj.status == 'pending' %}
{% if request_obj|can_decide_request:request.user and request_obj.status == 'pending' %}
<div class="req-detail-actions">
<button type="button" class="btn btn-success btn-sm"
data-bs-toggle="modal"

View File

@ -1,16 +1,12 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from typing import Any
import pytest
from django.db import models
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
from tests.test_config import API_REQUESTS_URL
@ -19,18 +15,8 @@ pytestmark = pytest.mark.django_db
API_BASE = API_REQUESTS_URL
def _results(payload: Any) -> List[Dict[str, Any]]:
"""Возвращает список объектов из ответа API.
DRF может возвращать как чистый список (без пагинации), так и словарь
с `results` (с пагинацией). Эта функция нормализует оба случая.
Args:
payload (Any): JSON-ответ.
Returns:
List[Dict[str, Any]]: Список элементов.
"""
def _results(payload: Any) -> list[dict[str, Any]]:
"""Нормализует DRF-пагинацию в список объектов."""
if isinstance(payload, list):
return payload
if isinstance(payload, dict) and "results" in payload:
@ -38,352 +24,516 @@ def _results(payload: Any) -> List[Dict[str, Any]]:
return []
def _reload(user):
# сбрасываем кэш прав на текущем экземпляре (если уже вычислялся)
if hasattr(user, "_perm_cache"):
delattr(user, "_perm_cache")
# подменяем объект свежим из БД — это важно для force_authenticate
return get_user_model().objects.get(pk=user.pk)
# ------------------------------------------------------------------------------
# 1) СПИСКИ / ВИДИМОСТЬ
# ------------------------------------------------------------------------------
def test_list_unauth_401(api_client: APIClient) -> None:
"""Неаутентифицированный доступ к списку → 401."""
resp = api_client.get(API_BASE)
assert resp.status_code == 401
def test_list_regular_user_sees_only_own(
def test_list_only_participants_see_requests(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""Обычный пользователь видит только свои заявки независимо от параметров."""
other = make_user(email="other@example.com")
"""В списке видны только свои, адресованные и заявки в копии."""
recipient = make_user(email="participant-recipient@example.com")
cc_user = make_user(email="participant-cc@example.com")
foreign_user = make_user(email="foreign-owner@example.com")
# Сгенерируем заявки двух пользователей
r1 = make_request(employee=regular_user, type_=RequestType.VACATION)
_ = make_request(employee=other, type_=RequestType.SICK_LEAVE)
own_request = make_request(employee=regular_user, type_=RequestType.VACATION)
recipient_request = make_request(
employee=foreign_user, type_=RequestType.SICK_LEAVE
)
cc_request = make_request(employee=foreign_user, type_=RequestType.DAY_OFF)
hidden_request = make_request(
employee=foreign_user, type_=RequestType.OTHER
)
recipient_request.recipients.add(regular_user, recipient)
cc_request.cc_users.add(regular_user, cc_user)
client = auth_client(regular_user)
# Без параметров
resp = client.get(API_BASE)
assert resp.status_code == 200
ids = {it["id"] for it in _results(resp.json())}
assert r1.id in ids
assert len(ids) == 1
# Параметры ?view=mine и ?mine=1 игнорируются для обычного пользователя
for qs in ("?view=mine", "?mine=1", "?mine=true", "?mine=True"):
resp = client.get(f"{API_BASE}{qs}")
assert resp.status_code == 200
ids = {it["id"] for it in _results(resp.json())}
assert ids == {r1.id}
ids = {item["id"] for item in _results(resp.json())}
assert own_request.id in ids
assert recipient_request.id in ids
assert cc_request.id in ids
assert hidden_request.id not in ids
def test_list_admin_default_all_and_mine_toggle(
def test_list_addressed_to_me_shows_only_recipient_and_cc_requests(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""Фильтр addressed_to_me исключает собственные заявки."""
foreign_user = make_user(email="addressed-owner@example.com")
own_request = make_request(employee=regular_user)
recipient_request = make_request(employee=foreign_user)
cc_request = make_request(employee=foreign_user)
recipient_request.recipients.add(regular_user)
cc_request.cc_users.add(regular_user)
client = auth_client(regular_user)
resp = client.get(f"{API_BASE}?addressed_to_me=true")
assert resp.status_code == 200
ids = {item["id"] for item in _results(resp.json())}
assert own_request.id not in ids
assert recipient_request.id in ids
assert cc_request.id in ids
def test_list_admin_without_participation_sees_nothing(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:
"""Админ по умолчанию видит все, но может сузить до «только свои» параметром."""
u1 = make_user(email="u1@example.com")
u2 = make_user(email="u2@example.com")
r1 = make_request(employee=u1, type_=RequestType.VACATION)
r2 = make_request(employee=u2, type_=RequestType.SICK_LEAVE)
"""Админ без участия в заявках не получает доступ через обычный API."""
owner = make_user(email="admin-hidden-owner@example.com")
make_request(employee=owner, type_=RequestType.VACATION)
make_request(employee=owner, type_=RequestType.SICK_LEAVE)
client = auth_client(admin_user)
# По умолчанию — все
resp = client.get(API_BASE)
assert resp.status_code == 200
ids = {it["id"] for it in _results(resp.json())}
assert ids.issuperset({r1.id, r2.id})
# mine: у админа обычно нет собственных заявок → пусто
for qs in ("?view=mine", "?mine=1", "?mine=true", "?mine=True"):
resp = client.get(f"{API_BASE}{qs}")
assert resp.status_code == 200
ids = {it["id"] for it in _results(resp.json())}
assert ids == set()
assert _results(resp.json()) == []
def test_list_manager_with_model_perm_sees_all(
def test_list_model_permission_does_not_bypass_visibility(
auth_client, make_user, grant_model_perm, make_request
) -> None:
"""Пользователь с модельным правом `view_request` видит все заявки."""
manager = make_user(email="manager@example.com")
"""Глобальные model permissions не раскрывают чужие заявки."""
manager = make_user(email="manager-view@example.com")
owner = make_user(email="manager-view-owner@example.com")
grant_model_perm(manager, "requests_app.view_request")
u1 = make_user(email="u1b@example.com")
u2 = make_user(email="u2b@example.com")
r1 = make_request(employee=u1)
r2 = make_request(employee=u2)
hidden_request = make_request(employee=owner)
client = auth_client(manager)
resp = client.get(API_BASE)
assert resp.status_code == 200
ids = {it["id"] for it in _results(resp.json())}
assert ids.issuperset({r1.id, r2.id})
ids = {item["id"] for item in _results(resp.json())}
assert hidden_request.id not in ids
# ------------------------------------------------------------------------------
# 2) ДЕТАЛЬНЫЙ ПРОСМОТР
# ------------------------------------------------------------------------------
def test_detail_regular_user_own_and_forbidden_foreign(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""Обычный пользователь: свою заявку видит, чужую — нет (404 через урезанный queryset)."""
other = make_user(email="other2@example.com")
mine = make_request(employee=regular_user)
foreign = make_request(employee=other)
client = auth_client(regular_user)
ok = client.get(f"{API_BASE}{mine.id}/")
no = client.get(f"{API_BASE}{foreign.id}/")
assert ok.status_code == 200
assert no.status_code in (403, 404) # через queryset ожидаем 404
def test_detail_admin_and_manager_can_see_any(
def test_detail_only_participants_can_view_request(
auth_client, admin_user: models.Model, make_user, grant_model_perm, make_request
) -> None:
"""Админ и менеджер с правами видят любую заявку."""
u = make_user(email="x@example.com")
r = make_request(employee=u)
"""Детали видны только участникам даже при наличии глобальных прав."""
owner = make_user(email="detail-owner@example.com")
recipient = make_user(email="detail-recipient@example.com")
cc_user = make_user(email="detail-cc@example.com")
outsider = make_user(email="detail-outsider@example.com")
privileged = make_user(email="detail-privileged@example.com")
grant_model_perm(privileged, "requests_app.view_request")
# Админ
aclient = auth_client(admin_user)
resp = aclient.get(f"{API_BASE}{r.id}/")
assert resp.status_code == 200
req = make_request(employee=owner)
req.recipients.add(recipient)
req.cc_users.add(cc_user)
# Менеджер
manager = make_user(email="manager2@example.com")
grant_model_perm(manager, "requests_app.view_request")
mclient = auth_client(manager)
resp = mclient.get(f"{API_BASE}{r.id}/")
assert resp.status_code == 200
assert auth_client(owner).get(f"{API_BASE}{req.id}/").status_code == 200
assert auth_client(recipient).get(f"{API_BASE}{req.id}/").status_code == 200
assert auth_client(cc_user).get(f"{API_BASE}{req.id}/").status_code == 200
assert auth_client(outsider).get(f"{API_BASE}{req.id}/").status_code == 403
assert auth_client(privileged).get(f"{API_BASE}{req.id}/").status_code == 403
assert auth_client(admin_user).get(f"{API_BASE}{req.id}/").status_code == 403
# ------------------------------------------------------------------------------
# 3) СОЗДАНИЕ
# ------------------------------------------------------------------------------
def test_create_regular_user_forces_employee_and_default_status(
auth_client, regular_user: models.Model, make_user
def test_create_regular_user_and_admin_forced_to_self(
auth_client, regular_user: models.Model, admin_user: models.Model, make_user
) -> None:
"""POST: обычному пользователю принудительно проставляется employee=текущий и дефолтный статус."""
recipient = make_user(email="recipient-regular@example.com")
client = auth_client(regular_user)
payload = {
"type": RequestType.VACATION,
"comment": "Отпуск на недельку",
"employee": 999,
"""Обычный пользователь и админ не могут создать заявку от чужого имени через API."""
recipient = make_user(email="create-recipient@example.com")
other = make_user(email="create-other@example.com")
regular_payload = {
"type": RequestType.OTHER,
"comment": "Отпуск",
"employee": other.id,
"status": RequestStatus.APPROVED,
"recipient_ids": [recipient.id],
}
resp = client.post(API_BASE, data=payload, format="json")
assert resp.status_code == 201
data = resp.json()
# Поле employee — текущий пользователь
assert (
str(data["employee"]["id"]) == str(regular_user.id)
or data["employee"]["id"] == regular_user.id
)
# Статус должен быть не финальный (дефолт модели — pending)
assert data["status"] in (RequestStatus.DRAFT, RequestStatus.PENDING)
def test_create_admin_can_set_employee(
auth_client, admin_user: models.Model, make_user
) -> None:
"""POST: админ может создавать заявку для другого пользователя."""
other = make_user(email="y@example.com")
recipient = make_user(email="recipient-admin@example.com")
client = auth_client(admin_user)
payload = {
"type": RequestType.SICK_LEAVE,
"employee": other.id,
admin_payload = {
"type": RequestType.OTHER,
"comment": "Больничный",
"employee": other.id,
"recipient_ids": [recipient.id],
}
resp = client.post(API_BASE, data=payload, format="json")
regular_resp = auth_client(regular_user).post(
API_BASE, data=regular_payload, format="json"
)
admin_resp = auth_client(admin_user).post(
API_BASE, data=admin_payload, format="json"
)
assert regular_resp.status_code == 201
assert admin_resp.status_code == 201
assert str(regular_resp.json()["employee"]["id"]) == str(regular_user.id)
assert str(admin_resp.json()["employee"]["id"]) == str(admin_user.id)
assert regular_resp.json()["status"] in (
RequestStatus.DRAFT,
RequestStatus.PENDING,
)
assert admin_resp.json()["status"] in (
RequestStatus.DRAFT,
RequestStatus.PENDING,
)
def test_create_draft_allows_minimal_payload(
auth_client, regular_user: models.Model
) -> None:
"""Черновик можно сохранить без типа, получателей и отделов."""
resp = auth_client(regular_user).post(
f"{API_BASE}?save_as=draft",
data={"title": "Минимальный черновик"},
format="json",
)
assert resp.status_code == 201
assert str(resp.json()["employee"]["id"]) == str(other.id)
data = resp.json()
assert data["status"] == RequestStatus.DRAFT
assert data["title"] == "Минимальный черновик"
assert data["type"] == ""
assert data["recipients"] == []
assert data["cc_users"] == []
# ------------------------------------------------------------------------------
# 4) ОБНОВЛЕНИЕ / 5) УДАЛЕНИЕ
# ------------------------------------------------------------------------------
def test_update_own_pending_ok_and_final_forbidden(
auth_client, regular_user: models.Model, make_request, make_user
def test_submit_requires_full_validation_even_for_existing_draft(
auth_client, regular_user: models.Model
) -> None:
"""PATCH: владелец правит не финальную заявку; финальную — нельзя (403/400)."""
"""Неполный черновик не переходит в pending без обязательных данных."""
create_resp = auth_client(regular_user).post(
f"{API_BASE}?save_as=draft",
data={"title": "Неполный черновик"},
format="json",
)
assert create_resp.status_code == 201
req_id = create_resp.json()["id"]
submit_resp = auth_client(regular_user).patch(
f"{API_BASE}{req_id}/?save_as=submit",
data={},
format="json",
)
assert submit_resp.status_code == 400
errors = submit_resp.json()
assert "type" in errors or "recipient_ids" in errors
def test_update_and_delete_require_author_even_for_admin(
auth_client, regular_user: models.Model, admin_user: models.Model, make_user, make_request
) -> None:
"""Редактирование и удаление через API доступны только автору."""
owner = make_user(email="update-owner@example.com")
recipient = make_user(email="update-recipient@example.com")
pending = make_request(employee=regular_user, status=RequestStatus.PENDING)
final = make_request(employee=regular_user, status=RequestStatus.APPROVED)
pending.recipients.add(recipient)
final.recipients.add(recipient)
client = auth_client(regular_user)
ok = client.patch(
f"{API_BASE}{pending.id}/", data={"comment": "обновлено"}, format="json"
)
deny = client.patch(
f"{API_BASE}{final.id}/", data={"comment": "нельзя"}, format="json"
)
assert ok.status_code == 200
assert deny.status_code in (400, 403)
def test_update_foreign_hidden(
auth_client, regular_user: models.Model, make_user, make_request
) -> None:
"""PATCH: чужая заявка для обычного пользователя недоступна (ожидаем 404 по queryset)."""
other = make_user(email="z@example.com")
foreign = make_request(employee=other)
client = auth_client(regular_user)
resp = client.patch(
f"{API_BASE}{foreign.id}/", data={"comment": "try"}, format="json"
)
assert resp.status_code in (403, 404)
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=RequestStatus.PENDING)
final = make_request(employee=regular_user, status=RequestStatus.REJECTED)
client = auth_client(regular_user)
ok = client.delete(f"{API_BASE}{pending.id}/")
deny = client.delete(f"{API_BASE}{final.id}/")
assert ok.status_code in (200, 204) # зависит от реализации destroy
assert deny.status_code in (400, 403)
# ------------------------------------------------------------------------------
# 6) ЭКШЕНЫ СТАТУСОВ
# ------------------------------------------------------------------------------
def test_actions_permissions_and_effects(
auth_client, admin_user: models.Model, make_user, grant_model_perm, make_request
) -> None:
"""approve/reject/cancel: права и изменение статусов."""
owner = make_user(email="owner@example.com")
manager = make_user(email="manager3@example.com")
grant_model_perm(manager, "requests_app.change_request") # или can_process_requests
req = make_request(employee=owner, status=RequestStatus.PENDING)
req.recipients.add(manager)
# Менеджер может 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"] == 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=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"] == 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=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"] == RequestStatus.REJECTED
# ------------------------------------------------------------------------------
# 7) КОММЕНТАРИИ
# ------------------------------------------------------------------------------
def test_comments_forbidden_for_regular_user(
auth_client,
regular_user: models.Model,
make_user,
make_request,
) -> None:
"""Владелец заявки может читать и создавать комментарии по своей заявке."""
mine = make_request(employee=regular_user)
client = auth_client(regular_user)
get_resp = client.get(f"{API_BASE}{mine.id}/comments/")
assert get_resp.status_code == 200
post_resp = client.post(
f"{API_BASE}{mine.id}/comments/",
data={"text": "мимопроходил"},
format="json",
)
assert post_resp.status_code == 201
def test_comments_allowed_for_admin_and_manager(
auth_client,
admin_user: models.Model,
make_user,
grant_model_perm,
make_request,
) -> None:
"""Проверяет доступ к комментариям:
- Админ видит и создаёт.
- Получатель заявки видит и создаёт как участник workflow.
"""
owner = make_user(email="owner-comments@example.com")
req = make_request(employee=owner)
# --- Админ ---
aclient = auth_client(admin_user)
get_a = aclient.get(f"{API_BASE}{req.id}/comments/")
assert get_a.status_code == 200, "Админ должен видеть список комментариев"
post_a = aclient.post(
f"{API_BASE}{req.id}/comments/",
data={"text": "от админа"},
format="json",
)
assert post_a.status_code == 201, "Админ должен уметь создавать комментарий"
recipient = make_user(email="manager-comments-recipient@example.com")
req.recipients.add(recipient)
rclient = auth_client(recipient)
get_r = rclient.get(f"{API_BASE}{req.id}/comments/")
assert get_r.status_code == 200, "Получатель должен видеть комментарии"
recipient_client = auth_client(recipient)
admin_client = auth_client(admin_user)
post_r = rclient.post(
recipient_patch = recipient_client.patch(
f"{API_BASE}{req.id}/", data={"comment": "try"}, format="json"
)
admin_patch = admin_client.patch(
f"{API_BASE}{req.id}/", data={"comment": "try"}, format="json"
)
recipient_delete = recipient_client.delete(f"{API_BASE}{req.id}/")
admin_delete = admin_client.delete(f"{API_BASE}{req.id}/")
assert recipient_patch.status_code == 403
assert admin_patch.status_code in (403, 404)
assert recipient_delete.status_code == 403
assert admin_delete.status_code in (403, 404)
own_recipient = make_user(email="own-update-recipient@example.com")
own_pending = make_request(
employee=regular_user,
type_=RequestType.OTHER,
status=RequestStatus.PENDING,
)
own_pending.recipients.add(own_recipient)
own_final = make_request(
employee=regular_user,
type_=RequestType.OTHER,
status=RequestStatus.APPROVED,
)
own_final.recipients.add(own_recipient)
own_client = auth_client(regular_user)
assert own_client.patch(
f"{API_BASE}{own_pending.id}/",
data={"comment": "обновлено"},
format="json",
).status_code == 200
assert own_client.patch(
f"{API_BASE}{own_final.id}/",
data={"comment": "нельзя"},
format="json",
).status_code in (400, 403)
assert own_client.delete(f"{API_BASE}{own_pending.id}/").status_code in (
200,
204,
)
assert own_client.delete(f"{API_BASE}{own_final.id}/").status_code == 403
def test_multiple_recipients_can_decide_until_request_becomes_final(
auth_client, make_user, make_request
) -> None:
"""Несколько прямых получателей могут решать только пока заявка не финализирована."""
owner = make_user(email="multi-owner@example.com")
recipient_one = make_user(email="multi-recipient-one@example.com")
recipient_two = make_user(email="multi-recipient-two@example.com")
req = make_request(employee=owner, status=RequestStatus.PENDING)
req.recipients.add(recipient_one, recipient_two)
first_resp = auth_client(recipient_one).post(
f"{API_BASE}{req.id}/approve/", data={}, format="json"
)
second_resp = auth_client(recipient_two).post(
f"{API_BASE}{req.id}/reject/", data={}, format="json"
)
assert first_resp.status_code == 200
assert first_resp.json()["status"] == RequestStatus.APPROVED
assert second_resp.status_code in (400, 403)
def test_only_direct_recipients_can_decide(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:
"""Автор, CC, посторонний и админ без recipients не могут принять решение."""
owner = make_user(email="decision-owner@example.com")
cc_user = make_user(email="decision-cc@example.com")
outsider = make_user(email="decision-outsider@example.com")
req = make_request(employee=owner, status=RequestStatus.PENDING)
req.cc_users.add(cc_user)
assert auth_client(owner).post(f"{API_BASE}{req.id}/approve/").status_code == 403
assert auth_client(cc_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403
assert auth_client(outsider).post(f"{API_BASE}{req.id}/approve/").status_code == 403
assert auth_client(admin_user).post(f"{API_BASE}{req.id}/approve/").status_code == 403
def test_admin_can_decide_only_if_explicitly_added_to_recipients(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:
"""Админ в обычном API ведёт себя как обычный прямой получатель."""
owner = make_user(email="admin-recipient-owner@example.com")
req = make_request(employee=owner, status=RequestStatus.PENDING)
req.recipients.add(admin_user)
resp = auth_client(admin_user).post(
f"{API_BASE}{req.id}/reject/", data={}, format="json"
)
assert resp.status_code == 200
assert resp.json()["status"] == RequestStatus.REJECTED
def test_draft_is_visible_only_to_author(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:
"""Черновик остаётся только у автора и не попадает во входящие адресатам."""
owner = make_user(email="draft-owner@example.com")
recipient = make_user(email="draft-recipient@example.com")
cc_user = make_user(email="draft-cc@example.com")
outsider = make_user(email="draft-outsider@example.com")
req = make_request(employee=owner, status=RequestStatus.DRAFT)
req.recipients.add(recipient)
req.cc_users.add(cc_user)
owner_ids = {
item["id"]
for item in _results(auth_client(owner).get(API_BASE).json())
}
recipient_ids = {
item["id"]
for item in _results(auth_client(recipient).get(API_BASE).json())
}
cc_ids = {
item["id"] for item in _results(auth_client(cc_user).get(API_BASE).json())
}
assert req.id in owner_ids
assert req.id not in recipient_ids
assert req.id not in cc_ids
assert (
req.id
not in {
item["id"]
for item in _results(
auth_client(recipient).get(
f"{API_BASE}?addressed_to_me=true"
).json()
)
}
)
assert (
req.id
not in {
item["id"]
for item in _results(
auth_client(cc_user).get(
f"{API_BASE}?addressed_to_me=true"
).json()
)
}
)
assert auth_client(owner).get(f"{API_BASE}{req.id}/").status_code == 200
assert auth_client(recipient).get(f"{API_BASE}{req.id}/").status_code == 403
assert auth_client(cc_user).get(f"{API_BASE}{req.id}/").status_code == 403
assert auth_client(outsider).get(f"{API_BASE}{req.id}/").status_code == 403
assert auth_client(admin_user).get(f"{API_BASE}{req.id}/").status_code == 403
def test_comments_and_decisions_are_forbidden_for_draft(
auth_client, make_user, make_request
) -> None:
"""По черновику нельзя комментировать и нельзя принимать решение."""
owner = make_user(email="draft-comments-owner@example.com")
recipient = make_user(email="draft-comments-recipient@example.com")
cc_user = make_user(email="draft-comments-cc@example.com")
req = make_request(employee=owner, status=RequestStatus.DRAFT)
req.recipients.add(recipient)
req.cc_users.add(cc_user)
assert auth_client(owner).get(f"{API_BASE}{req.id}/comments/").status_code == 403
assert auth_client(owner).post(
f"{API_BASE}{req.id}/comments/",
data={"text": "от получателя"},
data={"text": "owner draft comment"},
format="json",
).status_code == 403
assert (
auth_client(recipient).post(
f"{API_BASE}{req.id}/approve/", data={}, format="json"
).status_code
== 403
)
assert (
auth_client(recipient).post(
f"{API_BASE}{req.id}/reject/", data={}, format="json"
).status_code
== 403
)
assert auth_client(cc_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403
def test_author_can_submit_draft_explicitly_to_pending(
auth_client, make_user
) -> None:
"""Черновик переходит в pending только по явному save_as=submit."""
owner = make_user(email="draft-submit-owner@example.com")
recipient = make_user(email="draft-submit-recipient@example.com")
create_resp = auth_client(owner).post(
f"{API_BASE}?save_as=draft",
data={
"type": RequestType.OTHER,
"title": "Черновик на отправку",
"recipient_ids": [recipient.id],
},
format="json",
)
assert post_r.status_code == 201, "Получатель должен уметь создавать комментарий"
assert create_resp.status_code == 201
req_id = create_resp.json()["id"]
assert create_resp.json()["status"] == RequestStatus.DRAFT
assert auth_client(recipient).get(f"{API_BASE}{req_id}/").status_code == 403
submit_resp = auth_client(owner).patch(
f"{API_BASE}{req_id}/?save_as=submit",
data={},
format="json",
)
assert submit_resp.status_code == 200
assert submit_resp.json()["status"] == RequestStatus.PENDING
assert auth_client(recipient).get(f"{API_BASE}{req_id}/").status_code == 200
def test_comments_available_to_participants_only(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:
"""Комментарии доступны автору, прямому получателю и CC, но не посторонним."""
owner = make_user(email="comments-owner@example.com")
recipient = make_user(email="comments-recipient@example.com")
cc_user = make_user(email="comments-cc@example.com")
outsider = make_user(email="comments-outsider@example.com")
req = make_request(employee=owner)
req.recipients.add(recipient)
req.cc_users.add(cc_user)
owner_client = auth_client(owner)
recipient_client = auth_client(recipient)
cc_client = auth_client(cc_user)
assert owner_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200
assert recipient_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200
assert cc_client.get(f"{API_BASE}{req.id}/comments/").status_code == 200
assert owner_client.post(
f"{API_BASE}{req.id}/comments/",
data={"text": "owner"},
format="json",
).status_code == 201
assert recipient_client.post(
f"{API_BASE}{req.id}/comments/",
data={"text": "recipient"},
format="json",
).status_code == 201
assert cc_client.post(
f"{API_BASE}{req.id}/comments/",
data={"text": "cc"},
format="json",
).status_code == 201
assert auth_client(outsider).get(f"{API_BASE}{req.id}/comments/").status_code == 403
assert auth_client(outsider).post(
f"{API_BASE}{req.id}/comments/",
data={"text": "outsider"},
format="json",
).status_code == 403
assert auth_client(admin_user).get(f"{API_BASE}{req.id}/comments/").status_code == 403
assert auth_client(admin_user).post(
f"{API_BASE}{req.id}/comments/",
data={"text": "admin"},
format="json",
).status_code == 403
def test_detail_exposes_can_decide_only_for_direct_recipients(
auth_client, admin_user: models.Model, make_user, make_request
) -> None:
"""`can_decide` служит UI-контрактом и true только для direct recipients."""
owner = make_user(email="serializer-owner@example.com")
recipient = make_user(email="serializer-recipient@example.com")
cc_user = make_user(email="serializer-cc@example.com")
req = make_request(employee=owner, status=RequestStatus.PENDING)
req.recipients.add(recipient)
req.cc_users.add(cc_user)
owner_data = auth_client(owner).get(f"{API_BASE}{req.id}/").json()
recipient_data = auth_client(recipient).get(f"{API_BASE}{req.id}/").json()
cc_data = auth_client(cc_user).get(f"{API_BASE}{req.id}/").json()
assert owner_data["can_decide"] is False
assert recipient_data["can_decide"] is True
assert cc_data["can_decide"] is False
assert recipient_data["is_recipient"] is True
assert cc_data["is_recipient"] is True
assert auth_client(admin_user).get(f"{API_BASE}{req.id}/").status_code == 403
def test_comment_author_can_delete_own_comment(
@ -391,7 +541,7 @@ def test_comment_author_can_delete_own_comment(
regular_user: models.Model,
make_request,
) -> None:
"""Автор комментария может удалить свой комментарий без 500."""
"""Автор комментария может удалить свой комментарий."""
req = make_request(employee=regular_user)
client = auth_client(regular_user)
@ -403,11 +553,6 @@ def test_comment_author_can_delete_own_comment(
assert post_resp.status_code == 201
comment_id = post_resp.json()["id"]
list_before = client.get(API_BASE)
assert list_before.status_code == 200
item_before = next(item for item in _results(list_before.json()) if item["id"] == req.id)
assert item_before["comments_count"] == 1
delete_resp = client.delete(f"{API_BASE}{req.id}/comments/{comment_id}/")
assert delete_resp.status_code == 204
@ -416,58 +561,53 @@ def test_comment_author_can_delete_own_comment(
ids = {item["id"] for item in _results(list_resp.json())}
assert comment_id not in ids
list_after = client.get(API_BASE)
assert list_after.status_code == 200
item_after = next(item for item in _results(list_after.json()) if item["id"] == req.id)
assert item_after["comments_count"] == 0
# ------------------------------------------------------------------------------
# 8) ФИЛЬТРЫ
# ------------------------------------------------------------------------------
@pytest.mark.parametrize(
"flt,expected_types",
("flt", "expected_status"),
[
("?type=vacation", {RequestType.VACATION}),
("?status=pending", {RequestStatus.PENDING}),
("?type=vacation", RequestType.VACATION),
("?status=pending", RequestStatus.PENDING),
],
)
def test_filters_type_status_applied_within_scope(
def test_filters_apply_within_participant_scope(
auth_client,
admin_user: models.Model,
regular_user: models.Model,
make_user,
make_request,
flt: str,
expected_types: set[str],
expected_status: str,
) -> None:
"""Фильтры type/status применяются поверх выбранной области (all/mine)."""
u1 = make_user(email="fu1@example.com")
u2 = make_user(email="fu2@example.com")
make_request(employee=u1, type_=RequestType.VACATION, status=RequestStatus.PENDING)
make_request(employee=u2, type_=RequestType.SICK_LEAVE, status=RequestStatus.REJECTED)
"""Фильтры работают поверх уже вычисленной participant-only области."""
other = make_user(email="filter-owner@example.com")
my_request = make_request(
employee=regular_user,
type_=RequestType.VACATION,
status=RequestStatus.PENDING,
)
foreign_request = make_request(
employee=other,
type_=RequestType.SICK_LEAVE,
status=RequestStatus.REJECTED,
)
foreign_request.recipients.add(regular_user)
client = auth_client(admin_user)
resp = auth_client(regular_user).get(f"{API_BASE}{flt}")
# all
resp_all = client.get(f"{API_BASE}{flt}")
assert resp_all.status_code == 200
data_all = _results(resp_all.json())
assert len(data_all) >= 1
# mine (у админа обычно пусто; проверим, что не падает)
resp_mine = client.get(f"{API_BASE}?view=mine&{flt.lstrip('?')}")
assert resp_mine.status_code == 200
assert resp.status_code == 200
items = _results(resp.json())
assert items
for item in items:
if flt.startswith("?type="):
assert item["type"] == expected_status
else:
assert item["status"] == expected_status
assert my_request.id in {item["id"] for item in _results(auth_client(regular_user).get(API_BASE).json())}
# ------------------------------------------------------------------------------
# 9) БЕЗОПАСНОСТЬ / ПРОЧЕЕ
# ------------------------------------------------------------------------------
def test_invalid_filter_does_not_crash(auth_client, admin_user: models.Model) -> None:
def test_invalid_filter_does_not_crash(
auth_client, regular_user: models.Model
) -> None:
"""Странные значения фильтров не приводят к 500."""
client = auth_client(admin_user)
client = auth_client(regular_user)
resp = client.get(f"{API_BASE}?type=__sql__' OR 1=1 --&status=")
assert resp.status_code == 200

View File

@ -1,39 +1,31 @@
# mypy: ignore-errors
from __future__ import annotations
from typing import Any, Iterable
from typing import Any
import pytest
from django.db import transaction
from django.utils.crypto import get_random_string
from tests.test_config import API_REQUESTS_URL
# Константа API
API_BASE = API_REQUESTS_URL
from employees.models import (
Department,
DepartmentRole,
DepartmentPermission,
DepartmentRole,
EmployeeDepartment,
)
from tests.test_config import API_REQUESTS_URL
API_BASE = API_REQUESTS_URL
def _grant_dept_perm(user, dept, code: str) -> None:
"""Выдаёт пользователю департаментное право `code` для отдела `dept`.
Делает так:
- создаёт/находит DepartmentPermission(code)
- создаёт/находит DepartmentRole(department=dept, name='auto-{code}')
- добавляет право в role.scoped_permissions
- создаёт/активирует EmployeeDepartment(user, dept, role=эта_роль)
"""
"""Выдаёт пользователю департаментное право для проверки отсутствия байпаса."""
perm, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": code}
)
role, _ = DepartmentRole.objects.get_or_create(department=dept, name=f"auto-{code}")
role, _ = DepartmentRole.objects.get_or_create(
department=dept, name=f"auto-{code}"
)
role.scoped_permissions.add(perm)
link, _ = EmployeeDepartment.objects.get_or_create(
@ -44,17 +36,6 @@ def _grant_dept_perm(user, dept, code: str) -> None:
link.save(update_fields=["is_active", "role"])
# ЗАМЕНИТЕ фикстуры two_departments / dept_dataset на версии ниже
# (главное — создаём EmployeeDepartment для владельцев):
import pytest
from django.utils.crypto import get_random_string
def _random_email(prefix: str) -> str:
return f"{prefix}-{get_random_string(6).lower()}@example.com"
def _results(payload: Any):
if isinstance(payload, list):
return payload
@ -72,12 +53,10 @@ def two_departments(db):
@pytest.fixture
def dept_dataset(db, make_user, make_request, two_departments):
"""owner1 в dept1 + его заявка; owner2 в dept2 + его заявка.
Привязка к отделам — через EmployeeDepartment (is_active=True).
"""
"""Два владельца и две заявки в разных отделах."""
d1, d2 = two_departments
owner1 = make_user(email=_random_email("owner1"))
owner2 = make_user(email=_random_email("owner2"))
owner1 = make_user(email=f"owner1-{get_random_string(6).lower()}@example.com")
owner2 = make_user(email=f"owner2-{get_random_string(6).lower()}@example.com")
EmployeeDepartment.objects.get_or_create(
employee=owner1, department=d1, defaults={"is_active": True}
@ -88,13 +67,10 @@ def dept_dataset(db, make_user, make_request, two_departments):
req1 = make_request(employee=owner1)
req2 = make_request(employee=owner2)
# Если у Request есть FK department (есть) — выставим для консистентности:
if hasattr(req1, "department_id"):
req1.department = d1
req1.save(update_fields=["department"])
if hasattr(req2, "department_id"):
req2.department = d2
req2.save(update_fields=["department"])
req1.department = d1
req1.save(update_fields=["department"])
req2.department = d2
req2.save(update_fields=["department"])
return {
"dept1": d1,
@ -106,190 +82,86 @@ def dept_dataset(db, make_user, make_request, two_departments):
}
# ---------- Тесты видимости ----------
@pytest.mark.django_db
def test_dept_viewer_sees_only_own_department_requests(
def test_department_view_permission_does_not_grant_list_visibility(
auth_client, make_user, dept_dataset
):
"""Роль/право отдела 'view_request': видит заявки своего отдела и не видит чужой."""
"""Права отдела не раскрывают заявки вне participant-only модели."""
data = dept_dataset
manager = make_user(email=_random_email("manager-view"))
# Привязываем менеджера к dept1, если у пользователя есть поле department
if hasattr(manager, "department_id"):
setattr(manager, "department", data["dept1"])
manager.save(update_fields=["department"])
manager = make_user(email=f"manager-{get_random_string(6).lower()}@example.com")
_grant_dept_perm(manager, data["dept1"], "view_request")
client = auth_client(manager)
resp = client.get(API_BASE)
assert resp.status_code == 200, resp.content
items = _results(resp.json())
ids = {x["id"] for x in items}
resp = auth_client(manager).get(API_BASE)
assert data["req1"].id in ids, "должен видеть заявки своего отдела"
assert data["req2"].id not in ids, "не должен видеть заявки другого отдела"
assert resp.status_code == 200
assert _results(resp.json()) == []
@pytest.mark.django_db
def test_dept_viewer_cannot_retrieve_other_department_request(
def test_department_view_permission_does_not_grant_detail_visibility(
auth_client, make_user, dept_dataset
):
"""Роль/право отдела 'view_request': детальный просмотр чужого отдела запрещён."""
"""Даже детальный просмотр требует участия в заявке."""
data = dept_dataset
manager = make_user(email=_random_email("manager-view2"))
if hasattr(manager, "department_id"):
setattr(manager, "department", data["dept1"])
manager.save(update_fields=["department"])
manager = make_user(email=f"manager2-{get_random_string(6).lower()}@example.com")
_grant_dept_perm(manager, data["dept1"], "view_request")
client = auth_client(manager)
# Чужой отдел
resp = client.get(f"{API_BASE}{data['req2'].id}/")
assert resp.status_code == 403, resp.content
resp = auth_client(manager).get(f"{API_BASE}{data['req1'].id}/")
# ---------- Статусные экшены (approve/reject) ----------
assert resp.status_code == 403
@pytest.mark.django_db
def test_dept_processor_can_approve_own_department_request(
def test_department_comment_permissions_do_not_grant_comment_access(
auth_client, make_user, dept_dataset
):
"""Пользователь с департаментным правом обработки может approve в своём отделе."""
"""Права отдела на комментарии больше не открывают комментарии без участия."""
data = dept_dataset
processor = make_user(email=_random_email("processor"))
if hasattr(processor, "department_id"):
setattr(processor, "department", data["dept1"])
processor.save(update_fields=["department"])
for code in ("can_process_requests",):
_grant_dept_perm(processor, data["dept1"], code)
data["req1"].recipients.add(processor)
client = auth_client(processor)
resp = client.post(f"{API_BASE}{data['req1'].id}/approve/")
assert resp.status_code in (200, 201), resp.content
@pytest.mark.django_db
def test_dept_processor_cannot_approve_other_department_request(
auth_client, make_user, dept_dataset
):
"""Пользователь с департаментным правом обработки НЕ может approve в чужом отделе."""
data = dept_dataset
processor = make_user(email=_random_email("processor2"))
if hasattr(processor, "department_id"):
setattr(processor, "department", data["dept1"])
processor.save(update_fields=["department"])
for code in ("can_process_requests", "change_request"):
_grant_dept_perm(processor, data["dept1"], code)
client = auth_client(processor)
resp = client.post(f"{API_BASE}{data['req2'].id}/approve/")
assert resp.status_code == 403, resp.content
# ---------- Комментарии по отделам ----------
@pytest.mark.django_db
def test_dept_comment_viewer_can_read_comments_in_department(
auth_client, make_user, dept_dataset
):
"""Право отдела 'view_requestcomment' даёт доступ к GET /comments/ в своём отделе."""
data = dept_dataset
viewer = make_user(email=_random_email("comm-view"))
if hasattr(viewer, "department_id"):
setattr(viewer, "department", data["dept1"])
viewer.save(update_fields=["department"])
viewer = make_user(email=f"viewer-{get_random_string(6).lower()}@example.com")
adder = make_user(email=f"adder-{get_random_string(6).lower()}@example.com")
_grant_dept_perm(viewer, data["dept1"], "view_requestcomment")
client = auth_client(viewer)
resp = client.get(f"{API_BASE}{data['req1'].id}/comments/")
assert resp.status_code == 200, resp.content
@pytest.mark.django_db
def test_dept_comment_adder_can_post_in_department(
auth_client, make_user, dept_dataset
):
"""Право отдела 'add_requestcomment' даёт доступ к POST /comments/ в своём отделе."""
data = dept_dataset
adder = make_user(email=_random_email("comm-add"))
if hasattr(adder, "department_id"):
setattr(adder, "department", data["dept1"])
adder.save(update_fields=["department"])
_grant_dept_perm(adder, data["dept1"], "add_requestcomment")
client = auth_client(adder)
resp = client.post(
f"{API_BASE}{data['req1'].id}/comments/", data={"text": "ok"}, format="json"
get_resp = auth_client(viewer).get(f"{API_BASE}{data['req1'].id}/comments/")
post_resp = auth_client(adder).post(
f"{API_BASE}{data['req1'].id}/comments/",
data={"text": "forbidden"},
format="json",
)
assert resp.status_code == 201, resp.content
assert get_resp.status_code == 403
assert post_resp.status_code == 403
@pytest.mark.django_db
def test_dept_comment_viewer_forbidden_in_other_department(
def test_department_processor_can_approve_only_as_direct_recipient(
auth_client, make_user, dept_dataset
):
"""Право отдела 'view_requestcomment' НЕ раскрывает комментарии чужого отдела."""
"""Департаментная роль не важна: решает только прямой получатель."""
data = dept_dataset
viewer = make_user(email=_random_email("comm-view2"))
if hasattr(viewer, "department_id"):
setattr(viewer, "department", data["dept1"])
viewer.save(update_fields=["department"])
processor = make_user(
email=f"processor-{get_random_string(6).lower()}@example.com"
)
_grant_dept_perm(processor, data["dept1"], "can_process_requests")
data["req1"].recipients.add(processor)
_grant_dept_perm(viewer, data["dept1"], "view_requestcomment")
resp = auth_client(processor).post(f"{API_BASE}{data['req1'].id}/approve/")
client = auth_client(viewer)
resp = client.get(f"{API_BASE}{data['req2'].id}/comments/")
assert resp.status_code == 403, resp.content
# ---------- Создание/валидация department ----------
assert resp.status_code in (200, 201)
@pytest.mark.django_db
def test_regular_user_cannot_set_foreign_department_on_create(
auth_client, regular_user, two_departments
def test_department_processor_without_recipient_cannot_approve(
auth_client, make_user, dept_dataset
):
"""Обычный пользователь не должен уметь подставлять чужой department при создании.
"""Права отдела без recipients не дают approve даже в своём отделе."""
data = dept_dataset
processor = make_user(
email=f"processor2-{get_random_string(6).lower()}@example.com"
)
_grant_dept_perm(processor, data["dept1"], "can_process_requests")
Ожидаем: либо игнор (проставится свой отдел), либо 400/403.
"""
d1, d2 = two_departments
# Привяжем пользователя к dept1, если поле есть:
if hasattr(regular_user, "department_id"):
setattr(regular_user, "department", d1)
regular_user.save(update_fields=["department"])
resp = auth_client(processor).post(f"{API_BASE}{data['req1'].id}/approve/")
client = auth_client(regular_user)
payload = {
"type": "vacation",
"title": "Попытка в чужой отдел",
"comment": "…",
# пробуем подменить отдел
"department": getattr(d2, "id", None),
}
resp = client.post(API_BASE, data=payload, format="json")
# Допустимы два корректных варианта поведения: запрет или игнор чужого отдела.
if resp.status_code in (400, 403):
return
assert resp.status_code == 201, resp.content
data = resp.json()
# Если заявка создалась — проверяем, что отдел НЕ чужой
if (
"department" in data
and isinstance(data["department"], dict)
and "id" in data["department"]
):
assert data["department"]["id"] != getattr(d2, "id", -1)
assert resp.status_code == 403

View File

@ -26,6 +26,7 @@
- [README.md](../README.md) - основная документация проекта
- [docs/reports/](reports/) - отчеты и планы по проекту
- [docs/guides/frontend/](guides/frontend/) - карта frontend UI-системы и guide по рефакторингу интерфейса
## 🔍 Поиск информации

View File

@ -1,102 +0,0 @@
# Frontend Style Guide
Короткий проектный контракт для новых экранов и рефакторинга старых.
## Базовые поверхности
- Внешний самостоятельный блок страницы: `app-surface`
- Внутренний блок внутри секции: `app-surface-muted`
- Не делать `card inside card inside card`, если можно обойтись одним внешним и одним внутренним уровнем.
## Подписи секций
- Верхние подписи карточек и секций: `app-card-caption`
- Внутренние заголовки внутри блоков:
- `text-sm font-semibold text-[var(--foreground)]`
- Не смешивать `app-card-caption` и обычный заголовок в одном уровне вложенности.
## Кнопки
- Primary action:
- `app-action-primary`
- Secondary action:
- `app-action-secondary`
- Danger action:
- secondary-style кнопка с red hover/focus, а не тяжелый красный прямоугольник по умолчанию
- Все add/create CTA должны выглядеть одинаково:
- иконка `Plus`
- `app-action-primary`
- `rounded-xl`
- одинаковая высота и плотность
## Pills и бейджи
- Нейтральный chip/filter: `app-pill`
- Активный/select/primary state: `app-pill-active` или `app-selected`
- Статусы должны быть семантическими:
- success: `app-feedback-success`
- warning: `app-feedback-warning`
- danger: `app-feedback-danger`
- Не использовать случайный синий только потому, что нужен акцент.
## Формы
- Текстовые поля: `app-input`
- Select: `app-select`
- Поисковая строка в шапке: pill-форма
- Обычные form fields: скругленные углы, не pill
- Не смешивать разные hover/focus-паттерны на одном экране
## Профильные и read-only страницы
- `/profile` и `/users/[id]`:
- hero сверху
- отдельные широкие секции ниже
- не дублировать settings-редактирование карандашами в нескольких местах
- Read-only экран:
- минимум inline editing
- одно явное действие лучше, чем несколько карандашей по карточкам
- Общий контракт профильно-подобных страниц:
- `ProfileHeroCard`
- `ProfileContactsPanel`
- `ProfileInfoCard`
- `ProfileSkillsCard`
- `EmployeeActionsTimeline`
- Hero:
- `app-card-caption` в шапке карточки
- status pill справа сверху
- аватар слева, имя и secondary meta справа
- внутри hero только один `app-surface-muted` блок контактов
- `Контакты`:
- строки с иконкой, заголовком и значением
- опциональные действия справа живут в строке, а не отдельной карточкой
- `Информация`:
- отдельный полноширинный `app-surface`
- внутри одна `app-surface-muted` сетка 2x2
- `Навыки`:
- одинаковая add-row и chip-scale и на self, и на foreign profile
- `/profile`:
- self-oriented hero
- без chat/call actions
- `/users/[id]`:
- тот же hero-contract
- chat/call/edit/manage actions допустимы только как добавочные props, без отдельной layout-ветки
## Таймлайны
- Линия события цветная по типу события
- Точка только на текущем событии
- Текущее событие определяется по дате, а не по первой записи в списке
## Контакты
- Контактные блоки строятся как список строк внутри `app-surface-muted`
- Не делать отдельную маленькую карточку на каждый контакт
- Иконки контактов нейтральны, если нет реальной причины для акцента
## Чего избегать
- `bg-white`, `bg-gray-*`, `ring-gray-*`, `focus:ring-sky-*` в новых и перерабатываемых экранах
- Разных стилей add/save/delete между соседними страницами
- Декоративных badge без логики состояния
- Смешения old-gray-contract и `app-*` токенов в одном блоке

View File

@ -1,363 +0,0 @@
# Оптимизация кэширования - Резюме изменений
## Проблема
- Страницы перезагружались каждый раз при открытии (отсутствие браузерного кэша)
- Множественные дублирующиеся API запросы на загрузку страницы
- Отсутствие Cache-Control заголовков для HTML страниц
## Решение
### 1. Backend: Django Cache + Redis (Серверное кэширование)
**Файлы**:
- `backend/eusrr_backend/settings.py` - настройка Redis CACHES
- `backend/common/cache_decorators.py` - декораторы кэширования
- `backend/feed/views_front.py` - применение кэширования к views
**Что делает**:
```python
# Кэширование в Redis на 60 секунд для каждого пользователя
@cache_page_per_user(timeout=60, cache_prefix="feed_list")
def feed_list(request):
...
# Инвалидация кэша после изменений
from common.cache_decorators import invalidate_cache_pattern
invalidate_cache_pattern("feed_list:*")
```
**Результат**:
- ✅ Одинаковые запросы от одного пользователя берутся из Redis
- ✅ Снижение нагрузки на БД ~70%
- ✅ Автоматическая инвалидация при создании/обновлении/удалении
---
### 2. Backend: Cache-Control Headers (Браузерное кэширование)
**Файлы**:
- `backend/eusrr_backend/middleware.py` - новый `CacheControlMiddleware`
- `backend/eusrr_backend/settings.py` - добавлен middleware в MIDDLEWARE
**Что делает**:
```python
class CacheControlMiddleware:
"""Добавляет Cache-Control: private, max-age=60 к HTML страницам"""
# Исключения: /api/, /admin/, /auth/, /static/, /media/
```
**Результат**:
- ✅ Браузер кэширует HTML страницы на 60 секунд
- ✅ Повторные заходы на страницу НЕ делают HTTP запрос к серверу
- ✅ Private cache - данные не кэшируются прокси
- ✅ Декораторы views могут переопределить TTL
---
### 3. Frontend: DataManager Cache (Клиентское кэширование)
**Файлы** (уже были созданы ранее):
- `backend/static/js/managers/dataManager.js` - синглтон с кэшем
- `backend/static/js/api/calendarApi.js` - обертка с TTL 30s
- `backend/static/js/api/departmentsApi.js` - обертка с TTL 60s
- `backend/static/js/api/notificationsApi.js` - обертка с TTL 10s
- `backend/static/js/api/postsApi.js` - обертка с TTL 30s
- `backend/static/js/api/employeesApi.js` - обертка с TTL 60s
**Что делает**:
- Дедупликация одновременных запросов
- In-memory кэш в браузере с TTL
- Автоинвалидация по WebSocket событиям
**Результат**:
- ✅ Снижение сетевых запросов ~60%
- ✅ Мгновенный ответ для повторных запросов
- ✅ Умная инвалидация при изменениях
---
### 4. Исправление модулей
**Файлы**:
- `backend/templates/base.html` - изменен тип скрипта notification-manager
**До**:
```html
<script src="notification-manager.js" defer></script>
```
**После**:
```html
<script type="module" src="notification-manager.js"></script>
```
**Результат**:
- ✅ Исправлена ошибка "Cannot use import statement outside a module"
- ✅ notification-manager.js теперь работает с ES6 imports
---
### 5. Устранение FOUC (мигания белой темы)
**Проблема**: При переходе по страницам мигала белая тема, даже если выбрана тёмная.
**Причина**: Тема применялась через JavaScript **ПОСЛЕ** загрузки CSS, что вызывало Flash of Unstyled Content.
**Файлы**:
- `backend/templates/base.html` - добавлен синхронный скрипт в `<head>`
**Решение**:
```html
<head>
<!-- КРИТИЧНО: До загрузки CSS! -->
<script src="themeInitializer.js"></script>
<!-- Preload критичных CSS -->
<link rel="preload" href="bootstrap-custom.css" as="style">
<link rel="preload" href="variables.css" as="style">
<!-- Теперь загружаем CSS -->
<link rel="stylesheet" href="bootstrap-custom.css">
...
</head>
```
**Результат**:
- ✅ Тема применяется **ДО** загрузки CSS
- ✅ Нет мигания белой темы при переходах
- ✅ Preload ускоряет загрузку критичных стилей
- ✅ Плавные переходы между страницами
**Почему это важно для кэширования**:
- Стили **всегда кэшировались браузером** (это статика)
- Проблема была НЕ в кэше, а в **порядке выполнения** скриптов
- Теперь даже с кэшем не будет мигания!
---
## Совокупный эффект
### Уровни кэширования (3 слоя):
```
Браузер → DataManager (память) → Cache-Control (60s) → Django Redis (60-120s) → База данных
↓ 60% запросов ↓ 85% запросов ↓ 70% запросов
```
### Метрики:
**До оптимизации**:
- Загрузка ленты: ~500ms + 10+ API запросов
- Каждый refresh: полная перезагрузка всех данных
- Дублирование: departments (3x), calendar (2-3x)
**После оптимизации**:
- 🚀 Загрузка ленты: ~150ms + 2-3 API запроса (при холодном кэше)
- 🚀 Повторный заход (< 60s): ~50ms + 0 API запросов (из браузерного кэша)
- 🚀 Refresh в течение TTL: мгновенно из DataManager
- 🚀 Снижение API запросов: **~85%**
- 🚀 Снижение нагрузки на БД: **~70%**
- 🚀 Ускорение загрузки: **3-10x**
---
## Тестирование
### 1. Проверить заголовки Cache-Control
```bash
curl -I http://localhost:9000/feed/ | grep Cache-Control
# Ожидаем: Cache-Control: private, max-age=60
# Ожидаем: X-Cache: MISS (первый раз) или X-Cache: HIT (повторно)
```
### 2. Проверить Redis кэш
```bash
redis-cli
> KEYS eusrr_cache:*
> GET eusrr_cache:feed_list:user_1:/feed/:
```
### 3. Проверить DataManager в консоли браузера
```javascript
// Откройте DevTools → Console
// Должны видеть логи:
// [DataManager] Fetching: calendar:events:...
// [DataManager] Cache HIT: calendar:events:... (age: 123ms)
// [DataManager] Request DEDUPED: departments:my
```
### 4. Проверить Network tab
- Откройте страницу первый раз → все запросы
- Обновите страницу (F5) в течение 60 секунд → страница загружается из disk cache
- Подождите > 60 секунд → новый запрос, но с X-Cache: HIT от Django
---
## Инвалидация кэша
### Когда кэш инвалидируется автоматически:
**Backend (Django/Redis)**:
- ✅ Создание поста → `invalidate_cache_pattern("feed_list:*")`
- ✅ Обновление поста → инвалидация всех лент
- ✅ Удаление поста → инвалидация всех лент
**Frontend (DataManager)**:
- ✅ WebSocket событие notification → `invalidateNotifications()`
- ✅ Истечение TTL → автоматическое обновление
- ✅ Ручная инвалидация через API методы
### Ручная инвалидация (если нужно):
**Backend**:
```python
from common.cache_decorators import invalidate_cache_pattern
invalidate_cache_pattern("feed_list:*") # Все ленты компании
invalidate_cache_pattern("dept_feed:*") # Все ленты отделов
```
**Frontend**:
```javascript
import { invalidateCalendar } from '/static/js/api/calendarApi.js';
import { invalidatePosts } from '/static/js/api/postsApi.js';
invalidateCalendar();
invalidatePosts();
```
---
## Что НЕ кэшируется (by design)
❌ `/api/*` - API управляет своим кэшем
❌ `/admin/*` - админка всегда свежая
❌ `/auth/*` - страницы аутентификации
❌ POST/PUT/DELETE запросы
❌ Страницы с формами создания/редактирования (CSRF issues)
---
## Архитектурные принципы
✅ **Правильно**:
- Все данные идут через API
- Кэширование на уровне HTTP/Redis
- Умная инвалидация при изменениях
❌ **Неправильно** (так делать НЕ надо):
- ~~Прямой доступ к БД из views~~
- ~~Данные минуя API~~
- ~~`location.reload()` без необходимости~~
---
## Deployment
```bash
# На сервере
cd /path/to/project
git pull origin master
# Перезапуск
sudo systemctl restart eusrr
# Проверка Redis
redis-cli ping # Должно вернуть PONG
# Очистка старого кэша (опционально)
redis-cli FLUSHDB
```
---
## Мониторинг
### Redis stats
```bash
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses'
# Рассчитать hit rate: hits / (hits + misses) * 100%
```
### Django debug toolbar (в development)
- Установить: `pip install django-debug-toolbar`
- Видеть количество SQL запросов до/после кэша
### Browser DevTools
- Network tab → Disable cache (для тестирования холодного кэша)
- Network tab → Size column → "(disk cache)" = работает!
- Console → логи DataManager
---
## Полезные команды
```bash
# Очистить весь кэш Django
python manage.py shell -c "from django.core.cache import cache; cache.clear(); print('Cache cleared')"
# Посмотреть все ключи кэша
redis-cli KEYS "eusrr_cache:*"
# Удалить конкретный паттерн
redis-cli KEYS "eusrr_cache:feed_list:*" | xargs redis-cli DEL
# Мониторинг Redis в реальном времени
redis-cli MONITOR
```
---
## Заключение
Реализовано **правильное многоуровневое кэширование** с соблюдением архитектуры:
- ✅ Все данные через API (не в обход)
- ✅ Три уровня кэша (браузер → память → Redis)
- ✅ Умная инвалидация на всех уровнях
- ✅ Снижение нагрузки на 70-85%
- ✅ Ускорение загрузки в 3-10 раз
Проблема "страницы перезагружаются каждый раз" **решена** через Cache-Control headers! 🎉
---
## Быстрая проверка (Quick Check)
### ✅ Checklist после деплоя:
1. **Redis работает?**
```bash
redis-cli ping
# Ожидаем: PONG
```
2. **Django видит Redis?**
```bash
python manage.py shell -c "from django.core.cache import cache; cache.set('test', 'ok'); print(cache.get('test'))"
# Ожидаем: ok
```
3. **Cache-Control headers работают?**
```bash
curl -I http://your-domain.com/feed/ | grep -E 'Cache-Control|X-Cache'
# Ожидаем:
# Cache-Control: private, max-age=60
# X-Cache: MISS (первый запрос) или HIT (повторный)
```
4. **DataManager работает?**
- Откройте страницу
- F12 → Console
- Ищите логи: `[DataManager] Cache HIT` и `Request DEDUPED`
5. **Notification manager без ошибок?**
- F12 → Console
- НЕ должно быть: "Cannot use import statement outside a module"
### 🔥 Быстрый тест производительности:
1. Откройте `/feed/` → засеките время загрузки (DevTools → Network)
2. Обновите страницу (F5) в течение 60 секунд
3. Результат: страница должна загрузиться **мгновенно** из disk cache (размер будет показан как "disk cache" в столбце Size)
Если все чеклисты ✅ - оптимизация работает! 🚀

View File

@ -1,96 +0,0 @@
# 🔒 IP-ограничения для регистрации - Краткая инструкция
## ✅ Что сделано
Добавлен **middleware** для ограничения доступа к регистрации по IP-адресу.
## 📁 Изменённые файлы
1. **`backend/eusrr_backend/middleware.py`** - добавлен `RegistrationIPRestrictionMiddleware`
2. **`backend/eusrr_backend/settings.py`** - middleware включен в `MIDDLEWARE`
3. **`backend/common/ip_restrictions.py`** - утилиты для проверки IP
## ⚙️ Настройка
В `backend/eusrr_backend/settings.py` найдите:
```python
# IP ОГРАНИЧЕНИЯ ДЛЯ РЕГИСТРАЦИИ
REGISTRATION_ALLOWED_IPS = None # По умолчанию только локальные IP
```
### Варианты настройки:
```python
# 1. Только локальные IP (по умолчанию)
REGISTRATION_ALLOWED_IPS = None
# 2. Разрешить все IP (для разработки)
REGISTRATION_ALLOWED_IPS = ['*']
# 3. Разрешить конкретные сети
REGISTRATION_ALLOWED_IPS = [
'192.168.1.0/24', # Локальная сеть офиса
'10.0.0.0/8', # Корпоративная сеть
]
# 4. Разрешить конкретные IP
REGISTRATION_ALLOWED_IPS = ['192.168.1.100', '203.0.113.42']
```
## 🎯 Как работает
**По умолчанию (None):**
- ✅ Разрешены: `127.0.0.1`, `192.168.x.x`, `10.x.x.x`, `172.16-31.x.x`
- ❌ Заблокированы: все внешние IP
**Блокируемые URL:**
- `/auth/register/` (веб-форма)
- `/api/v1/auth/register/` (API)
**Ответ при блокировке:**
- API: `{"detail": "Регистрация доступна только из локальной сети.", "client_ip": "..."}`
- Веб: Красивая страница 403 с информацией об IP
## 🔧 Отключение
### Временно (для разработки):
```python
REGISTRATION_ALLOWED_IPS = ['*']
```
### Полностью:
В `settings.py` закомментируйте строку в `MIDDLEWARE`:
```python
# "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware",
```
## 🧪 Тестирование
```bash
# Из локальной сети - работает
curl http://localhost:9000/api/v1/auth/register/ \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com",...}'
# С внешнего IP - блокируется
curl http://localhost:9000/api/v1/auth/register/ \
-H "X-Forwarded-For: 203.0.113.50" \
-d '{"email":"test@example.com",...}'
```
## 🌐 Работа за прокси
Middleware автоматически определяет реальный IP через заголовки `X-Forwarded-For` и `X-Real-IP`.
Настройте Nginx:
```nginx
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
}
```
## 📚 Полная документация
См. `IP_REGISTRATION_RESTRICTIONS.md`

View File

@ -4,29 +4,64 @@
## 📚 Доступные руководства
### Валидация и формы
### Frontend UI система
- **[frontend/](frontend/)** - нормативная карта frontend UI-системы, геометрии, карточек, форм и модалок
### Frontend и UX
- **MODERN_DOCUMENT_UI.md** - ориентиры по UI системы документов
- **RECIPIENT_PICKER_UX_IMPROVEMENTS.md** - актуальный compose UX для формы заявлений
### Requests
- **REQUESTS_DETAIL_VIEW_GUIDE.md** - текущие правила detail view заявлений
- **testing-request-filters.md** - ручная проверка фильтров заявок
### Валидация
- **LIVE_VALIDATION_GUIDE.md** - живая валидация форм
- **NAME_VALIDATION.md** - валидация имен
### Миграция и обновление
### API и backend
- **API_TESTING_GUIDE.md** - тестирование API
- **API_V2_MIGRATION.md** - текущее состояние и контракт API v2
- **DJANGO_RULES_INTEGRATION.md** - object-level permissions через django-rules
- **DJANGO_CORS_SETUP.md** - CORS для frontend/backend
### Realtime и WebSocket
- **REALTIME_MIGRATION_GUIDE.md** - миграция на realtime
- **WEBSOCKET_UNIFIED_MIGRATION.md** - унифицированный WebSocket
### Быстрый старт
- **MESSENGER_QUICKSTART.md** - быстрый старт с мессенджером
### UX и интерфейс
- **RECIPIENT_PICKER_UX_IMPROVEMENTS.md** - улучшения выбора получателей
- **WEBSOCKET_BACKEND_SETUP.md** - запуск backend с WebSocket
### Кэширование и оптимизация
- **CACHE_OPTIMIZATION_SUMMARY.md** - оптимизация кэширования
- **CACHING_SETUP.md** - настройка кэша
- **MESSAGE_NOTIFICATIONS_OPTIMIZATION.md** - оптимизация уведомлений сообщений
### Безопасность
- **IP_RESTRICTIONS_README.md** - IP ограничения
### Infrastructure и deployment
- **CELERY_SETUP.md** - запуск Celery в development
- **CELERY_PRODUCTION_DEPLOY.md** - production deploy Celery
### Безопасность и доступ
- **IP_REGISTRATION_RESTRICTIONS.md** - ограничения регистрации
- **ldap-orm-mixins-usage.md** - использование LDAP ORM mixins
- **ldap-password-change-admin.md** - смена LDAP-паролей через админку
### Legacy / на пересмотр
- **CALENDAR_TESTING.md** - сценарии проверки календаря; содержит legacy-термины и требует отдельной ревизии
- **SCSS_QUICK_REFERENCE.md** - справка по старому SCSS-стеку; актуальна только для legacy-частей
## 🧹 Что удалено из `guides`
Как лишние или дублирующие документы удалены:
- `CALENDAR_LIBRARIES.md` - пустой файл
- `MESSENGER_QUICKSTART.md` - пустой файл
- `IP_RESTRICTIONS_README.md` - краткий дубль `IP_REGISTRATION_RESTRICTIONS.md`
- `CACHE_OPTIMIZATION_SUMMARY.md` - summary-дубль `CACHING_SETUP.md`
## 🎯 Использование
Эти гайды содержат актуальные инструкции для разработчиков.
При внесении изменений в функционал - обновляйте соответствующие гайды.
Этот каталог должен содержать только актуальные developer guides.
Правила:
- завершённые implementation reports лучше хранить в `docs/completed/` или `docs/reports/`
- активные нормативные документы и инструкции остаются в `docs/guides/`
- если guide устарел, его нужно либо переписать, либо удалить, а не оставлять рядом с актуальным

View File

@ -1,420 +1,93 @@
# UX Улучшения: RecipientPicker в стиле Email-клиента
**Дата:** 2025-12-02
**Коммит:** `e68404c`
**Основано на:** Gmail, Outlook, Apple Mail
---
## 🎯 Проблема
Первоначальный дизайн RecipientPicker был:
- ❌ Неинтуитивным (выпадающие списки select)
- ❌ Требовал кликов для раскрытия секций (collapse)
- ❌ Не показывал контекст (кто уже выбран)
- ❌ Плохо работал на мобильных устройствах
- ❌ Не похож на привычные интерфейсы
---
## ✨ Решение: Email-style UI
### Вдохновение
Интерфейс создания заявления теперь похож на отправку email:
```
┌─────────────────────────────────────────────────┐
│ ✉️ Новое заявление [×] │
├─────────────────────────────────────────────────┤
│ │
│ 🏢 Отделы: [IT отдел] [HR] [+Выбрать отдел] │
│ ☑ Всем сотрудникам │
│ │
│ ✓ Кому: [Иванов И.] [Петрова А.] _______ │
│ [+ Копия] │
│ │
│ ─────────────────────────────────────────────── │
│ │
│ Тип: [📅 Отпуск ▼] │
│ Тема: [_________________________] │
│ ... │
└─────────────────────────────────────────────────┘
```
---
## 🎨 Ключевые улучшения
### 1. Chip-based Input (Token Input)
**До:**
```html
<select multiple>
<option>Иванов Иван</option>
<option>Петрова Анна</option>
</select>
```
**После:**
```html
[Иванов И. ×] [Петрова А. ×] _начните вводить имя..._
└─ Иванов Игорь
Иванова Мария
Игнатьев Петр
```
**Преимущества:**
- ✅ Визуально понятно, кто выбран
- ✅ Один клик для удаления (×)
- ✅ Не занимает много места
- ✅ Как в Gmail/Slack/Telegram
---
### 2. Autocomplete с живым поиском
**Функционал:**
- Начинаете вводить имя → появляются подсказки
- Поиск по имени, фамилии, email, должности
- Показ аватаров (инициалы)
- Ограничение до 10 результатов
- Подсветка при hover
**Пример dropdown:**
```
┌───────────────────────────────────────┐
│ [ИИ] Иванов Иван │
│ Старший разработчик │
├───────────────────────────────────────┤
│ [ИМ] Иванова Мария │
│ HR менеджер │
├───────────────────────────────────────┤
│ [ИП] Игнатьев Петр │
│ ivanov@company.com │
└───────────────────────────────────────┘
```
---
### 3. To/CC разделение
**Паттерн из email:**
- **To (Кому):** основные получатели
- **CC (Копия):** наблюдатели
- Кнопка "+ Копия" для показа поля CC
- Визуальное отличие (цвет chips)
**Код:**
```javascript
// Основные получатели - синие chips
<span class="recipient-chip">Иванов И. ×</span>
// CC - серые chips
<span class="recipient-chip chip-cc">Петрова А. ×</span>
```
---
### 4. Компактная структура "рядами"
**Layout:**
```
Отделы: [chips] [dropdown] [☑ Всем сотрудникам]
───────────────────────────────────────────────────
Кому: [chips] _input с autocomplete_ [+ Копия]
───────────────────────────────────────────────────
Копия: [chips] _input с autocomplete_
```
**CSS:**
```css
.recipient-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
border-bottom: 1px solid;
}
.recipient-label {
min-width: 80px; /* To:/CC:/Отделы: */
}
.recipient-field {
flex: 1; /* Растягивается */
}
```
---
### 5. Визуальные улучшения
#### Иконки и эмодзи
```html
<!-- Типы заявлений -->
<option value="vacation">📅 Отпуск</option>
<option value="sick_leave">🏥 Больничный</option>
<option value="transfer">↔️ Перевод</option>
<option value="dismissal">👋 Увольнение</option>
<option value="other">📋 Другое</option>
<!-- Метки полей -->
🏢 Отделы:
✓ Кому:
➕ Копия:
```
#### Аватары пользователей
```html
<div class="user-avatar-small">ИИ</div>
```
```css
.user-avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bs-primary);
color: white;
font-weight: 600;
}
```
---
### 6. Улучшенная модальная структура
**Изменения:**
- `modal-dialog` → `modal-dialog modal-lg` (шире)
- Получатели в начале (как в email)
- Убраны collapsible секции
- Разделители между секциями
**До:**
```
[Тип]
[Заголовок]
[Даты]
[Комментарий]
[Вложение]
└─ [▼ Получатели] (свернуто)
```
**После:**
```
[Получатели - всегда видны]
─────────────────────────────
[Тип] 📅
[Тема]
[Даты]
[Комментарий]
[Вложение] 📎
```
---
## 💻 Технические детали
### JavaScript архитектура
```javascript
class RecipientPicker {
state = {
recipients: [], // ID выбранных получателей
ccUsers: [], // ID в копии
departments: [], // ID отделов
searchQuery: '', // Поисковый запрос (To)
searchQueryCC: '', // Поисковый запрос (CC)
showCC: false, // Показать поле CC
showDropdown: false // Показать dropdown
}
// Рендер chips
renderUserChips(userIds, isCC) { ... }
// Рендер dropdown с поиском
renderUserDropdown(excludeIds, type) { ... }
// События: focus, input, blur
attachEvents() { ... }
}
```
### CSS структура
```
recipient-picker-email/ # Контейнер
├─ recipient-row/ # Строка (To:/CC:/etc)
│ ├─ recipient-label/ # Метка (80px)
│ └─ recipient-field/ # Поле (flex: 1)
│ └─ recipient-input-wrapper/ # Input + chips
│ ├─ recipient-chip/ # Chip пользователя
│ ├─ recipient-input/ # Text input
│ └─ recipient-dropdown/ # Autocomplete
│ └─ dropdown-item-user/
│ ├─ user-avatar-small/
│ └─ user-info/
```
---
## 📊 Сравнение: До vs После
| Критерий | До | После |
|----------|-----|--------|
| **Выбор пользователей** | Select dropdown | Autocomplete с чипами |
| **Видимость выбранных** | Скрыты в select | Chips всегда видны |
| **Поиск** | Нет | Живой поиск по имени |
| **Удаление** | Deselect в select | Клик на × в chip |
| **Мобильная адаптация** | Плохо | Адаптивный layout |
| **Визуальный контекст** | Нет | Аватары, должности |
| **Clutter** | Много текста | Компактно |
| **Скорость работы** | Медленно (скроллинг) | Быстро (поиск) |
---
## 🎯 UX метрики
### Количество кликов для добавления 3 получателей
**До:**
1. Клик "Настроить получателей"
2. Открыть select (клик)
3. Скроллинг к пользователю 1
4. Клик на пользователя 1
5. Скроллинг к пользователю 2
6. Клик на пользователя 2
7. Скроллинг к пользователю 3
8. Клик на пользователя 3
**Итого: 8 кликов + скроллинг**
**После:**
1. Ввод 2-3 символов имени
2. Клик на пользователя в dropdown
3. Повторить 2 раза
**Итого: 3 клика + минимальный ввод**
### Время выполнения задачи
- **До:** ~20-30 секунд (со скроллингом)
- **После:** ~5-10 секунд (с поиском)
---
## 📱 Адаптивность
### Десктоп (>768px)
```
Отделы: [chips] [dropdown] [checkbox]
Кому: [chips] _________________________________
```
### Мобильный (<768px)
```
Отделы:
[chips]
[dropdown]
[checkbox]
Кому:
[chips]
___________________
```
**CSS:**
```css
@media (max-width: 768px) {
.recipient-row {
flex-direction: column;
}
.recipient-label {
min-width: auto;
}
}
```
---
## 🌙 Темная тема
```css
@media (prefers-color-scheme: dark) {
.recipient-input-wrapper {
background: var(--bs-dark);
border-color: var(--bs-gray-700);
}
.recipient-dropdown {
background: var(--bs-dark);
}
.dropdown-item-user:hover {
background-color: var(--bs-gray-800);
}
}
```
---
## 🚀 Следующие улучшения
### Приоритет 1: Функциональность
- [ ] Недавние контакты (recent recipients)
- [ ] Частые получатели (frequent recipients)
- [ ] Группы пользователей
- [ ] Сохраненные шаблоны получателей
### Приоритет 2: UX
- [ ] Drag & drop для chips
- [ ] Keyboard navigation (↑↓ в dropdown, Enter для выбора)
- [ ] Bulk actions (выбрать всех в отделе)
- [ ] Предпросмотр: кто получит уведомление
### Приоритет 3: Производительность
- [ ] Виртуализация списка (для >1000 пользователей)
- [ ] Debounce для поиска (300ms)
- [ ] Кэширование результатов поиска
- [ ] Lazy loading для аватаров
---
## 📚 Ссылки и вдохновение
### Примеры из продуктов:
- **Gmail:** Chip-based To/CC/BCC с autocomplete
- **Outlook:** Token input для получателей
- **Slack:** Mentions с живым поиском
- **Telegram:** Поиск контактов при пересылке
- **Apple Mail:** Компактные chips с аватарами
### UI библиотеки:
- [React Select](https://react-select.com/) - Multi-select с chips
- [Tagify](https://github.com/yairEO/tagify) - Tag/Chip input
- [Choices.js](https://github.com/Choices-js/Choices) - Select enhancement
### UX паттерны:
- **Token Input Pattern** - Nielsen Norman Group
- **Autocomplete Best Practices** - Material Design
- **Progressive Disclosure** - не показывать CC сразу
---
## ✅ Итоги
### Достигнуто:
✅ Интуитивный интерфейс как в email-клиентах
✅ Chip-based UI для визуальной ясности
✅ Autocomplete с живым поиском
✅ Компактный и адаптивный дизайн
✅ Улучшена скорость работы в 3-4 раза
✅ Поддержка мобильных устройств
### Метрики:
- **Время создания заявления:** ↓ 60%
- **Количество кликов:** ↓ 70%
- **Размер модального окна:** +30% (для удобства)
- **Удовлетворенность UX:** ожидается ↑ значительно
---
**Готово к тестированию!** 🎉
# Requests Compose UX Guide
Статус: актуальный guide по compose-паттерну формы заявлений.
Этот документ заменяет старое описание recipient picker, в котором были смешаны пользовательские получатели, отделы и старый workflow создания заявлений. Сейчас normal UI формы должен соответствовать текущей backend-логике и общему frontend style guide.
## Базовый принцип
Форма создания заявления должна быть похожа на compose-интерфейс электронного письма:
- `Кому`
- `Копия`
- `Тема`
- основной текст
- вложение
- действия формы
Форма не должна выглядеть как техническая админская форма или как длинный список равнозначных полей.
## Актуальная адресация
В normal UI используются только пользовательские адресаты:
- `recipient_ids` для прямых получателей
- `cc_user_ids` для копии
В рамках текущего compose-flow не использовать:
- `department_ids`
- `sent_to_all_department`
Если отделы вернутся как отдельный сценарий, это должен быть отдельный UX-flow, а не скрытое расширение текущей compose-формы.
## Семантика ролей
`Кому` и `Копия` не равны.
Правила:
- прямой получатель участвует в маршрутизации решения
- пользователь в копии имеет доступ к просмотру, но не принимает решение
- фронтенд не должен размывать это различие визуально или логически
Следствие:
- `Копия` не должна выглядеть как второе поле “тоже получатели”
- UI не должен создавать впечатление, что все выбранные люди равноправно согласуют заявление
## Поведение формы
### Черновик
Черновик:
- можно сохранить неполным
- не требует полного набора обязательных полей
- не должен перегружать пользователя ошибками
### Отправка
Отправка:
- требует хотя бы одного прямого получателя
- требует обязательные поля, нужные для конкретного типа заявления
- должна использовать нормальный submit-flow, а не локальные role-based исключения
## Визуальный контракт
Compose-форма должна придерживаться следующих принципов:
- адресные строки сверху
- главное поле сообщения занимает визуальный центр формы
- служебные поля ниже и спокойнее по весу
- footer предсказуем и содержит основные действия: отмена, черновик, отправка
Для dropdown-полей:
- если нужен поиск или мультивыбор, использовать shared select-паттерн
- не собирать локальные стрелки, раскрытия и chips вручную, если это уже покрывается shared-компонентом
## Что больше неактуально
Больше не считаются актуальными идеи:
- смешивать отделы и пользователей в одном compose-flow
- показывать отправку “всем сотрудникам отдела” как часть обычной формы
- трактовать копию как почти тот же список согласующих
## Связанные документы
- `docs/guides/frontend/README.md`
- `docs/guides/frontend/04-PATTERNS.md`
- `docs/guides/REQUESTS_DETAIL_VIEW_GUIDE.md`

View File

@ -1,374 +1,109 @@
# Руководство: Requests Detail View
**Дата создания:** 26 декабря 2025 г.
**Версия:** 1.0
**Статус:** ✅ Готово к использованию
---
## 📖 Описание функции
**Requests Detail View** - это полнофункциональное модальное окно для просмотра деталей заявления с возможностью:
- 📋 Просмотра полной информации о заявлении
- 💬 Добавления и удаления комментариев
- ✅ Изменения статуса (для администраторов)
- 🎨 Интуитивного пользовательского интерфейса
---
## 🎯 Основные компоненты
### 1. Backend
#### RequestDetailView (views_front.py)
```python
class RequestDetailView(LoginRequiredMixin, TemplateView):
"""Детальный просмотр заявления с комментариями"""
template_name = "requests_app/request_detail.html"
def get_context_data(self, pk: int, **kwargs):
# Загружает заявление и комментарии из API
# Проверяет права доступа
# Форматирует данные для отображения
```
**URL:** `/requests/{id}/`
**Контекст:**
- `request_obj` - данные заявления
- `comments` - список комментариев
- `can_process` - может ли пользователь менять статус
- `error` - сообщение об ошибке (если есть)
#### API Endpoints
**GET /api/v1/requests/{id}/**
Получить полные данные заявления
```json
{
"id": 123,
"type": "vacation",
"status": "pending",
"title": "Отпуск на неделю",
"comment": "Хочу взять отпуск...",
"created_at": "2025-12-26T10:00:00Z",
"date_from": "2026-01-01",
"date_to": "2026-01-07"
}
```
**GET /api/v1/requests/{id}/comments/**
Получить список комментариев
```json
{
"count": 2,
"results": [
{
"id": 1,
"text": "Согласен",
"author": {"id": 1, "full_name": "Иван Петров"},
"created_at": "2025-12-26T11:00:00Z"
}
]
}
```
**POST /api/v1/requests/{id}/comments/**
Добавить комментарий
```json
{
"text": "Новый комментарий"
}
```
**DELETE /api/v1/requests/{id}/comments/{comment_id}/**
Удалить комментарий (только для автора)
**POST /api/v1/requests/{id}/approve/**
Одобрить заявление (требует права)
**POST /api/v1/requests/{id}/reject/**
Отклонить заявление (требует права)
**POST /api/v1/requests/{id}/cancel/**
Отменить заявление
### 2. Frontend
#### requestDetail.js (JavaScript модуль)
```javascript
// Инициализация на странице
const detailModal = initRequestDetailModal();
// Открытие модали
detailModal.open(requestId);
// Закрытие модали
detailModal.close();
```
**Класс RequestDetailModal:**
- `open(requestId)` - открыть модальное окно
- `loadData(requestId)` - загрузить данные через AJAX
- `handleCommentSubmit(e)` - добавить комментарий
- `handleDeleteComment(e)` - удалить комментарий
- `handleStatusChange(e)` - изменить статус
- `attachEventListeners()` - подключить обработчики событий
- `updateCommentsCount()` - обновить счетчик комментариев
- `initEmojiPicker()` - инициализировать emoji picker
### 3. Шаблоны
#### request_detail.html
Основной шаблон для отображения деталей заявления
- Информация о заявлении (тип, статус, даты)
- Список комментариев
- Форма добавления комментария
- Форма изменения статуса (для администраторов)
#### request_list_full.html
Главная страница списка заявлений
- Таблица заявлений
- Модальное окно для деталей
- Фильтры и поиск
- Пагинация
---
## 🚀 Использование
### Для пользователей
1. **Открыть страницу заявлений:** `/requests/`
2. **Кликнуть на заявление в таблице**
3. **В модали можно:**
- Просмотреть все детали
- Прочитать и добавить комментарии
- Изменить статус (если администратор)
4. **Закрыть модаль:** кнопка X или клик вне модали
### Для разработчиков
#### Интеграция в другую страницу
```html
<!-- Подключить JavaScript модуль -->
<script type="module">
import { initRequestDetailModal } from '/static/js/modules/requestDetail.js';
// Инициализировать модаль
const modal = initRequestDetailModal();
// Открыть при клике
document.getElementById('my-btn').addEventListener('click', () => {
modal.open(requestId);
});
</script>
<!-- Добавить HTML структуру модали -->
<div class="modal" id="requestDetailModal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Детали заявления</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="requestDetailContent">
<!-- Содержимое загружается здесь -->
</div>
</div>
</div>
</div>
```
# 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
<button onclick="detailModal.open(123)">Просмотреть</button>
```
## Решения по заявлению
**Результат:** Модальное окно с полной информацией о заявлении #123
Решение (`approve` / `reject`) в normal UI/API может принять только прямой получатель.
### Пример 2: Добавление комментария в модали
Не могут принимать решение:
**Форма автоматически обрабатывает:**
1. Валидацию текста (не пусто)
2. Отправку через API
3. Обновление списка комментариев
4. Обновление счетчика
- автор, если он не добавлен в `recipients`
- пользователь в копии
- посторонний пользователь
- `staff` / `superuser`, если они не являются прямыми получателями
### Пример 3: Изменение статуса
Дополнительные ограничения:
**Только для администраторов видна форма:**
```html
<form class="request-status-form">
<select name="status">
<option value="approved">✓ Одобрить</option>
<option value="rejected">✗ Отклонить</option>
<option value="cancelled">⊗ Отменить</option>
</select>
<button>Применить</button>
</form>
```
- решение доступно только для `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.