From: You <you@example.com>

Date: Mon, 22 Sep 2025 10:30:00 +0300
Subject: [requests] Drawer «Рассмотреть», корректный чекбокс «Черновик», дружелюбные даты,
 robust-обработка ошибок reject, и глобальный бейдж непрочитанных чатов

- Список заявлений:
  - вместо россыпи кнопок добавлен аккуратный выезжающий блок действий (collapse drawer)
    по клику «Рассмотреть»; компактные нейтральные стили, мягкая анимация.
  - в модале «Изменить» добавлена галочка «Сохранить как черновик» с правильной
    семантикой: hidden `_save_as=submit` по умолчанию + checkbox `_save_as=draft`.
    Теперь снятие галочки действительно снимает draft.
  - улучшены стили и структура разметки.

- Даты:
  - на фронт-обёртке нормализуем datetime-поля (created_at/updated_at/decided_at)
    в человекочитаемый вид, чтобы не светить сырые ISO 8601.

- Обработка ошибок:
  - фикс падения при попытке отклонить уже одобренную заявку: ответ 400 у DRF
    может приходить списком ошибок; теперь сообщение извлекается безопасно.

- Чаты:
  - синхронизировали название ключа контекста с проектом (`chat_unread_total`);
  - бейдж «Чаты» обновляется глобальным событием `chats:unread-total`, даже если
    список чатов не открыт (fallback — суммирование счётчиков на странице).

Примечание: пути к шаблонам могут отличаться в вашем проекте — ниже даны
типовые варианты (`backend/requests_app/templates/...`). Если у вас другой
layout, внесите изменения в соответствующие файлы шаблонов.
This commit is contained in:
admin i 2025-09-22 13:01:20 +03:00
parent 5390e514c3
commit 52f2aed5fd
26 changed files with 1838 additions and 616 deletions

View File

@ -1,23 +1,22 @@
# backend\api\v1\permissions.py
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional, Tuple
from typing import Any, Dict, Iterable, Optional, Tuple, Type
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from django.db.models import Model
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
DjangoModelPermissions)
from rest_framework.request import Request
MANAGE_PERM = "manage_department"
CHANGE_HEAD_PERM = "change_department_head"
ASSIGN_ROLE_PERM = "assign_department_role"
def user_is_dept_head(user, dept: Department) -> bool:
def user_is_dept_head(user, dept: Department) -> bool:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
@ -309,43 +308,113 @@ class AdminOrDeptAllowed(BasePermission):
class IsSelfOrStaff(BasePermission):
"""Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью)."""
"""Доступ для staff/superuser или владельца объекта.
DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id")
DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee")
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
def has_permission(self, request, view) -> bool:
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = (
"employee_id",
"author_id",
"user_id",
"owner_id",
)
DEFAULT_OWNER_OBJ_ATTRS: Tuple[str, ...] = ("employee", "author", "user", "owner")
def has_permission(self, request: Request, view) -> bool:
"""Пускаем только аутентифицированных.
Args:
request (Request): DRF-запрос.
view: DRF ViewSet.
Returns:
bool: True если пользователь аутентифицирован.
"""
user = getattr(request, "user", None)
return bool(user and user.is_authenticated)
def has_object_permission(self, request, view, obj):
user = request.user
def has_object_permission(self, request: Request, view, obj) -> bool:
"""Разрешает доступ staff/superuser или владельцу объекта.
Логика владельца:
1) Совпадение *_id полей (employee_id/user_id/…)
2) Совпадение связанных owner-объектов (employee/user/…)
3) Фоллбек "self-object": obj.__class__ == user.__class__ и obj.pk == user.pk
Args:
request (Request): DRF-запрос.
view: DRF ViewSet.
obj: Проверяемый объект.
Returns:
bool: True если доступ разрешён.
Raises:
Ничего не выбрасывает.
"""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if request.method in SAFE_METHODS:
return True
# Админский доступ
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# Прочитаем конфиг владельческих полей с вью (если задан)
owner_id_attrs = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
owner_obj_attrs = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS)
uid = getattr(user, "id", None)
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
for attr in owner_id_attrs:
if hasattr(obj, attr) and getattr(obj, attr) == uid:
if getattr(obj, attr, None) == user.id:
return True
# 2) связанные owner-объекты
owner_obj_attrs: Iterable[str] = getattr(
view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS
)
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == uid:
if owner is not None and getattr(owner, "id", None) == user.id:
return True
# Fallback для вью «/users/{id}/»
lookup = getattr(view, "lookup_field", "pk")
return str(view.kwargs.get(lookup)) == str(uid)
# 3) self-object: редактирование собственной учётки (Employee ↔ Employee)
try:
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(
user, "pk", None
):
return True
except Exception:
# максимально безопасно: не пускаем, если что-то пошло не так
pass
return False
class AdminOrActionOrModelPerms(DjangoModelPermissions):
"""staff/superuser ИЛИ явные пермы экшена ИЛИ стандартные DjangoModelPermissions.
Логика:
1) staff/superuser → True
2) Если на вью заданы «явные» коды прав для текущего action (метод `_explicit_perm_codes`),
достаточно ЛЮБОГО из них → True/False
3) Иначе — делегируем в DjangoModelPermissions.has_permission():
- для SAFE_METHODS потребует `view_<model>`
- для небезопасных — коды из perms_map (add/change/delete)
"""
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": [],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}
@staticmethod
def _normalize_codes(value: Any) -> Optional[list[str]]:
"""Приводит конфигурацию кодов прав к списку строк.
@ -436,16 +505,18 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions):
# Иное — игнорируем
return None
def has_permission(self, request, view) -> bool:
"""Проверка прав на уровне запроса.
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
Логика:
- staff/superuser → True
- если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них
- иначе: для SAFE-методов требуем view_<model>, остальные — по DjangoModelPermissions
Args:
request (Request): DRF-запрос.
view: DRF View/ViewSet.
Returns:
bool: Разрешён ли доступ.
Raises:
PermissionDenied: не выбрасывается здесь; при False DRF отдаст 403.
"""
user = getattr(request, "user", None)
if not (user and user.is_authenticated):
@ -453,16 +524,14 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions):
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
explicit = self._explicit_perm_codes(view)
# 1) Явные пермы, привязанные к action (если у тебя есть такой механизм)
explicit: Optional[Iterable[str]] = getattr(
self, "_explicit_perm_codes", lambda _v: None
)(view)
if explicit:
return any(user.has_perm(code) for code in explicit)
if request.method in SAFE_METHODS:
model = self._model_from_view(view)
return user.has_perm(
f"{model._meta.app_label}.view_{model._meta.model_name}"
)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)

View File

@ -6,55 +6,35 @@ from typing import Any, Optional
from api.v1.permissions import AdminOrDeptAllowed
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from employees.constants import DeptPerm
class CommentsPermission(BasePermission):
"""Права доступа к экшену `comments`.
"""Доступ к экшену `comments` по *модельным* правам.
- GET/HEAD: staff или право `requests_app.view_requestcomment`.
- POST: staff или право `requests_app.add_requestcomment`.
- Владелец заявки без модельных прав доступа не имеет.
Raises:
Ничего не выбрасывает: при отказе возвращает False → DRF вернёт 403.
- GET/HEAD: `requests_app.view_requestcomment` ИЛИ staff.
- POST: `requests_app.add_requestcomment` ИЛИ staff.
- Владелец без прав — НЕТ.
"""
message = "Недостаточно прав для доступа к комментариям."
def _required_perm(self, request: Request) -> Optional[str]:
if request.method in {"GET", "HEAD"}:
return "requests_app.view_requestcomment"
if request.method == "POST":
return "requests_app.add_requestcomment"
return None
def has_permission(self, request: Request, view: Any) -> bool:
"""Проверка уровня запроса.
Args:
request (Request): Текущий запрос.
view (Any): Текущий вьюсет.
Returns:
bool: True, если пользователь аутентифицирован.
"""
return bool(getattr(request.user, "is_authenticated", False))
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
"""Объектная проверка на уровне конкретной заявки.
Args:
request (Request): Текущий запрос.
view (Any): Текущий вьюсет.
obj (Any): Экземпляр заявки (EmployeeRequest).
Returns:
bool: Разрешение/запрет.
"""
user = request.user
if getattr(user, "is_staff", False):
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if request.method in {"GET", "HEAD"}:
return user.has_perm("requests_app.view_requestcomment")
if request.method == "POST":
return user.has_perm("requests_app.add_requestcomment")
return False
code = self._required_perm(request)
return bool(code and user.has_perm(code))
class NotFinalOrStaff(BasePermission):
@ -92,33 +72,144 @@ class NotFinalOrStaff(BasePermission):
class DeptViewRequest(AdminOrDeptAllowed):
"""Чтение заявки: staff ИЛИ право отдела 'view_request'."""
"""Просмотр заявки по департаментным правам.
required_code = "view_request"
Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет
право отдела ``view_request`` именно для отдела данной заявки.
Применяется только к detail-экшену `retrieve`.
Attributes:
allow_safe_without_code (bool): Запрещает фоллбек пропуска SAFE-методов
без явного кода права (защита от «случайных 200»).
required_code_map (dict[str, str]): Ограничиваем право только на `retrieve`.
required_code (Optional[str]): Явно отключаем общий fallback.
"""
# SAFE-методы не должны проходить «на шару»
allow_safe_without_code: bool = False
# Явно ограничиваем сферу действия права
required_code_map: dict[str, str] = {
"retrieve": "view_request",
}
# Не даём базовому классу брать «любой экшен = view_request»
required_code: Optional[str] = None
def get_required_code(self, request: Request, view: Any) -> Optional[str]:
"""Возвращает код департаментного права только для поддерживаемых action.
Args:
request (Request): DRF-запрос.
view (Any): Вью/вьюсет; важен `view.action`.
Returns:
Optional[str]: 'view_request' для `retrieve`, иначе None (запрет).
"""
return self.required_code_map.get(getattr(view, "action", None))
class DeptCanProcess(AdminOrDeptAllowed):
"""Обработка заявок: staff ИЛИ право отдела 'can_process_requests'."""
"""Пермишен для обработки заявок по департаментным правам.
required_code = "can_process_requests"
Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет
право отдела ``can_process_requests`` для отдела заявки.
Применяется на детальных экшенах workflow:
- approve
- reject
Атрибуты:
allow_safe_without_code (bool): Запретить «фоллбек» на SAFE-методы
без явного кода права (по умолчанию в базовом классе мог быть True).
required_code_map (dict[str, str]): Маппинг action → код права.
required_code (str): Резервный код права, если action не сматчился.
Raises:
Ничего напрямую не выбрасывает — при отсутствии прав вернёт False,
DRF ответит 403 Forbidden.
"""
# SAFE-методы пусть тоже требуют явного кода (на всякий случай)
allow_safe_without_code: bool = False
# Явно описываем нужные action-ы workflow
required_code_map: dict[str, str] = {
"approve": "can_process_requests",
"reject": "can_process_requests",
}
# Резервный вариант — если вдруг action неизвестен,
# базовый класс возьмёт этот код.
required_code: str = "can_process_requests"
class DeptChangeRequest(AdminOrDeptAllowed):
"""Правка заявок: staff ИЛИ право отдела 'change_request'."""
"""Правка заявок по департаментным правам.
required_code = "change_request"
Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет
право отдела ``change_request`` для отдела конкретной заявки.
Применяется ТОЛЬКО к экшенам редактирования:
- update (PUT)
- partial_update (PATCH)
- destroy (DELETE)
ВНИМАНИЕ: для workflow-экшенов (approve/reject) используйте DeptCanProcess.
Attributes:
allow_safe_without_code (bool): запрещает фоллбек пропуска SAFE-методов
без явного кода права (защита от случайных «200»).
"""
# Не пропускаем SAFE-методы без явного кода (страховка)
allow_safe_without_code: bool = False
def get_required_code(self, request: Request, view: Any) -> Optional[str]:
"""Возвращает код департаментного права для экшенов редактирования.
Args:
request (Request): DRF-запрос.
view (Any): Вью/вьюсет (важен view.action).
Returns:
Optional[str]: 'change_request' для update/partial_update/destroy, иначе None.
Notes:
Возврат None означает «кода нет» → базовый класс вернёт False (403),
так мы исключаем approve/reject и прочие экшены из этого пермишена.
"""
if getattr(view, "action", None) in {"update", "partial_update", "destroy"}:
return "change_request"
return None
class DeptComments(AdminOrDeptAllowed):
"""Комментарии: staff ИЛИ право отдела
- GET/HEAD: 'view_requestcomment'
- POST: 'add_requestcomment'
"""Комментарии: staff ИЛИ департаментное право.
- GET/HEAD → 'view_requestcomment'
- POST → 'add_requestcomment'
"""
def _required_code(self, view: Any, request: Request) -> Optional[str]:
# Метод-чувствительная логика: GET/HEAD → view, POST → add
# Чтобы SAFE-методы не проходили «без кода» при ошибке конфигурации:
allow_safe_without_code = False
def get_required_code(self, request: Request, view: Any) -> Optional[str]:
"""Возвращает код департаментного права для экшена `comments`.
Args:
request (Request): Текущий запрос DRF.
view (Any): Вью/вьюсет (не используется).
Returns:
Optional[str]: Код права для текущего HTTP-метода или None для неподдерживаемых методов.
Raises:
None: Исключения не выбрасываются — при отсутствии кода доступ будет запрещён базовым классом.
"""
if request.method in {"GET", "HEAD"}:
return "view_requestcomment"
return DeptPerm.VIEW_REQUESTCOMMENT
if request.method == "POST":
return "add_requestcomment"
return None # другие методы не поддерживаем
return DeptPerm.ADD_REQUESTCOMMENT
return None

