chore: checkpoint workspace changes
This commit is contained in:
parent
b170ba9f43
commit
5ab638892b
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
"Финальная заявка уже не может быть отменена."
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
|
||||
- [README.md](../README.md) - основная документация проекта
|
||||
- [docs/reports/](reports/) - отчеты и планы по проекту
|
||||
- [docs/guides/frontend/](guides/frontend/) - карта frontend UI-системы и guide по рефакторингу интерфейса
|
||||
|
||||
## 🔍 Поиск информации
|
||||
|
||||
|
||||
@ -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-*` токенов в одном блоке
|
||||
@ -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)
|
||||
|
||||
Если все чеклисты ✅ - оптимизация работает! 🚀
|
||||
@ -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`
|
||||
@ -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 устарел, его нужно либо переписать, либо удалить, а не оставлять рядом с актуальным
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user