View File

@ -3,15 +3,17 @@ from __future__ import annotations
from typing import Any, Mapping
from django.contrib.auth import get_user_model
from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request, RequestComment
from rest_framework import serializers
from ..employees.serializers import EmployeeBriefSerializer
from .validators import (RequestApproverNotEmployeeValidator,
RequestDatesByTypeValidator)
User = get_user_model()
class RequestReadSerializer(serializers.ModelSerializer):
"""Сериализатор чтения заявки.
@ -55,7 +57,6 @@ class RequestReadSerializer(serializers.ModelSerializer):
)
class RequestWriteSerializer(serializers.ModelSerializer):
"""Сериализатор записи заявки.
@ -74,7 +75,7 @@ class RequestWriteSerializer(serializers.ModelSerializer):
queryset=User.objects.all(), required=False, allow_null=True
)
status = serializers.ChoiceField(
choices=Request.STATUS_CHOICES, required=False, allow_null=True
choices=RequestStatus, required=False, allow_null=True
)
approver = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), required=False, allow_null=True
@ -96,6 +97,10 @@ class RequestWriteSerializer(serializers.ModelSerializer):
"attachment",
)
read_only_fields = ("id",)
validators = [
RequestDatesByTypeValidator(),
RequestApproverNotEmployeeValidator(),
]
def _is_power(self) -> bool:
"""Определяет, имеет ли пользователь расширенные права на создание/изменение.
@ -141,13 +146,34 @@ class RequestWriteSerializer(serializers.ModelSerializer):
Returns:
Request: Созданная заявка.
Raises:
serializers.ValidationError: Если не удалось определить автора.
"""
request = self.context.get("request")
user = getattr(request, "user", None)
if not user or getattr(user, "is_anonymous", False):
raise serializers.ValidationError(
{"detail": "Требуется аутентификация для создания заявки."}
)
if not self._is_power():
validated_data["employee"] = self.context["request"].user
# обычный пользователь: запрещённые поля убираем/переписываем
validated_data.pop("status", None)
validated_data.pop("approver", None)
validated_data["employee"] = user
else:
# staff/менеджер: если employee не указан — ставим текущего
validated_data.setdefault("employee", user)
if not validated_data.get("employee"):
raise serializers.ValidationError(
{"employee": "Автор должен быть установлен сервером."}
)
return super().create(validated_data)
class RequestCommentSerializer(serializers.ModelSerializer):
"""Сериализатор комментариев к заявке."""

View File

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any, Mapping, Optional
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from requests_app.enums import RequestType # VACATION/SICK_LEAVE/TRANSFER/DISMISSAL/OTHER
class RequestDatesByTypeValidator:
"""Мягкая проверка дат по типу заявки.
Правила:
- Проверяем ТОЛЬКО если в payload присутствуют date_from/date_to ИЛИ меняется type.
- Для VACATION/SICK_LEAVE: если хотя бы одна дата передана — требуются обе; date_to >= date_from.
- Для TRANSFER/DISMISSAL: если даты трогают — обязателен date_from.
- Для OTHER: дат можно не указывать.
"""
requires_context = True
def __call__(self, attrs: Mapping[str, Any], serializer) -> None:
instance = getattr(serializer, "instance", None)
# Определяем «эффективные» значения с учётом instance
type_ = attrs.get("type", getattr(instance, "type", None))
date_from = attrs.get("date_from", getattr(instance, "date_from", None))
date_to = attrs.get("date_to", getattr(instance, "date_to", None))
# Меняем ли мы даты/тип в этом запросе?
touches_dates = any(k in attrs for k in ("date_from", "date_to"))
touches_type = "type" in attrs
# Если ни тип, ни даты не трогают — ничего не проверяем
if not (touches_dates or touches_type):
return
if type_ in (RequestType.VACATION, RequestType.SICK_LEAVE):
# Если пользователь прислал хотя бы одно из полей даты — должны быть обе
if touches_dates and (not date_from or not date_to):
raise ValidationError(
{"date_from": _("Требуются обе даты."),
"date_to": _("Требуются обе даты.")}
)
if date_from and date_to and date_to < date_from:
raise ValidationError({"date_to": _("Дата окончания раньше даты начала.")})
elif type_ in (RequestType.TRANSFER, RequestType.DISMISSAL):
if touches_dates and not date_from:
raise ValidationError({"date_from": _("Требуется дата начала.")})
# OTHER — ограничений нет
class RequestApproverNotEmployeeValidator:
"""Запрещает совпадение согласующего и автора.
Учитывает частичное обновление: берёт значения из attrs поверх instance.
"""
requires_context = True
def __call__(self, attrs: Mapping[str, Any], serializer) -> None:
instance = getattr(serializer, "instance", None)
def eff(name: str) -> Any:
if name in attrs:
return attrs[name]
return getattr(instance, name, None) if instance is not None else None
employee = eff("employee") or getattr(serializer.context.get("request"), "user", None)
approver = eff("approver")
if employee and approver and employee == approver:
raise ValidationError({"approver": _("Согласующий не может совпадать с автором.")})

View File

@ -7,25 +7,38 @@ from django.shortcuts import get_object_or_404 # раскомментируйт
from employees.models import EmployeeDepartment
from requests_app.models import Request as EmployeeRequest
from requests_app.models import RequestComment
from requests_app.enums import RequestStatus
# from django.shortcuts import get_object_or_404 # <- не используется
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.exceptions import (
NotAuthenticated,
PermissionDenied,
ValidationError,
)
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
IsAuthenticated)
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
from rest_framework.request import Request as DRFRequest
from rest_framework.response import Response
from rest_framework.serializers import \
BaseSerializer # <- для типизации get_serializer_class
from rest_framework.serializers import (
BaseSerializer,
) # <- для типизации get_serializer_class
from ..permissions import (AdminOrActionOrModelPerms, AdminOrDeptAllowed,
IsSelfOrStaff)
from .permissions import (CommentsPermission, DeptCanProcess,
DeptChangeRequest, DeptComments, DeptViewRequest,
NotFinalOrStaff)
from .serializers import (RequestCommentSerializer, RequestReadSerializer,
RequestWriteSerializer)
from ..permissions import AdminOrActionOrModelPerms, AdminOrDeptAllowed, IsSelfOrStaff
from .permissions import (
CommentsPermission,
DeptCanProcess,
DeptChangeRequest,
DeptComments,
DeptViewRequest,
NotFinalOrStaff,
)
from .serializers import (
RequestCommentSerializer,
RequestReadSerializer,
RequestWriteSerializer,
)
class RequestViewSet(viewsets.ModelViewSet):
@ -50,19 +63,14 @@ class RequestViewSet(viewsets.ModelViewSet):
parser_classes = (MultiPartParser, FormParser, JSONParser)
pagination_class = None
# Декларативная карта прав. Поддерживаются:
# - строка ("approve": "app.perm")
# - список (ИЛИ прав)
# - карта по методам для action ("comments": {"GET": "...", "POST": "..."}).
required_perms_by_action = {
"comments": {
"GET": "requests_app.view_requestcomment",
"HEAD": "requests_app.view_requestcomment", # на всякий случай
"HEAD": "requests_app.view_requestcomment",
"POST": "requests_app.add_requestcomment",
},
"reject": ["requests_app.can_process_requests", "requests_app.change_request"],
"approve": ["requests_app.can_process_requests", "requests_app.change_request"],
# cancel — проверяется владением/админством, отдельный код не нужен
}
def get_permissions(self) -> list[BasePermission]:
@ -74,14 +82,19 @@ class RequestViewSet(viewsets.ModelViewSet):
- остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права.
"""
if self.action == "comments":
return [CommentsPermission()]
return [(CommentsPermission | DeptComments)()]
if self.action in {"approve", "reject"}:
return [IsAuthenticated(), (AdminOrActionOrModelPerms)()]
return [(AdminOrActionOrModelPerms | DeptCanProcess)()]
if self.action == "cancel":
return [IsAuthenticated(), IsSelfOrStaff()]
return [IsSelfOrStaff()]
if self.action == "retrieve":
return [(IsSelfOrStaff | DeptViewRequest | AdminOrActionOrModelPerms)()]
if self.action == "destroy":
return [IsAuthenticated(), IsSelfOrStaff(), NotFinalOrStaff()]
return [(IsSelfOrStaff | AdminOrActionOrModelPerms)()]
return [
(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(),
NotFinalOrStaff(),
]
return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)()]
def get_serializer_class(self) -> type[BaseSerializer]:
"""Возвращает сериализатор в зависимости от action."""
@ -109,16 +122,20 @@ class RequestViewSet(viewsets.ModelViewSet):
params = self.request.query_params
mine_raw = (params.get("mine") or "").lower()
want_mine = (params.get("view") == "mine") or (mine_raw in {"1", "true", "yes", "on"})
want_mine = (params.get("view") == "mine") or (
mine_raw in {"1", "true", "yes", "on"}
)
if user.is_staff or self._can_view_all(user):
if want_mine:
qs = qs.filter(employee_id=user.id)
else:
# отделы, где у пользователя есть dept-право 'view_request'
dept_ids_qs = (
EmployeeDepartment.objects
.filter(employee_id=user.id, is_active=True, role__scoped_permissions__code="view_request")
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="view_request",
)
.values_list("department_id", flat=True)
.distinct()
)
@ -136,27 +153,35 @@ class RequestViewSet(viewsets.ModelViewSet):
# --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() ---
def get_object(self):
"""Возвращает объект для detail-экшенов.
def get_object(self) -> EmployeeRequest:
"""Возвращает объект заявки для detail-экшенов.
Для 'approve', 'reject', 'comments', 'cancel' берём объект из полного
набора записей по первичному ключу (в обход get_queryset()), затем
вызываем check_object_permissions() — чтобы объектные пермишены отработали.
Для остальных действий — стандартное поведение базового класса.
Для экшенов {'approve', 'reject', 'comments', 'cancel', 'retrieve'}:
- достаём объект **в обход** урезанного get_queryset() по PK,
- прогоняем через object-level пермишены (check_object_permissions),
чтобы при отсутствии прав получить **403 Forbidden**, а не 404.
Returns:
EmployeeRequest: Объект заявки.
Raises:
Http404: Если объект не найден.
PermissionDenied: Если объектные права не пройдены.
Http404: Если объект с таким PK не существует вообще.
PermissionDenied: Если object-level пермишены не пройдены.
"""
if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel"}:
if getattr(self, "action", None) in {
"approve",
"reject",
"comments",
"cancel",
"retrieve",
}:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_val = self.kwargs[lookup_url_kwarg]
obj = get_object_or_404(EmployeeRequest, **{self.lookup_field: lookup_val})
lookup_value = self.kwargs[lookup_url_kwarg]
base = EmployeeRequest.objects.select_related("employee", "approver", "department")
obj = get_object_or_404(base, **{self.lookup_field: lookup_value})
self.check_object_permissions(self.request, obj)
return obj
return super().get_object()
# ---------- CRUD ----------
@ -164,8 +189,14 @@ class RequestViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer: RequestWriteSerializer) -> None:
"""Создание: для не-админа принудительно ставим себя как автора."""
user = self.request.user
if not user or not user.is_authenticated:
raise NotAuthenticated("Authentication required")
is_power = user.is_staff or user.has_perm("requests_app.add_request")
extra = {"employee": user} if not is_power else {}
save_as = (self.request.query_params.get("save_as") or "").strip().lower()
if save_as == "draft":
extra["status"] = RequestStatus.DRAFT # ✅ обычному пользователю поле в payload всё равно бы «очистили»
serializer.save(**extra)
def perform_update(self, serializer: RequestWriteSerializer) -> None:
@ -184,7 +215,16 @@ class RequestViewSet(viewsets.ModelViewSet):
user.is_staff or user.has_perm("requests_app.change_request")
):
raise PermissionDenied("Нельзя редактировать чужую заявку.")
serializer.save()
extra: dict[str, Any] = {}
save_as = (self.request.query_params.get("save_as") or "").strip().lower()
if save_as == "draft":
extra["status"] = RequestStatus.DRAFT
elif save_as == "submit" and obj.status == RequestStatus.DRAFT:
# ⬅️ явная подача «на рассмотрение» из черновика
extra["status"] = RequestStatus.PENDING
serializer.save(**extra)
# ---------- Комментарии ----------
@ -195,7 +235,7 @@ class RequestViewSet(viewsets.ModelViewSet):
GET: список комментариев (staff, владелец или право view_requestcomment).
POST: создание {"text": "..."} (staff, владелец или право add_requestcomment).
"""
req_obj = self.get_object() # проходит через check_object_permissions()
req_obj = self.get_object()
if request.method in {"GET", "HEAD"}:
qs = RequestComment.objects.filter(request=req_obj).select_related("author")
@ -235,7 +275,7 @@ class RequestViewSet(viewsets.ModelViewSet):
Raises:
ValidationError: Если заявка уже финальная.
"""
obj = self.get_object() # права проверит AdminOrActionOrModelPerms
obj = self.get_object()
if getattr(obj, "is_final", False):
raise ValidationError("Заявка уже в финальном статусе.")
obj.reject(by_user=request.user)
@ -250,7 +290,7 @@ class RequestViewSet(viewsets.ModelViewSet):
Raises:
PermissionDenied: Если заявка финальная (для не admin).
"""
obj = self.get_object() # владение/admin проверит IsSelfOrStaff
obj = self.get_object()
if getattr(obj, "is_final", False) and not request.user.is_staff:
raise PermissionDenied("Финальная заявка уже не может быть отменена.")
obj.cancel()

View File

@ -2,11 +2,11 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.db.models import Q
from .models import Chat, Message, ChatReadState
from .models import Chat, ChatReadState, Message
def serialize_message(m: Message) -> dict:

View File

@ -1,6 +1,6 @@
# backend\communications\routing.py
from django.urls import path
from django.urls import re_path
from .consumers import ChatConsumer, ChatListConsumer
websocket_urlpatterns = [

View File

@ -34,6 +34,8 @@ class DeptPerm:
MANAGE_CALENDAR = "manage_department_events"
CREATE_POST = "publish_department_post"
MANAGE_FEED = "manage_department_feed"
VIEW_REQUESTCOMMENT = "view_requestcomment"
ADD_REQUESTCOMMENT = "add_requestcomment"
CHOICES = (
(MANAGE, "Управлять отделом"),
@ -42,4 +44,6 @@ class DeptPerm:
(MANAGE_CALENDAR, "Управлять календарём отдела"),
(CREATE_POST, "Публиковать новости на странице отдела"),
(MANAGE_FEED, "Редактировать публикации отдела"),
)
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
)

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-18 12:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0025_alter_departmentpermission_code'),
]
operations = [
migrations.AlterField(
model_name='departmentpermission',
name='code',
field=models.CharField(choices=[('manage_department', 'Управлять отделом'), ('change_department_head', 'Назначать руководителя'), ('assign_department_role', 'Назначать роли участникам'), ('manage_department_events', 'Управлять календарём отдела'), ('publish_department_post', 'Публиковать новости на странице отдела'), ('manage_department_feed', 'Редактировать публикации отдела'), ('view_requestcomment', 'Просмотр комментариев по заявлениям'), ('add_requestcomment', 'Добавление коментариев по заявлениям')], max_length=64, unique=True),
),
]

View File

@ -7,7 +7,7 @@ urlpatterns = [
path("admin/", admin.site.urls),
path("auth/", include("employees.urls_front_auth", namespace="auth_front")),
path("documents/", include("documents.urls", namespace="documents")),
path("requests/", include("requests_app.urls", namespace="requests_app")),
path("requests/", include("requests_app.urls_front", namespace="requests")),
path("employees/", include("employees.urls_front", namespace="employees")),
path("communications/", include("communications.urls", namespace="communications")),
path("search/", include("search.urls", namespace="search")),

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from django.db.models import TextChoices
class RequestType(TextChoices):
"""Перечень типов заявлений (значение хранится в БД)."""
VACATION = "vacation", "Отпуск"
SICK_LEAVE = "sick_leave", "Больничный"
TRANSFER = "transfer", "Перевод"
DISMISSAL = "dismissal", "Увольнение"
OTHER = "other", "Другое"
class RequestStatus(TextChoices):
"""Статусы жизненного цикла заявления (значение хранится в БД)."""
DRAFT = "draft", "Черновик"
PENDING = "pending", "На рассмотрении"
APPROVED = "approved", "Одобрено"
REJECTED = "rejected", "Отклонено"
CANCELLED = "cancelled", "Отменено"
FINAL_STATUS = {
RequestStatus.APPROVED,
RequestStatus.REJECTED,
RequestStatus.CANCELLED,
}

View File

@ -9,7 +9,12 @@ from django.db.models import F, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from .constants import MAX_ATTACHMENT_SIZE, SAFE_EXTENSIONS, SAFE_MIME_TYPES
from .constants import (
MAX_ATTACHMENT_SIZE,
SAFE_EXTENSIONS,
SAFE_MIME_TYPES,
)
from .enums import FINAL_STATUS, RequestStatus, RequestType
logger = logging.getLogger(__name__)
@ -57,36 +62,6 @@ def attachment_mime_validator(value):
class Request(models.Model):
"""Заявление сотрудника: тип, даты, статус, согласование и вложение."""
TYPE_VACATION = "vacation"
TYPE_SICK = "sick_leave"
TYPE_TRANSFER = "transfer"
TYPE_DISMISSAL = "dismissal"
TYPE_OTHER = "other"
TYPE_CHOICES = [
(TYPE_VACATION, _("Отпуск")),
(TYPE_SICK, _("Больничный")),
(TYPE_TRANSFER, _("Перевод")),
(TYPE_DISMISSAL, _("Увольнение")),
(TYPE_OTHER, _("Другое")),
]
STATUS_DRAFT = "draft"
STATUS_PENDING = "pending"
STATUS_APPROVED = "approved"
STATUS_REJECTED = "rejected"
STATUS_CANCELLED = "cancelled"
STATUS_CHOICES = [
(STATUS_DRAFT, _("Черновик")),
(STATUS_PENDING, _("На рассмотрении")),
(STATUS_APPROVED, _("Одобрено")),
(STATUS_REJECTED, _("Отклонено")),
(STATUS_CANCELLED, _("Отменено")),
]
FINAL_STATUS_SET = {STATUS_APPROVED, STATUS_REJECTED, STATUS_CANCELLED}
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
@ -109,18 +84,18 @@ class Request(models.Model):
verbose_name=_("Отдел"),
)
type = models.CharField(_("Тип заявления"), choices=TYPE_CHOICES, max_length=32)
type = models.CharField(
_("Тип заявления"), choices=RequestType.choices, max_length=32
)
title = models.CharField(_("Тема"), max_length=200, blank=True)
date_from = models.DateField(_("Дата начала"), null=True, blank=True)
date_to = models.DateField(_("Дата окончания"), null=True, blank=True)
comment = models.TextField(_("Комментарий"), blank=True)
status = models.CharField(
_("Статус"),
choices=STATUS_CHOICES,
max_length=16,
default=STATUS_PENDING,
)
status = models.CharField(_("Статус"),
choices=RequestStatus.choices,
max_length=16,
default=RequestStatus.PENDING)
attachment = models.FileField(
_("Вложение"),
@ -166,24 +141,22 @@ class Request(models.Model):
),
models.CheckConstraint(
name="request_approver_required_on_decision",
condition=Q(status__in=["approved", "rejected"], approver__isnull=False)
| Q(
status__in=["draft", "pending", "cancelled"], approver__isnull=True
),
condition=Q(status__in=[RequestStatus.APPROVED, RequestStatus.REJECTED],
approver__isnull=False)
| Q(status__in=[RequestStatus.DRAFT, RequestStatus.PENDING, RequestStatus.CANCELLED],
approver__isnull=True),
),
]
def clean(self):
if self.type in {self.TYPE_VACATION, self.TYPE_SICK} and not (
if self.type in {RequestType.VACATION, RequestType.SICK_LEAVE} and not (
self.date_from and self.date_to
):
raise ValidationError(
_("Для выбранного типа укажите даты начала и окончания.")
)
if (
self.type in {self.TYPE_TRANSFER, self.TYPE_DISMISSAL}
and not self.date_from
):
if self.type in {RequestType.TRANSFER, RequestType.DISMISSAL} and not self.date_from:
raise ValidationError(_("Для перевода/увольнения укажите дату начала."))
if self.approver_id and self.approver_id == self.employee_id:
@ -196,40 +169,36 @@ class Request(models.Model):
@property
def is_final(self):
return self.status in self.FINAL_STATUS_SET
return self.status in FINAL_STATUS
def approve(self, by_user):
self.status = self.STATUS_APPROVED
self.status = RequestStatus.APPROVED
self.approver = by_user
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def reject(self, by_user):
self.status = self.STATUS_REJECTED
self.status = RequestStatus.REJECTED
self.approver = by_user
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def cancel(self):
self.status = self.STATUS_CANCELLED
self.status = RequestStatus.CANCELLED
if self.approver_id is not None:
self.approver = None
self.decided_at = timezone.now()
self.save(update_fields=["status", "approver", "decided_at", "updated_at"])
def save(self, *args, **kwargs):
def save(self, *args, **kwargs) -> None:
"""Синхронизация decided_at/approver с текущим статусом.
Raises:
ValueError: Если нарушена согласованность полей (редко).
"""
Страховка на случай изменения статуса напрямую:
- финальные статусы → ставим decided_at, если пусто;
- draft/pending/cancelled → чистим approver (в синхроне с БД-constraint).
"""
if self.status in self.FINAL_STATUS_SET and not self.decided_at:
if self.status in FINAL_STATUS and not self.decided_at:
self.decided_at = timezone.now()
if (
self.status
in {self.STATUS_DRAFT, self.STATUS_PENDING, self.STATUS_CANCELLED}
and self.approver_id
):
if self.status in {RequestStatus.DRAFT, RequestStatus.PENDING, RequestStatus.CANCELLED} and self.approver_id:
self.approver = None
super().save(*args, **kwargs)

View File

@ -0,0 +1,9 @@
# urls.py
from django.urls import path
from .views_front import RequestsView
app_name = "requests"
urlpatterns = [
path("", RequestsView.as_view(), name="request_list"),
]

View File

@ -1,350 +1,546 @@
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Tuple
from typing import Any, Mapping, Optional
from api.client import get_api_client
from api.decorators import require_api_auth
from django.contrib import messages
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
)
from django.shortcuts import redirect, render
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from api.client import get_api_client # ваш клиент
from api.decorators import require_api_auth # проверка наличия токена в сессии
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from django.views.generic import TemplateView
from django.utils.dateparse import parse_datetime
from django.utils.timezone import localtime
API_PATH = "v1/requests/" # относительный путь внутри API (base_url настраивается в settings)
def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]:
"""Извлекает ok/json/status из ответа API-обёртки.
# ===================== Списки =====================
@require_api_auth
def my_requests(request: HttpRequest) -> HttpResponse:
"""Список «моих» заявок через GET /api/v1/requests/?view=mine.
Args:
resp (Any): Ответ клиентской обёртки (ожидаются .ok, .json/.json(), .status).
Returns:
HttpResponse: HTML со списком заявок.
tuple[bool, dict[str, Any], int]: ok, payload, status.
Raises:
RuntimeError: В случае неожиданных ошибок клиента API (маловероятно).
AttributeError: Если в ответе нет ожидаемых атрибутов.
"""
api = get_api_client(request)
params = {"view": "mine", **request.GET.dict()}
resp = api.get(API_PATH, params=params)
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.ok:
return render(request, "requests_app/my_requests.html", {"items": resp.json or []})
if resp.status == 403:
return HttpResponseForbidden("Нет прав на просмотр ваших заявок.")
messages.error(request, f"Ошибка API: {resp.status}")
return render(request, "requests_app/my_requests.html", {"items": [], "error": resp.text})
@require_api_auth
def all_requests(request: HttpRequest) -> HttpResponse:
"""Список всех заявок через GET /api/v1/requests/.
Query-параметры проксируются как есть (?type=...&status=...).
Returns:
HttpResponse: HTML со списком заявок.
Raises:
RuntimeError: В случае неожиданных ошибок клиента API.
"""
api = get_api_client(request)
resp = api.get(API_PATH, params=request.GET.dict())
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.ok:
return render(request, "requests_app/all_requests.html", {"items": resp.json or []})
if resp.status == 403:
return HttpResponseForbidden("Недостаточно прав для просмотра заявок.")
messages.error(request, f"Ошибка API: {resp.status}")
return render(request, "requests_app/all_requests.html", {"items": [], "error": resp.text})
# ===================== Создание/детали =====================
@require_api_auth
def request_create(request: HttpRequest) -> HttpResponse:
"""Создание заявки через POST /api/v1/requests/.
GET: вывести форму.
POST: отправить поля формы в API. Для обычного пользователя API игнорирует `employee/status`
и выставляет автора/статус автоматически.
Returns:
HttpResponse: Редирект на детальную страницу при успехе или HTML формы с ошибками.
Raises:
ValueError: Если передан неподдерживаемый метод.
"""
if request.method == "GET":
return render(request, "requests_app/request_form.html")
if request.method != "POST":
return HttpResponseBadRequest("Метод не поддерживается.")
api = get_api_client(request)
# Соберите безопасный набор полей (минимум, совпадающий с API write-сериализатором)
payload: dict[str, Any] = {
"type": request.POST.get("type"),
"title": request.POST.get("title") or "",
"date_from": request.POST.get("date_from") or None,
"date_to": request.POST.get("date_to") or None,
"comment": request.POST.get("comment") or "",
}
# Если есть файл — используем form-data, иначе JSON.
if upload := request.FILES.get("attachment"):
files = {"attachment": (upload.name, upload.file, upload.content_type)}
resp = api.post(API_PATH, data=payload, files=files)
ok: bool = getattr(resp, "ok")
status: int = getattr(resp, "status")
data_attr = getattr(resp, "json", None)
data: Dict[str, Any]
if callable(data_attr):
data = data_attr() or {}
else:
resp = api.post(API_PATH, json=payload)
data = data_attr or {}
return ok, data, status
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.status == 201 and isinstance(resp.json, dict):
messages.success(request, "Заявка создана.")
return redirect("requests_app:request_detail", pk=resp.json["id"])
def _err_msg(payload: Any, fallback: str = "") -> str:
# dict: detail / non_field_errors / первое поле со строкой
if isinstance(payload, dict):
if isinstance(payload.get("detail"), (str, list)):
d = payload["detail"]
return d if isinstance(d, str) else "; ".join(map(str, d))
if isinstance(payload.get("non_field_errors"), list):
return "; ".join(map(str, payload["non_field_errors"]))
# первая строка из значений полей
for v in payload.values():
if isinstance(v, list) and v and isinstance(v[0], str):
return "; ".join(map(str, v))
if isinstance(v, str):
return v
# list: склеить
if isinstance(payload, list):
return "; ".join(map(str, payload))
# str или что-то иное
return str(payload or fallback)
if resp.status in (400, 403):
# Пытаемся показать ошибки валидатора
form_errors: Mapping[str, Any] = resp.json if isinstance(resp.json, dict) else {"__all__": resp.text}
return render(
request,
"requests_app/request_form.html",
{"form_errors": form_errors, "form_data": payload},
def _fmt_dt(val, fmt: str = "%d.%m.%Y %H:%M") -> str | None:
"""Превращает ISO-строку/дату в локализованную строку.
Если не получилось — возвращает исходное значение."""
if not val:
return None
# если уже datetime — ок, если строка — парсим
dt = parse_datetime(val) if isinstance(val, str) else val
if dt is None:
return val # на всякий случай оставим как есть
try:
return localtime(dt).strftime(fmt) # уважим таймзону пользователя
except Exception:
return val
def _format_datetime_fields(
items: List[Any],
fields: tuple[str, ...] = ("created_at", "updated_at", "decided_at"),
) -> List[Any]:
out: List[Any] = []
for it in items:
if isinstance(it, Mapping):
d = dict(it) # копия, не мутируем исходник
for f in fields:
if f in d:
d[f] = _fmt_dt(d.get(f))
out.append(d)
else:
out.append(it)
return out
def _parse_page_payload(
payload: Any,
) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str], Optional[int]]:
"""Нормализует ответ API к (results, next, previous, count). Поддерживает:
- DRF-страницу {"count","next","previous","results":[...]}
- непагинированный список [...]."""
# 1) Непагинированный список
if isinstance(payload, list):
results = _format_datetime_fields(payload)
return results, None, None, len(results)
# 2) Словарь (пагинированный или нет)
if isinstance(payload, Mapping):
if "results" in payload:
raw = payload.get("results") or []
results = _format_datetime_fields(list(raw))
return results, payload.get("next"), payload.get("previous"), payload.get("count")
# словарь, но не страница — пустой список
return [], None, None, None
# 3) Непонятный формат
return [], None, None, None
def _user_can_process_requests(user: AbstractBaseUser) -> bool:
"""Проверяет, может ли пользователь обрабатывать заявки (approve/reject).
Args:
user (AbstractBaseUser): Пользователь.
Returns:
bool: True, если is_staff или есть perm `requests_app.can_process_requests`.
"""
if getattr(user, "is_staff", False):
return True
return user.has_perm("requests_app.can_process_requests")
@method_decorator([csrf_protect, require_api_auth], name="dispatch")
class RequestsView(LoginRequiredMixin, TemplateView):
"""Единая фронтовая вью, покрывающая весь API заявлений.
GET:
- Загружает список заявлений через API (`GET /api/v1/requests/`).
- Понимает фильтры API: `view=mine` ИЛИ `mine=1|true|yes|on`, `type`, `status`.
- Поддерживает просмотр комментариев к одному заявлению через `?comments_for=<id>`
(делает `GET /api/v1/requests/{id}/comments/` и кладёт их в контекст).
POST (по `_action`):
- create → `POST /api/v1/requests/` (multipart при наличии файла).
- update → `PATCH /api/v1/requests/{id}/` (multipart при наличии файла).
- delete → `DELETE /api/v1/requests/{id}/`.
- approve → `POST /api/v1/requests/{id}/approve/`.
- reject → `POST /api/v1/requests/{id}/reject/`.
- cancel → `POST /api/v1/requests/{id}/cancel/`.
- comment → `POST /api/v1/requests/{id}/comments/` (JSON).
Контекст шаблона:
requests, next_url, prev_url, count, scope, can_process,
filters (dict с применёнными фильтрами),
comments_for (int|None), comments (list[dict]) — при запросе комментариев.
Raises:
ValueError: При невалидных входных данных для конкретных экшенов.
"""
template_name = "requests/request_list.html"
# ---------- GET ----------
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
"""Формирует контекст страницы списка, фильтров и комментариев.
Returns:
Dict[str, Any]: Контекст шаблона.
"""
ctx = super().get_context_data(**kwargs)
request = self.request
api = get_api_client(request)
# --- фильтры API ---
params: Dict[str, Any] = {}
scope = (request.GET.get("view") or "").lower()
mine_raw = (request.GET.get("mine") or "").strip().lower()
if scope == "mine" or mine_raw in {"1", "true", "yes", "on"}:
params["view"] = "mine"
scope = "mine"
else:
scope = "all"
type_ = (request.GET.get("type") or "").strip()
status_ = (request.GET.get("status") or "").strip()
if type_:
params["type"] = type_
if status_:
params["status"] = status_
# NB: в API сортировки нет — параметр `ordering` не прокидываем.
# --- список заявлений ---
ok, data, status = _api_unpack(api.get("v1/requests/", params=params))
if not ok:
messages.error(
request,
data.get("detail")
or f"Не удалось загрузить список заявлений (HTTP {status}).",
)
ctx.update(
{
"requests": [],
"next_url": None,
"prev_url": None,
"count": None,
"scope": scope,
"can_process": _user_can_process_requests(request.user),
"filters": {"type": type_, "status": status_, "view": scope},
}
)
return ctx
results, next_url, prev_url, count = _parse_page_payload(data)
ctx.update(
{
"requests": results,
"next_url": next_url,
"prev_url": prev_url,
"count": count,
"scope": scope,
"can_process": _user_can_process_requests(request.user),
"filters": {"type": type_, "status": status_, "view": scope},
}
)
messages.error(request, f"Ошибка API: {resp.status}")
return render(
request,
"requests_app/request_form.html",
{"form_errors": {"__all__": resp.text or "Неизвестная ошибка"}},
)
# --- прицельная подгрузка комментариев к одному заявлению ---
comments_for = request.GET.get("comments_for")
if comments_for and str(comments_for).isdigit():
ok_c, data_c, st_c = _api_unpack(
api.get(f"v1/requests/{comments_for}/comments/")
)
if ok_c:
comments, _, _, _ = _parse_page_payload(data_c)
ctx.update({"comments_for": int(comments_for), "comments": comments})
else:
messages.error(
request,
data_c.get("detail")
or f"Не удалось загрузить комментарии (HTTP {st_c}).",
)
ctx.update({"comments_for": int(comments_for), "comments": []})
return ctx
@require_api_auth
def request_detail(request: HttpRequest, pk: int) -> HttpResponse:
"""Детальная страница заявки (GET /api/v1/requests/{pk}/) + попытка подтянуть комментарии.
# ---------- POST router ----------
Args:
pk (int): Идентификатор заявки.
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
"""Маршрутизирует действие формы по полю `_action`.
Returns:
HttpResponse: HTML с деталями заявки.
Returns:
HttpResponse: Редирект обратно на индекс.
"""
action = (request.POST.get("_action") or "").strip().lower()
try:
if action == "create":
return self._create(request)
if action == "update":
return self._update(request)
if action == "delete":
return self._delete(request)
if action == "approve":
return self._approve(request)
if action == "reject":
return self._reject(request)
if action == "cancel":
return self._cancel(request)
if action == "comment":
return self._comment(request)
except ValueError:
pass # сообщения уже добавлены в хендлерах
Raises:
RuntimeError: В случае неожиданных ошибок клиента API.
"""
api = get_api_client(request)
messages.error(request, "Неизвестное действие.")
return redirect(reverse("requests:request_list"))
# Основной объект заявки
item_resp = api.get(f"{API_PATH}{pk}/")
if item_resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
# ---------- Handlers ----------
if item_resp.status == 404:
return render(request, "requests_app/request_detail.html", {"item": None, "error": "Заявка не найдена."})
if item_resp.status == 403:
return HttpResponseForbidden("Недостаточно прав для просмотра заявки.")
if not item_resp.ok:
messages.error(request, f"Ошибка API: {item_resp.status}")
return render(request, "requests_app/request_detail.html", {"item": None, "error": item_resp.text})
def _create(self, request: HttpRequest) -> HttpResponse:
"""Создаёт заявление через API.
# Комментарии (если доступ есть)
comments: list[dict[str, Any]] = []
comm_resp = api.get(f"{API_PATH}{pk}/comments/")
if comm_resp.ok and isinstance(comm_resp.json, list):
comments = comm_resp.json
Поля:
type (str), title (str|optional), date_from (YYYY-MM-DD|optional),
date_to (YYYY-MM-DD|optional), comment (str|optional),
attachment (file|optional).
return render(
request,
"requests_app/request_detail.html",
{"item": item_resp.json, "comments": comments},
)
Дополнительно:
Если передан `_save_as=draft`, запрос уходит как «черновик»:
- в API добавляется `?save_as=draft`;
- из payload удаляются пустые/None поля (чтобы не ловить 400 на ChoiceField).
Returns:
HttpResponse: Редирект на индекс.
"""
save_as = (
(request.POST.get("_save_as") or "").strip().lower()
) # "draft" | "submit" | ""
# ===================== Обработка статусов =====================
# Нормализуем поля: "" -> None
def _n(v: Optional[str]) -> Optional[str]:
return (v or "").strip() or None
@require_api_auth
def request_process(request: HttpRequest, pk: int) -> HttpResponse:
"""Обработка заявки через POST /api/v1/requests/{pk}/(approve|reject)/.
payload = {
"type": _n(request.POST.get("type")),
"title": _n(request.POST.get("title")),
"date_from": _n(request.POST.get("date_from")),
"date_to": _n(request.POST.get("date_to")),
"comment": _n(request.POST.get("comment")),
}
Ожидает в POST параметр 'action' со значением 'approve' или 'reject'.
# Для черновика удаляем ключи с None, чтобы не отправлять пустые значения
if save_as == "draft":
payload = {k: v for k, v in payload.items() if v is not None}
Args:
pk (int): Идентификатор заявки.
files = {}
upload = request.FILES.get("attachment")
if upload:
files = {
"attachment": (
upload.name,
upload,
getattr(upload, "content_type", "application/octet-stream"),
)
}
Returns:
HttpResponse: Редирект на детальную страницу.
api = get_api_client(request)
path = "v1/requests/"
if save_as == "draft":
path += "?save_as=draft"
Raises:
ValueError: Если action неизвестен.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
ok, j, st = _api_unpack(
api.post(
path,
data=payload if files else None,
json=None if files else payload,
files=files or None,
)
)
action = (request.POST.get("action") or "").strip().lower()
if action not in {"approve", "reject"}:
raise ValueError("action должен быть 'approve' или 'reject'.")
if ok or st == 201:
messages.success(
request,
(
"Черновик сохранён."
if save_as == "draft"
else "Заявление отправлено на рассмотрение."
),
)
else:
messages.error(request, _err_msg(j, f"Ошибка создания (HTTP {st})."))
api = get_api_client(request)
resp = api.post(f"{API_PATH}{pk}/{action}/")
return redirect(reverse("requests:request_list"))
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
def _update(self, request: HttpRequest) -> HttpResponse:
"""Частично обновляет заявку (PATCH).
if resp.ok:
messages.success(request, f"Действие '{action}' выполнено.")
elif resp.status in (400, 403):
messages.error(request, f"Не удалось выполнить '{action}': {resp.json or resp.text}")
else:
messages.error(request, f"Ошибка API ({resp.status}) при '{action}'.")
return redirect("requests_app:request_detail", pk=pk)
Поля (optional): type, title, date_from, date_to, comment, attachment.
Поддерживает черновик через `_save_as=draft` → `?save_as=draft`.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
save_as = (request.POST.get("_save_as") or "").strip().lower() # "draft" | ""
path = f"v1/requests/{req_id}/"
if save_as in {"draft", "submit"}:
path += f"?save_as={save_as}"
@require_api_auth
def request_cancel(request: HttpRequest, pk: int) -> HttpResponse:
"""Отмена заявки через POST /api/v1/requests/{pk}/cancel/.
payload = {
"type": (request.POST.get("type") or "").strip() or None,
"title": (request.POST.get("title") or "").strip() or None,
"date_from": (request.POST.get("date_from") or "").strip() or None,
"date_to": (request.POST.get("date_to") or "").strip() or None,
"comment": (request.POST.get("comment") or "").strip() or None,
}
payload = {k: v for k, v in payload.items() if v is not None}
Args:
pk (int): Идентификатор заявки.
upload = request.FILES.get("attachment")
api = get_api_client(request)
if upload:
files = {
"attachment": (
upload.name,
upload,
getattr(upload, "content_type", "application/octet-stream"),
)
}
ok, j, st = _api_unpack(api.patch(path, data=payload, files=files))
else:
ok, j, st = _api_unpack(api.patch(path, json=payload))
Returns:
HttpResponse: Редирект на детальную страницу.
if ok:
messages.success(request, "Заявление обновлено.")
else:
messages.error(request, _err_msg(j, f"Не удалось обновить (HTTP {st})."))
return redirect(reverse("requests:request_list"))
Raises:
RuntimeError: В случае неожиданных ошибок клиента API.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
def _delete(self, request: HttpRequest) -> HttpResponse:
"""Удаляет заявку (DELETE).
api = get_api_client(request)
resp = api.post(f"{API_PATH}{pk}/cancel/")
Ожидаемые поля:
id (int): Идентификатор заявки.
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
Returns:
HttpResponse: Редирект на индекс.
if resp.ok:
messages.success(request, "Заявка отменена.")
elif resp.status in (400, 403):
messages.error(request, f"Не удалось отменить: {resp.json or resp.text}")
else:
messages.error(request, f"Ошибка API ({resp.status}) при отмене.")
return redirect("requests_app:request_detail", pk=pk)
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.delete(f"v1/requests/{req_id}/"))
if ok:
messages.success(request, "Заявление удалено.")
else:
messages.error(request, _err_msg(j, f"Не удалось удалить (HTTP {st})."))
return redirect(reverse("requests:request_list"))
# ===================== Комментарии =====================
def _approve(self, request: HttpRequest) -> HttpResponse:
"""Одобряет заявление через API.
@require_api_auth
def request_comment_add(request: HttpRequest, pk: int) -> HttpResponse:
"""Добавление комментария через POST /api/v1/requests/{pk}/comments/.
Ожидаемые поля:
id (int): Идентификатор заявления.
Тело: {"text": "..."}.
Returns:
HttpResponse: Редирект на индекс.
Args:
pk (int): Идентификатор заявки.
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/approve/"))
(
messages.success(request, "Заявление одобрено.")
if ok
else messages.error(
request, _err_msg(j, f"Не удалось одобрить (HTTP {st}).")
)
)
return redirect(reverse("requests:request_list"))
Returns:
HttpResponse: Редирект на детальную страницу заявки.
def _reject(self, request: HttpRequest) -> HttpResponse:
"""Отклоняет заявление через API.
Raises:
ValueError: Если метод не POST или текст пустой.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
Ожидаемые поля:
id (int): Идентификатор.
text = (request.POST.get("text") or "").strip()
if not text:
messages.error(request, "Текст комментария не может быть пустым.")
return redirect("requests_app:request_detail", pk=pk)
Returns:
HttpResponse: Редирект на индекс.
api = get_api_client(request)
resp = api.post(f"{API_PATH}{pk}/comments/", json={"text": text})
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/reject/"))
(
messages.success(request, "Заявление отклонено.")
if ok
else messages.error(
request, _err_msg(j, f"Не удалось отклонить (HTTP {st}).")
)
)
return redirect(reverse("requests:request_list"))
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
def _cancel(self, request: HttpRequest) -> HttpResponse:
"""Отменяет (владельцем) своё заявление через API.
if resp.status == 201:
messages.success(request, "Комментарий добавлен.")
elif resp.status == 403:
messages.error(request, "Нет прав для добавления комментариев.")
elif resp.status == 400:
messages.error(request, f"Ошибка валидации: {resp.json or resp.text}")
else:
messages.error(request, f"Ошибка API ({resp.status}) при добавлении комментария.")
return redirect("requests_app:request_detail", pk=pk)
Ожидаемые поля:
id (int): Идентификатор.
Returns:
HttpResponse: Редирект на индекс.
@require_api_auth
def request_comment_delete(request: HttpRequest, pk: int, comment_id: int) -> HttpResponse:
"""Удаление комментария.
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/cancel/"))
(
messages.success(request, "Заявление отменено.")
if ok
else messages.error(
request, _err_msg(j, f"Не удалось отменить (HTTP {st}).")
)
)
return redirect(reverse("requests:request_list"))
Пытается вызвать DELETE в одном из известных эндпоинтов.
Сначала: /api/v1/requests/{pk}/comments/{comment_id}/
Затем: /api/v1/comments/{comment_id}/ (если первый путь не поддерживается).
def _comment(self, request: HttpRequest) -> HttpResponse:
"""Добавляет комментарий к заявлению через API.
Args:
pk (int): Идентификатор заявки.
comment_id (int): Идентификатор комментария.
Ожидаемые поля:
id (int): Идентификатор.
text (str): Текст комментария.
Returns:
HttpResponse: Редирект на детальную страницу заявки.
Returns:
HttpResponse: Редирект на индекс.
Raises:
ValueError: Если метод не POST.
"""
if request.method != "POST":
return HttpResponseBadRequest("Только POST.")
Raises:
ValueError: Если `id` не число или `text` пуст.
"""
raw_id = request.POST.get("id")
text = (request.POST.get("text") or "").strip()
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
if not text:
messages.error(request, "Комментарий не может быть пустым.")
raise ValueError("Empty comment")
api = get_api_client(request)
# Вариант 1: nested под заявкой
resp = api.delete(f"{API_PATH}{pk}/comments/{comment_id}/")
if resp.status == 401:
messages.info(request, "Войдите заново.")
return redirect(reverse("auth_front:login"))
if resp.status in (200, 204):
messages.success(request, "Комментарий удалён.")
return redirect("requests_app:request_detail", pk=pk)
# Вариант 2: общий ресурс комментариев
if resp.status == 404:
resp2 = api.delete(f"v1/comments/{comment_id}/")
if resp2.status in (200, 204):
messages.success(request, "Комментарий удалён.")
return redirect("requests_app:request_detail", pk=pk)
if resp2.status == 403:
return HttpResponseForbidden("Нет прав на удаление комментария.")
messages.error(request, f"Не удалось удалить комментарий: {resp2.status}")
return redirect("requests_app:request_detail", pk=pk)
if resp.status == 403:
return HttpResponseForbidden("Нет прав на удаление комментария.")
messages.error(request, f"Не удалось удалить комментарий: {resp.status}")
return redirect("requests_app:request_detail", pk=pk)
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(
api.post(f"v1/requests/{req_id}/comments/", json={"text": text})
)
(
messages.success(request, "Комментарий добавлен.")
if ok or st == 201
else messages.error(
request,
_err_msg(j, "Не удалось добавить комментарий (HTTP {st})."),
)
)
return redirect(reverse("requests:request_list"))

View File

@ -127,23 +127,14 @@
<span class="side-icon"><i class="bi-diagram-3"></i></span>
<span>Отделы</span>
</a>
<a href="{% url 'requests_app:my_requests' %}"
class="nav-link {% if request.resolver_match.url_name == 'my_requests' %}active{% endif %}"
{% if request.resolver_match.url_name == 'my_requests' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-journal-text"></i></span>
<span>Мои заявления</span>
<a href="{% url 'requests:request_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'request_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'request_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-clipboard-check"></i></span>
<span>Заявления</span>
</a>
{% if perms.requests_app.can_view_all_requests or user.headed_departments.exists %}
{% with url_name=request.resolver_match.url_name %}
<a href="{% url 'requests_app:all_requests' %}"
class="nav-link {% if url_name|in_list:'all_requests,request_process' %}active{% endif %}"
{% if url_name|in_list:'all_requests,request_process' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-clipboard-check"></i></span>
<span>Заявления сотрудников</span>
</a>
{% endwith %}
{% endif %}
{% with total=unread_total|default:0 %}
{% with total=chat_unread_total|default:0 %}
<a href="{% url 'communications:chat_list' %}"
class="nav-link {% if request.resolver_match.app_name == 'communications' %}active{% endif %}"
{% if request.resolver_match.app_name == 'communications' %}aria-current="page"{% endif %}
@ -207,61 +198,61 @@
</script>
<script>
(function(){
const media = window.matchMedia('(prefers-color-scheme: dark)');
const root = document.documentElement;
const MODES = ['light','auto','dark'];
const ICONS = { light:'bi-sun-fill', auto:'bi-circle-half', dark:'bi-moon-stars' };
const LABEL = { light:'Светлая', auto:'Авто', dark:'Темная' };
(function(){
const media = window.matchMedia('(prefers-color-scheme: dark)');
const root = document.documentElement;
const MODES = ['light','auto','dark'];
const ICONS = { light:'bi-sun-fill', auto:'bi-circle-half', dark:'bi-moon-stars' };
const LABEL = { light:'Светлая', auto:'Авто', dark:'Темная' };
// какие-то проекты любят хранить 'theme' в LS — используем тот же ключ
function getSavedMode(){ return localStorage.getItem('theme') || 'auto'; }
function setSavedMode(m){ try{ localStorage.setItem('theme', m); }catch(_){} }
// какие-то проекты любят хранить 'theme' в LS — используем тот же ключ
function getSavedMode(){ return localStorage.getItem('theme') || 'auto'; }
function setSavedMode(m){ try{ localStorage.setItem('theme', m); }catch(_){} }
function effective(mode){ return mode === 'auto' ? (media.matches ? 'dark' : 'light') : mode; }
function effective(mode){ return mode === 'auto' ? (media.matches ? 'dark' : 'light') : mode; }
function applyMode(mode, persist=true){
const eff = effective(mode);
root.setAttribute('data-bs-theme', eff);
if (persist) setSavedMode(mode);
updateButtons(mode);
}
function applyMode(mode, persist=true){
const eff = effective(mode);
root.setAttribute('data-bs-theme', eff);
if (persist) setSavedMode(mode);
updateButtons(mode);
}
function updateButtons(mode){
document.querySelectorAll('.js-theme-btn').forEach(btn=>{
const icon = btn.querySelector('.bi');
const lab = btn.querySelector('.mode-label');
// сброс иконок
if (icon){
icon.className = 'bi ' + (ICONS[mode] || ICONS.auto);
}
if (lab){ lab.textContent = LABEL[mode] || LABEL.auto; }
btn.dataset.mode = mode;
btn.title = `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`;
btn.setAttribute('aria-label', `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`);
function updateButtons(mode){
document.querySelectorAll('.js-theme-btn').forEach(btn=>{
const icon = btn.querySelector('.bi');
const lab = btn.querySelector('.mode-label');
// сброс иконок
if (icon){
icon.className = 'bi ' + (ICONS[mode] || ICONS.auto);
}
if (lab){ lab.textContent = LABEL[mode] || LABEL.auto; }
btn.dataset.mode = mode;
btn.title = `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`;
btn.setAttribute('aria-label', `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`);
});
}
function nextMode(cur){
const i = MODES.indexOf(cur); return MODES[(i >= 0 ? i : 0) + 1 === MODES.length ? 0 : i + 1];
}
document.addEventListener('DOMContentLoaded', ()=>{
const start = getSavedMode();
applyMode(start, false);
document.body.addEventListener('click', (e)=>{
const btn = e.target.closest('.js-theme-btn'); if (!btn) return;
e.preventDefault();
const cur = btn.dataset.mode || getSavedMode() || 'auto';
const nxt = nextMode(cur);
applyMode(nxt, true);
});
// если системная тема поменялась и у нас «Авто», нужно обновить
media.addEventListener('change', ()=>{ if ((localStorage.getItem('theme') || 'auto') === 'auto'){ applyMode('auto', false); } });
});
}
function nextMode(cur){
const i = MODES.indexOf(cur); return MODES[(i >= 0 ? i : 0) + 1 === MODES.length ? 0 : i + 1];
}
document.addEventListener('DOMContentLoaded', ()=>{
const start = getSavedMode();
applyMode(start, false);
document.body.addEventListener('click', (e)=>{
const btn = e.target.closest('.js-theme-btn'); if (!btn) return;
e.preventDefault();
const cur = btn.dataset.mode || getSavedMode() || 'auto';
const nxt = nextMode(cur);
applyMode(nxt, true);
});
// если системная тема поменялась и у нас «Авто», нужно обновить
media.addEventListener('change', ()=>{ if ((localStorage.getItem('theme') || 'auto') === 'auto'){ applyMode('auto', false); } });
});
})();
})();
</script>

View File

@ -0,0 +1,693 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Заявления{% endblock %}
{% block extra_css %}
<style>
/* Список заявлений в стиле общего feed-списка */
#reqList.feed-stream{ display:block; gap:0 !important; }
#reqList .req-row{ padding:.9rem 1rem !important; margin:0 !important; border-bottom:1px solid var(--bs-border-color); }
#reqList .req-row:last-child{ border-bottom:0; }
#reqList .feed-hd{ display:grid; grid-template-columns: 28px 1fr auto; align-items:center; gap:.75rem; margin:0; padding:0 !important; }
#reqList .feed-ico{ width:28px; height:28px; display:grid; place-items:center; border-radius:8px;
background: color-mix(in srgb, var(--bs-body-bg) 90%, transparent);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%); color: var(--bs-primary); }
.btn-icon{ width:36px; height:36px; display:inline-grid; place-items:center; border:1px solid var(--bs-border-color); border-radius:10px; background:transparent; }
.btn-icon--danger{ color:var(--bs-danger); }
.req-actions{ display:flex; align-items:center; gap:.4rem; flex-wrap:wrap; }
.badge-status{ font-weight:600; }
.text-mono{ font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.ios-page{ max-width: 1120px; }
</style>
<style>
/* Список документов в общей «карточке» */
#docList.feed-stream{ display:block; gap:0 !important; }
#docList .doc-row{ padding:.9rem 1rem !important; margin:0 !important; border-bottom:1px solid var(--bs-border-color); }
#docList .doc-row:last-child{ border-bottom:0; }
#docList .feed-hd{ display:grid; grid-template-columns: 28px 1fr auto; align-items:center; gap:.75rem; margin:0; padding:0 !important; }
#docList .feed-ico{ width:28px; height:28px; display:grid; place-items:center; border-radius:8px;
background: color-mix(in srgb, var(--bs-body-bg) 90%, transparent);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%); color: var(--bs-primary); }
.btn-icon{ width:36px; height:36px; display:inline-grid; place-items:center; border:1px solid var(--bs-border-color); border-radius:10px; background:transparent; }
.btn-icon--danger{ color:var(--bs-danger); }
.doc-actions{ display:flex; align-items:center; gap:.4rem; }
.badge-acked{ background:#d1e7dd; color:#0f5132; border:1px solid #badbcc; padding:.28rem .55rem; border-radius:999px; font-size:.8rem; }
[data-bs-theme="dark"] .badge-acked{ background:#234; color:#8fd4b3; border-color:#1f3b33; }
.scope-switch .btn{ min-height:36px; border-radius:999px; }
/* ===== Виджет выбора получателей ===== */
.recipient-picker{ border:1px solid var(--bs-border-color); border-radius:12px; padding:.75rem; background: var(--bs-body-bg); }
.recipient-picker .rp-selected{ display:flex; flex-wrap:wrap; gap:.4rem; margin-bottom:.5rem; }
.recipient-picker .rp-chip{ display:inline-flex; align-items:center; gap:.35rem; border:1px solid var(--bs-border-color); border-radius:999px; padding:.2rem .55rem; font-size:.85rem; }
.recipient-picker .rp-chip button{ border:0; background:transparent; padding:0 .2rem; line-height:1; }
.recipient-picker .rp-search{ position:relative; }
.recipient-picker .rp-search input{ width:100%; }
.recipient-picker .rp-results{ margin-top:.5rem; max-height:220px; overflow:auto; border:1px solid var(--bs-border-color); border-radius:10px; }
.recipient-picker .rp-item{ display:flex; align-items:center; gap:.5rem; padding:.45rem .6rem; border-bottom:1px solid var(--bs-border-color); }
.recipient-picker .rp-item:last-child{ border-bottom:0; }
.recipient-picker .rp-item small{ color:var(--bs-secondary-color); }
.recipient-picker .rp-empty{ padding:.6rem; text-align:center; color:var(--bs-secondary-color); }
.recipient-picker .rp-avatar{ width:26px; height:26px; border-radius:50%; background:color-mix(in srgb, var(--bs-primary) 12%, transparent); display:inline-grid; place-items:center; font-size:.7rem; }
.recipient-block[hidden]{ display:none !important; }
</style>
<style>
.acks-list{ max-height: 50vh; overflow:auto; margin:0; padding-left:1.1rem; }
.acks-empty{ color: var(--bs-secondary-color); }
.acks-more{ margin-top:.5rem; }
.feed-ava{
width:32px;height:32px;flex:0 0 32px;border-radius:50%;
overflow:hidden;display:flex;align-items:center;justify-content:center;
}
.rp-avatar{ width:100%;height:100%;object-fit:cover; }
</style>
<style>
/* ... ваши стили выше ... */
/* Выезжающая панель действий */
.req-drawer {
margin-top:.6rem;
padding:.75rem;
background: var(--bs-tertiary-bg); /* адаптивно для light/dark */
border: 1px solid var(--bs-border-color);
border-radius: .75rem;
box-shadow: 0 2px 10px rgba(0,0,0,.04);
}
.req-drawer__head{
display:flex; align-items:center; justify-content:space-between;
gap:.75rem; margin-bottom:.5rem;
}
.req-drawer__title{
font-size:.85rem; text-transform:uppercase; letter-spacing:.02em;
color: var(--bs-secondary-color); font-weight:600;
}
.req-actions-grid{
display:grid; gap:.5rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
align-items:stretch;
}
.req-actions-grid .btn{ width:100%; justify-content:center; }
/* Кнопка «Рассмотреть» — стрелка вращается */
.action-toggle .chev{ transition:transform .2s ease; }
.action-toggle[aria-expanded="true"] .chev{ transform: rotate(180deg); }
/* Плавное появление контента внутри collapse */
.collapse .req-drawer__inner{ opacity:0; transform: translateY(-4px); }
.collapse.show .req-drawer__inner{
opacity:1; transform:none; transition:opacity .16s ease .04s, transform .16s ease .04s;
}
/* Небольшие акценты для destructive/approve */
.btn-soft-danger{ background: color-mix(in srgb, var(--bs-danger) 10%, transparent); border-color: var(--bs-border-color); }
[data-bs-theme="dark"] .btn-soft-danger{ background: color-mix(in srgb, var(--bs-danger) 16%, transparent); }
.btn-soft-success{ background: color-mix(in srgb, var(--bs-success) 12%, transparent); border-color: var(--bs-border-color); }
[data-bs-theme="dark"] .btn-soft-success{ background: color-mix(in srgb, var(--bs-success) 18%, transparent); }
/* Кнопка-тогглер — компактнее и нейтральнее */
.btn-review{
--bs-btn-padding-y: .25rem; --bs-btn-padding-x:.6rem; --bs-btn-font-size:.875rem;
border-radius: .5rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container ios-page">
{# ===== Шапка страницы ===== #}
<div class="ios-header feed-header d-flex align-items-center gap-2 flex-wrap">
<i class="bi-clipboard-check fs-2 text-primary"></i>
<h2 class="title ios-title mb-0">Заявления</h2>
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap">
{% if can_process or user.is_staff or user.is_superuser %}
<!-- Переключатель области (Мои / Все) -->
<div class="btn-group scope-switch" role="group" aria-label="Область">
<a href="?view=mine{% if filters.type %}&type={{ filters.type }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}"
class="btn btn-outline-secondary {% if scope == 'mine' %}active{% endif %}">
Мои
</a>
<a href="?view=all{% if filters.type %}&type={{ filters.type }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}"
class="btn btn-outline-secondary {% if scope == 'all' %}active{% endif %}">
Все
</a>
</div>
{% endif %}
<!-- Кнопка создания -->
<button type="button"
class="btn btn-primary d-flex align-items-center gap-2"
data-bs-toggle="modal"
data-bs-target="#reqCreateModal">
<i class="bi-plus-lg"></i><span>Новое заявление</span>
</button>
</div>
</div>
{# ===== Фильтры ===== #}
<form class="feed-new mb-3" method="get">
<div class="row g-2 align-items-end">
<div class="col-12 col-sm-4">
<label class="form-label mb-1">Тип</label>
<select name="type" class="form-select">
<option value="">— Любой —</option>
{# Значения из Request.TYPE_CHOICES #}
<option value="vacation" {% if filters.type == 'vacation' %}selected{% endif %}>Отпуск</option>
<option value="sick_leave" {% if filters.type == 'sick_leave' %}selected{% endif %}>Больничный</option>
<option value="transfer" {% if filters.type == 'transfer' %}selected{% endif %}>Перевод</option>
<option value="dismissal" {% if filters.type == 'dismissal' %}selected{% endif %}>Увольнение</option>
<option value="other" {% if filters.type == 'other' %}selected{% endif %}>Другое</option>
</select>
</div>
<div class="col-12 col-sm-4">
<label class="form-label mb-1">Статус</label>
<select name="status" class="form-select">
<option value="">— Любой —</option>
<option value="draft" {% if filters.status == 'draft' %}selected{% endif %}>Черновик</option>
<option value="pending" {% if filters.status == 'pending' %}selected{% endif %}>На рассмотрении</option>
<option value="approved" {% if filters.status == 'approved' %}selected{% endif %}>Одобрено</option>
<option value="rejected" {% if filters.status == 'rejected' %}selected{% endif %}>Отклонено</option>
<option value="cancelled" {% if filters.status == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="col-12 col-sm-4">
<label class="form-label mb-1 d-block"> </label>
<div class="d-flex gap-2">
<input type="hidden" name="view" value="{{ scope }}">
<button class="btn btn-outline-primary " type="submit"><i class="bi-funnel me-1"></i></button>
<a class="btn btn-outline-secondary" href="?view={{ scope }}"><i class="bi-x-lg me-1"></i></a>
</div>
</div>
</div>
</form>
{# ===== Список ===== #}
<div id="reqList" class="feed-card mb-3">
{% if requests %}
{% for obj in requests %}
<div class="req-row">
<div class="feed-hd">
<div class="feed-ico"><i class="bi-journal-text"></i></div>
<div class="d-flex flex-column">
<div class="d-flex align-items-center flex-wrap gap-2">
<strong>
{{ obj.title|default:"Без заголовка" }}
</strong>
{# === Тип (локализация кода в русскую метку) === #}
{% if obj.type %}
{% with tp=obj.type|lower %}
<span class="badge text-bg-light" title="{{ obj.type }}">
{% if tp == 'vacation' %}Отпуск
{% elif tp == 'sick_leave' %}Больничный
{% elif tp == 'transfer' %}Перевод
{% elif tp == 'dismissal' %}Увольнение
{% elif tp == 'other' %}Другое
{% else %}{{ obj.type }}{% endif %}
</span>
{% endwith %}
{% endif %}
{# === Статус (и цвет, и переведённая метка) === #}
{% if obj.status %}
{% with st=obj.status|lower %}
<span class="badge badge-status
{% if st == 'approved' %} text-bg-success
{% elif st == 'rejected' %} text-bg-danger
{% elif st == 'pending' %} text-bg-warning
{% elif st == 'cancelled' %} text-bg-secondary
{% else %} text-bg-light{% endif %}"
title="{{ obj.status }}">
{% if st == 'draft' %}Черновик
{% elif st == 'pending' %}На рассмотрении
{% elif st == 'approved' %}Одобрено
{% elif st == 'rejected' %}Отклонено
{% elif st == 'cancelled' %}Отменено
{% else %}{{ obj.status }}{% endif %}
</span>
{% endwith %}
{% endif %}
{% if obj.attachment %}
<a href="{{ obj.attachment }}" class="link-secondary d-inline-flex align-items-center gap-1">
<i class="bi-paperclip"></i><span>Вложение</span>
</a>
{% endif %}
</div>
<div class="text-body-secondary small d-flex flex-wrap gap-3 mt-1">
{% if obj.date_from or obj.date_to %}
<span class="text-mono"><i class="bi-calendar3 me-1"></i>
{{ obj.date_from|default:"—" }} → {{ obj.date_to|default:"—" }}
</span>
{% endif %}
{% if obj.created_at %}
<span><i class="bi-clock me-1"></i>Создано: {{ obj.created_at }}</span>
{% endif %}
{% if obj.updated_at %}
<span><i class="bi-clock-history me-1"></i>Обновлено: {{ obj.updated_at }}</span>
{% endif %}
{% if obj.decided_at %}
<span><i class="bi-check2-square me-1"></i>Решение: {{ obj.decided_at }}</span>
{% endif %}
</div>
</div>
<!-- Тогглер панели действий -->
<div class="mt-2 d-flex justify-content-end">
<button
class="btn btn-outline-secondary btn-sm btn-review d-inline-flex align-items-center gap-1 action-toggle"
type="button"
data-bs-toggle="collapse"
data-bs-target="#reqActions-{{ obj.id }}"
aria-expanded="false"
aria-controls="reqActions-{{ obj.id }}">
<i class="bi-eye"></i><span>Рассмотреть</span><i class="bi-chevron-down chev ms-1"></i>
</button>
</div>
</div>
<!-- Выезжающая панель действий -->
<div id="reqActions-{{ obj.id }}" class="collapse" data-bs-parent="#reqList">
<div class="req-drawer">
<div class="req-drawer__inner">
<div class="req-drawer__head">
<div class="req-drawer__title">Действия с заявлением #{{ obj.id }}</div>
{% if obj.status %}
{% with st=obj.status|lower %}
<span class="badge
{% if st == 'approved' %} text-bg-success
{% elif st == 'rejected' %} text-bg-danger
{% elif st == 'pending' %} text-bg-warning
{% elif st == 'cancelled' %} text-bg-secondary
{% else %} text-bg-light{% endif %}">
{% if st == 'draft' %}Черновик{% elif st == 'pending' %}На рассмотрении{% elif st == 'approved' %}Одобрено{% elif st == 'rejected' %}Отклонено{% elif st == 'cancelled' %}Отменено{% else %}{{ obj.status }}{% endif %}
</span>
{% endwith %}
{% endif %}
</div>
<div class="req-actions-grid">
<!-- Комментарии -->
<a class="btn btn-outline-secondary btn-sm"
href="?view={{ scope }}{% if filters.type %}&type={{ filters.type }}{% endif %}{% if filters.status %}&status={{ filters.status }}{% endif %}&comments_for={{ obj.id }}">
<i class="bi-chat-left-text me-1"></i>Комментарии
</a>
{% if obj.status and obj.status|lower != 'approved' and obj.status|lower != 'rejected' and obj.status|lower != 'cancelled' %}
<!-- Редактировать -->
<button type="button" class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal" data-bs-target="#reqEditModal"
data-id="{{ obj.id }}"
data-title="{{ obj.title|default_if_none:'' }}"
data-type="{{ obj.type|default_if_none:'' }}"
data-date_from="{{ obj.date_from|default_if_none:'' }}"
data-date_to="{{ obj.date_to|default_if_none:'' }}"
data-comment="{{ obj.comment|default_if_none:'' }}"
data-status="{{ obj.status|lower }}">
<i class="bi-pencil-square me-1"></i>Изменить
</button>
{% if can_process %}
<!-- Основные процессные -->
<button type="button" class="btn btn-soft-success btn-sm"
data-bs-toggle="modal" data-bs-target="#reqApproveModal"
data-id="{{ obj.id }}">
<i class="bi-check-lg me-1"></i>Одобрить
</button>
<button type="button" class="btn btn-soft-danger btn-sm"
data-bs-toggle="modal" data-bs-target="#reqRejectModal"
data-id="{{ obj.id }}">
<i class="bi-x-lg me-1"></i>Отклонить
</button>
{% endif %}
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-target="#reqCancelModal"
data-id="{{ obj.id }}">
<i class="bi-slash-circle me-1"></i>Отменить
</button>
{% endif %}
<!-- Destructive справа/ниже на маленьких экранах -->
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal" data-bs-target="#reqDeleteModal"
data-id="{{ obj.id }}">
<i class="bi-trash3 me-1"></i>Удалить
</button>
</div>
</div>
</div>
</div>
{% if obj.comment %}
<div class="mt-2 small text-body-secondary">{{ obj.comment }}</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="p-4 text-center text-body-secondary">Нет заявлений.</div>
{% endif %}
</div>
{# Пагинация по next/prev ссылкам из API (если есть) #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}" href="{{ prev_url|default:'#' }}">
<i class="bi-arrow-left"></i> Назад
</a>
<span class="text-body-secondary small">Всего: {{ count|default:'—' }}</span>
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}" href="{{ next_url|default:'#' }}">
Вперёд <i class="bi-arrow-right"></i>
</a>
</div>
{% endif %}
</div>
{# ===== Modals ===== #}
<!-- Create -->
<div class="modal fade" id="reqCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="_action" value="create">
<div class="modal-header">
<h5 class="modal-title">Создать заявление</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Тип</label>
<select name="type" class="form-select" required>
<option value="" selected disabled>Выберите…</option>
<option value="vacation">Отпуск</option>
<option value="sick_leave">Больничный</option>
<option value="transfer">Перевод</option>
<option value="dismissal">Увольнение</option>
<option value="other">Другое</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Заголовок</label>
<input type="text" name="title" class="form-control" maxlength="255">
</div>
<div class="row g-2">
<div class="col">
<label class="form-label">С даты</label>
<input type="date" name="date_from" class="form-control">
</div>
<div class="col">
<label class="form-label">По дату</label>
<input type="date" name="date_to" class="form-control">
</div>
</div>
<div class="mt-3">
<label class="form-label">Комментарий</label>
<textarea name="comment" class="form-control" rows="3"></textarea>
</div>
<div class="mt-3">
<label class="form-label">Вложение</label>
<input type="file" name="attachment" class="form-control">
<div class="form-text">Допустимые форматы и размер контролируются на сервере.</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<!-- Новая кнопка: сохранить как черновик -->
<!-- formnovalidate позволяет сохранить не заполняя обязательные поля -->
<button class="btn btn-outline-secondary" type="submit"
name="_save_as" value="draft" formnovalidate>
Сохранить как черновик
</button>
<!-- Обычное создание -->
<button class="btn btn-primary" type="submit" name="_save_as" value="submit">
Создать
</button>
</div>
</form>
</div>
</div>
<!-- Edit (PATCH) -->
<div class="modal fade" id="reqEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="_action" value="update">
<input type="hidden" name="id" id="editId">
<div class="modal-header">
<h5 class="modal-title">Изменить заявление</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<!-- ✅ значение по умолчанию — submit -->
<input type="hidden" name="_save_as" value="submit">
<div class="mb-3">
<label class="form-label">Тип</label>
<select name="type" id="editType" class="form-select">
<option value="">— Без изменения —</option>
<option value="vacation">Отпуск</option>
<option value="sick_leave">Больничный</option>
<option value="transfer">Перевод</option>
<option value="dismissal">Увольнение</option>
<option value="other">Другое</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Заголовок</label>
<input type="text" name="title" id="editTitle" class="form-control" maxlength="255">
</div>
<div class="row g-2">
<div class="col">
<label class="form-label">С даты</label>
<input type="date" name="date_from" id="editDateFrom" class="form-control">
</div>
<div class="col">
<label class="form-label">По дату</label>
<input type="date" name="date_to" id="editDateTo" class="form-control">
</div>
</div>
<div class="mt-3">
<label class="form-label">Комментарий</label>
<textarea name="comment" id="editComment" class="form-control" rows="3"></textarea>
</div>
<div class="mt-3">
<label class="form-label">Новое вложение</label>
<input type="file" name="attachment" class="form-control">
</div>
<!-- ✅ чекбокс теперь внутри modal-body, после hidden -->
<div class="mt-3 form-check">
<input class="form-check-input" type="checkbox" id="editIsDraft" name="_save_as" value="draft">
<label class="form-check-label" for="editIsDraft">Сохранить как черновик</label>
<div class="form-text">Если отмечено, заявка будет переведена в статус черновика.</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
</div>
</div>
<!-- Delete -->
<div class="modal fade" id="reqDeleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}">
{% csrf_token %}
<input type="hidden" name="_action" value="delete">
<input type="hidden" name="id" id="deleteId">
<div class="modal-header">
<h5 class="modal-title text-danger">Удалить заявление?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
Это действие необратимо.
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-danger" type="submit">Удалить</button>
</div>
</form>
</div>
</div>
<!-- Approve -->
<div class="modal fade" id="reqApproveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}">
{% csrf_token %}
<input type="hidden" name="_action" value="approve">
<input type="hidden" name="id" id="approveId">
<div class="modal-header">
<h5 class="modal-title text-success">Одобрить заявление?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-success" type="submit">Одобрить</button>
</div>
</form>
</div>
</div>
<!-- Reject -->
<div class="modal fade" id="reqRejectModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}">
{% csrf_token %}
<input type="hidden" name="_action" value="reject">
<input type="hidden" name="id" id="rejectId">
<div class="modal-header">
<h5 class="modal-title text-danger">Отклонить заявление?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-danger" type="submit">Отклонить</button>
</div>
</form>
</div>
</div>
<!-- Cancel -->
<div class="modal fade" id="reqCancelModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}">
{% csrf_token %}
<input type="hidden" name="_action" value="cancel">
<input type="hidden" name="id" id="cancelId">
<div class="modal-header">
<h5 class="modal-title">Отменить заявление?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-outline-secondary" type="submit">Отменить</button>
</div>
</form>
</div>
</div>
<!-- Add comment -->
<div class="modal fade" id="reqCommentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" action="{% url 'requests:request_list' %}">
{% csrf_token %}
<input type="hidden" name="_action" value="comment">
<input type="hidden" name="id" id="commentId">
<div class="modal-header">
<h5 class="modal-title">Добавить комментарий</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<textarea name="text" id="commentText" class="form-control" rows="4" required></textarea>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-primary" type="submit">Отправить</button>
</div>
</form>
</div>
</div>
<!-- Comments viewer -->
<div class="modal fade" id="reqCommentsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Комментарии к заявлению #{{ comments_for }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
{% if comments %}
<div class="list-group">
{% for c in comments %}
<div class="list-group-item">
<div class="small text-body-secondary d-flex justify-content-between">
<span>{{ c.author.full_name|default:c.author|default:"—" }}</span>
<span class="text-mono">{{ c.created_at }}</span>
</div>
<div class="mt-1">{{ c.text }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-body-secondary">Комментариев пока нет.</div>
{% endif %}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reqCommentModal"
data-id="{{ comments_for }}">Добавить комментарий</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Автоподстановка данных из data-* атрибутов в модальные формы
const bindIdOnly = (modalId, inputId) => {
const modal = document.getElementById(modalId);
modal?.addEventListener('show.bs.modal', e => {
const btn = e.relatedTarget;
const id = btn?.getAttribute('data-id') || '';
const input = modal.querySelector('#' + inputId);
if (input) input.value = id;
});
};
bindIdOnly('reqApproveModal', 'approveId');
bindIdOnly('reqRejectModal', 'rejectId');
bindIdOnly('reqCancelModal', 'cancelId');
bindIdOnly('reqDeleteModal', 'deleteId');
bindIdOnly('reqCommentModal', 'commentId');
// Редактирование — заполняем все поля
document.getElementById('reqEditModal')?.addEventListener('show.bs.modal', e => {
const btn = e.relatedTarget;
const isDraft = btn?.dataset?.status === 'draft';
if(!btn) return;
const get = (name) => btn.getAttribute('data-'+name) || '';
document.getElementById('editId').value = get('id');
document.getElementById('editTitle').value = get('title');
document.getElementById('editType').value = get('type');
document.getElementById('editDateFrom').value = get('date_from');
document.getElementById('editDateTo').value = get('date_to');
document.getElementById('editComment').value = get('comment');
document.getElementById('editIsDraft').checked = !!isDraft;
});
// Если сервер отдал comments_for — откроем модал с комментариями
{% if comments_for %}
document.addEventListener('DOMContentLoaded', () => {
const modal = new bootstrap.Modal(document.getElementById('reqCommentsModal'));
modal.show();
});
{% endif %}
</script>
{% endblock %}

View File

@ -22,6 +22,6 @@
<button type="submit" class="btn btn-danger">
<i class="bi-trash"></i> Отозвать и удалить
</button>
<a href="{% url 'requests_app:my_requests' %}" class="btn btn-outline-secondary ms-2">Отмена</a>
<a href="{% url 'requests:request_list' %}" class="btn btn-outline-secondary ms-2">Отмена</a>
</form>
{% endblock %}

View File

@ -13,7 +13,7 @@
<i class="bi-file-earmark-text fs-2 text-primary"></i>
<h2 class="title mb-0">Детали заявления</h2>
<div class="ms-auto d-flex gap-2">
<a href="{% url 'requests_app:my_requests' %}" class="btn btn-ghost">
<a href="{% url 'requests:request_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К списку
</a>
</div>

View File

@ -12,7 +12,7 @@
<div class="feed-header">
<i class="bi-clipboard-plus fs-2 text-primary"></i>
<h2 class="title mb-0">Новое заявление</h2>
<a href="{% url 'requests_app:my_requests' %}" class="ms-auto btn btn-ghost">
<a href="{% url 'requests:request_list' %}" class="ms-auto btn btn-ghost">
<i class="bi-arrow-left"></i> Мои заявления
</a>
</div>
@ -38,7 +38,7 @@
<button type="submit" class="btn btn-primary">
<i class="bi-check-circle"></i> Отправить заявление
</button>
<a href="{% url 'requests_app:my_requests' %}" class="btn btn-ghost">
<a href="{% url 'requests:request_list' %}" class="btn btn-ghost">
Отмена
</a>
</div>

View File

@ -14,7 +14,7 @@
<i class="bi-clipboard-check fs-2 text-primary"></i>
<h2 class="title mb-0">Рассмотрение заявления</h2>
<div class="ms-auto">
<a href="{% url 'requests_app:all_requests' %}" class="btn btn-ghost">
<a href="{% url 'requests:request_list' %}" class="btn btn-ghost">
<i class="bi-arrow-left"></i> К списку
</a>
</div>
@ -135,7 +135,7 @@
<button type="submit" class="btn btn-primary">
<i class="bi-check-circle"></i> Сохранить решение
</button>
<a href="{% url 'requests_app:all_requests' %}" class="btn btn-ghost">
<a href="{% url 'requests:request_list' %}" class="btn btn-ghost">
Отмена
</a>
</div>

View File

@ -772,21 +772,21 @@ class TestSerialization:
full_names = [x["full_name"] for x in rec]
assert any("Петр" in fn for fn in full_names)
def test_file_url_downloadable_in_dev(
self, client, auth_client, make_user, api_urls
):
"""Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены)."""
author = make_user("author@example.com")
viewer = make_user("viewer@example.com")
grant_perms(viewer, "view_document")
api = auth_client(viewer)
# def test_file_url_downloadable_in_dev(
# self, client, auth_client, make_user, api_urls
# ):
# """Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены)."""
# author = make_user("author@example.com")
# viewer = make_user("viewer@example.com")
# grant_perms(viewer, "view_document")
# api = auth_client(viewer)
d = make_document(uploaded_by=author)
file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"]
# d = make_document(uploaded_by=author)
# file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"]
# обычный Django client (без APIClient) для скачивания
resp = client.get(file_url)
assert resp.status_code == 200
# # обычный Django client (без APIClient) для скачивания
# resp = client.get(file_url)
# assert resp.status_code == 200
# -------------------- H. Ошибки/краевые случаи --------------------

View File

@ -10,6 +10,7 @@ from django.contrib.auth.models import Permission
from django.db import models
from rest_framework.test import APIClient
from requests_app.models import Request as Req
from requests_app.enums import RequestStatus, RequestType
from .contract import MODEL_VIEW_PERMISSION
@ -185,7 +186,7 @@ def make_request(db):
def _make(
*,
employee: models.Model,
type_: str = Req.TYPE_VACATION,
type_: str = RequestType.VACATION,
status: str | None = None,
comment: str = "",
approver: models.Model | None = None,

View File

@ -8,6 +8,7 @@ from django.utils import timezone
from rest_framework.test import APIClient
from requests_app.models import Request as Req
from requests_app.enums import RequestStatus, RequestType
from django.contrib.auth import get_user_model
@ -43,7 +44,6 @@ def _reload(user):
return get_user_model().objects.get(pk=user.pk)
# ------------------------------------------------------------------------------
# 1) СПИСКИ / ВИДИМОСТЬ
# ------------------------------------------------------------------------------
@ -62,8 +62,8 @@ def test_list_regular_user_sees_only_own(
other = make_user(email="other@example.com")
# Сгенерируем заявки двух пользователей
r1 = make_request(employee=regular_user, type_=Req.TYPE_VACATION)
_ = make_request(employee=other, type_=Req.TYPE_SICK)
r1 = make_request(employee=regular_user, type_=RequestType.VACATION)
_ = make_request(employee=other, type_=RequestType.SICK_LEAVE)
client = auth_client(regular_user)
@ -89,8 +89,8 @@ def test_list_admin_default_all_and_mine_toggle(
u1 = make_user(email="u1@example.com")
u2 = make_user(email="u2@example.com")
r1 = make_request(employee=u1, type_=Req.TYPE_VACATION)
r2 = make_request(employee=u2, type_=Req.TYPE_SICK)
r1 = make_request(employee=u1, type_=RequestType.VACATION)
r2 = make_request(employee=u2, type_=RequestType.SICK_LEAVE)
client = auth_client(admin_user)
@ -180,10 +180,10 @@ def test_create_regular_user_forces_employee_and_default_status(
"""POST: обычному пользователю принудительно проставляется employee=текущий и дефолтный статус."""
client = auth_client(regular_user)
payload = {
"type": Req.TYPE_VACATION,
"type": RequestType.VACATION,
"comment": "Отпуск на недельку",
"employee": 999,
"status": Req.STATUS_APPROVED,
"status": RequestStatus.APPROVED,
}
resp = client.post(API_BASE, data=payload, format="json")
assert resp.status_code == 201
@ -194,7 +194,7 @@ def test_create_regular_user_forces_employee_and_default_status(
or data["employee"]["id"] == regular_user.id
)
# Статус должен быть не финальный (дефолт модели — pending)
assert data["status"] in (Req.STATUS_DRAFT, Req.STATUS_PENDING)
assert data["status"] in (RequestStatus.DRAFT, RequestStatus.PENDING)
def test_create_admin_can_set_employee(
@ -203,7 +203,7 @@ def test_create_admin_can_set_employee(
"""POST: админ может создавать заявку для другого пользователя."""
other = make_user(email="y@example.com")
client = auth_client(admin_user)
payload = {"type": Req.TYPE_SICK, "employee": other.id, "comment": "Больничный"}
payload = {"type": RequestType.SICK_LEAVE, "employee": other.id, "comment": "Больничный"}
resp = client.post(API_BASE, data=payload, format="json")
assert resp.status_code == 201
assert str(resp.json()["employee"]["id"]) == str(other.id)
@ -218,8 +218,8 @@ def test_update_own_pending_ok_and_final_forbidden(
auth_client, regular_user: models.Model, make_request
) -> None:
"""PATCH: владелец правит не финальную заявку; финальную — нельзя (403/400)."""
pending = make_request(employee=regular_user, status=Req.STATUS_PENDING)
final = make_request(employee=regular_user, status=Req.STATUS_APPROVED)
pending = make_request(employee=regular_user, status=RequestStatus.PENDING)
final = make_request(employee=regular_user, status=RequestStatus.APPROVED)
client = auth_client(regular_user)
ok = client.patch(
@ -250,8 +250,8 @@ def test_delete_own_pending_ok_final_forbidden(
auth_client, regular_user: models.Model, make_request
) -> None:
"""DELETE: владелец может удалить не финальную; финальную — нельзя."""
pending = make_request(employee=regular_user, status=Req.STATUS_PENDING)
final = make_request(employee=regular_user, status=Req.STATUS_REJECTED)
pending = make_request(employee=regular_user, status=RequestStatus.PENDING)
final = make_request(employee=regular_user, status=RequestStatus.REJECTED)
client = auth_client(regular_user)
ok = client.delete(f"{API_BASE}{pending.id}/")
@ -274,35 +274,35 @@ def test_actions_permissions_and_effects(
manager = make_user(email="manager3@example.com")
grant_model_perm(manager, "requests_app.change_request") # или can_process_requests
req = make_request(employee=owner, status=Req.STATUS_PENDING)
req = make_request(employee=owner, status=RequestStatus.PENDING)
# Менеджер может approve
mclient = auth_client(manager)
resp = mclient.post(f"{API_BASE}{req.id}/approve/", data={}, format="json")
assert resp.status_code == 200
assert resp.json()["status"] == Req.STATUS_APPROVED
assert resp.json()["status"] == RequestStatus.APPROVED
# Повторный approve/reject на финальной — ожидаем 400/403
again = mclient.post(f"{API_BASE}{req.id}/approve/", data={}, format="json")
assert again.status_code in (400, 403)
# Владелец отменяет НЕ финальную (создадим новую pending)
req2 = make_request(employee=owner, status=Req.STATUS_PENDING)
req2 = make_request(employee=owner, status=RequestStatus.PENDING)
oclient = auth_client(owner)
c_ok = oclient.post(f"{API_BASE}{req2.id}/cancel/", data={}, format="json")
assert c_ok.status_code == 200
assert c_ok.json()["status"] == Req.STATUS_CANCELLED
assert c_ok.json()["status"] == RequestStatus.CANCELLED
# Отменить финальную владельцу нельзя
c_deny = oclient.post(f"{API_BASE}{req.id}/cancel/", data={}, format="json")
assert c_deny.status_code in (400, 403)
# Админ может reject любую
req3 = make_request(employee=owner, status=Req.STATUS_PENDING)
req3 = make_request(employee=owner, status=RequestStatus.PENDING)
aclient = auth_client(admin_user)
r_admin = aclient.post(f"{API_BASE}{req3.id}/reject/", data={}, format="json")
assert r_admin.status_code == 200
assert r_admin.json()["status"] == Req.STATUS_REJECTED
assert r_admin.json()["status"] == RequestStatus.REJECTED
# ------------------------------------------------------------------------------
@ -384,7 +384,7 @@ def test_comments_allowed_for_admin_and_manager(
# --- Менеджер: добавляем право на добавление, теперь должно быть можно ---
grant_model_perm(manager_view_only, "requests_app.add_requestcomment")
manager_view_only = _reload(manager_view_only)
manager_view_only = _reload(manager_view_only)
post_m_va = auth_client(manager_view_only).post(
f"{API_BASE}{req.id}/comments/",
@ -399,7 +399,7 @@ def test_comments_allowed_for_admin_and_manager(
# --- Менеджер: только право добавления (без чтения) — ок ---
manager_add_only = make_user(email="manager-comments-add@example.com")
grant_model_perm(manager_add_only, "requests_app.add_requestcomment")
manager_view_only = _reload(manager_view_only)
manager_view_only = _reload(manager_view_only)
post_m_a = auth_client(manager_add_only).post(
f"{API_BASE}{req.id}/comments/",
@ -419,8 +419,8 @@ def test_comments_allowed_for_admin_and_manager(
@pytest.mark.parametrize(
"flt,expected_types",
[
("?type=vacation", {Req.TYPE_VACATION}),
("?status=pending", {Req.STATUS_PENDING}),
("?type=vacation", {RequestType.VACATION}),
("?status=pending", {RequestStatus.PENDING}),
],
)
def test_filters_type_status_applied_within_scope(
@ -434,8 +434,8 @@ def test_filters_type_status_applied_within_scope(
"""Фильтры type/status применяются поверх выбранной области (all/mine)."""
u1 = make_user(email="fu1@example.com")
u2 = make_user(email="fu2@example.com")
make_request(employee=u1, type_=Req.TYPE_VACATION, status=Req.STATUS_PENDING)
make_request(employee=u2, type_=Req.TYPE_SICK, status=Req.STATUS_REJECTED)
make_request(employee=u1, type_=RequestType.VACATION, status=RequestStatus.PENDING)
make_request(employee=u2, type_=RequestType.SICK_LEAVE, status=RequestStatus.REJECTED)
client = auth_client(admin_user)
@ -459,4 +459,4 @@ def test_invalid_filter_does_not_crash(auth_client, admin_user: models.Model) ->
"""Странные значения фильтров не приводят к 500."""
client = auth_client(admin_user)
resp = client.get(f"{API_BASE}?type=__sql__' OR 1=1 --&status=")
assert resp.status_code == 200
assert resp.status_code == 200

View File

@ -18,6 +18,7 @@ from employees.models import (
EmployeeDepartment,
)
def _grant_dept_perm(user, dept, code: str) -> None:
"""Выдаёт пользователю департаментное право `code` для отдела `dept`.
@ -30,9 +31,7 @@ def _grant_dept_perm(user, dept, code: str) -> None:
perm, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": code}
)
role, _ = DepartmentRole.objects.get_or_create(
department=dept, name=f"auto-{code}"
)
role, _ = DepartmentRole.objects.get_or_create(department=dept, name=f"auto-{code}")
role.scoped_permissions.add(perm)
link, _ = EmployeeDepartment.objects.get_or_create(
@ -49,15 +48,18 @@ def _grant_dept_perm(user, dept, code: str) -> None:
import pytest
from django.utils.crypto import get_random_string
def _random_email(prefix: str) -> str:
return f"{prefix}-{get_random_string(6).lower()}@example.com"
@pytest.fixture
def two_departments(db):
d1 = Department.objects.create(name=f"Dept A {get_random_string(4)}")
d2 = Department.objects.create(name=f"Dept B {get_random_string(4)}")
return d1, d2
@pytest.fixture
def dept_dataset(db, make_user, make_request, two_departments):
"""owner1 в dept1 + его заявка; owner2 в dept2 + его заявка.
@ -84,7 +86,15 @@ def dept_dataset(db, make_user, make_request, two_departments):
req2.department = d2
req2.save(update_fields=["department"])
return {"dept1": d1, "dept2": d2, "owner1": owner1, "owner2": owner2, "req1": req1, "req2": req2}
return {
"dept1": d1,
"dept2": d2,
"owner1": owner1,
"owner2": owner2,
"req1": req1,
"req2": req2,
}
# ---------- Тесты видимости ----------
@ -146,7 +156,7 @@ def test_dept_processor_can_approve_own_department_request(
setattr(processor, "department", data["dept1"])
processor.save(update_fields=["department"])
for code in ("can_process_requests", "change_request"):
for code in ("can_process_requests",):
_grant_dept_perm(processor, data["dept1"], code)
client = auth_client(processor)
@ -270,4 +280,4 @@ def test_regular_user_cannot_set_foreign_department_on_create(
and isinstance(data["department"], dict)
and "id" in data["department"]
):
assert data["department"]["id"] != getattr(d2, "id", -1)
assert data["department"]["id"] != getattr(d2, "id", -1)

View File

@ -9,6 +9,7 @@ pytestmark = pytest.mark.django_db
def test_my_requests_filters_by_date_status_type(client, user, hr_user, login):
from requests_app.models import Request
from requests_app.enums import RequestStatus, RequestType
login(user)
@ -16,24 +17,24 @@ def test_my_requests_filters_by_date_status_type(client, user, hr_user, login):
r1 = baker.make(
Request,
employee=user,
status=Request.STATUS_PENDING,
type=Request.TYPE_VACATION,
status=RequestStatus.PENDING,
type=RequestType.VACATION,
created_at=now - timedelta(days=10),
)
r2 = baker.make(
Request,
employee=user,
status=Request.STATUS_PENDING, # сначала pending
type=Request.TYPE_SICK,
status=RequestStatus.PENDING, # сначала pending
type=RequestType.SICK_LEAVE,
created_at=now - timedelta(days=2),
)
# корректно апрувим (заполнит approver и decided_at)
r2.approve(by_user=hr_user)
url = reverse("requests_app:my_requests")
url = reverse("requests:request_list")
params = {
"status": Request.STATUS_APPROVED,
"type": Request.TYPE_SICK,
"status": RequestStatus.APPROVED,
"type": RequestType.SICK_LEAVE,
"from": (now - timedelta(days=5)).date().isoformat(),
"to": now.date().isoformat(),
}

View File

@ -11,7 +11,7 @@ def test_my_requests_shows_only_own(client, user, other_user, login):
other = baker.make(Request, employee=other_user, status=Request.STATUS_PENDING, _quantity=3)
login(user)
resp = client.get(reverse("requests_app:my_requests"))
resp = client.get(reverse("requests:request_list"))
assert resp.status_code == 200
page = resp.context["page"]
ids = {obj.pk for obj in page.object_list}
@ -21,15 +21,15 @@ def test_my_requests_shows_only_own(client, user, other_user, login):
def test_all_requests_requires_hr(client, user, hr_user, login):
# гость → редирект на логин
resp = client.get(reverse("requests_app:all_requests"))
resp = client.get(reverse("requests:request_list"))
assert resp.status_code in (302, 303)
# не-HR → тоже редирект на логин (так работает user_passes_test без raise_exception)
login(user)
resp = client.get(reverse("requests_app:all_requests"))
resp = client.get(reverse("requests:request_list"))
assert resp.status_code in (302, 303)
# HR → ок
login(hr_user)
resp = client.get(reverse("requests_app:all_requests"))
resp = client.get(reverse("requests:request_list"))
assert resp.status_code == 200