refactor: переделать страницу деталей заявления с нуля со стилями из списка

- Полная переделка шаблона request_detail.html с единообразными стилями
- Новая структура: основная карточка (8 колонок) + боковая панель комментариев (4 колонки)
- Единообразные иконки типов заявлений (calendar-check, capsule, umbrella-fill и т.д.)
- Единообразные бейджи статуса и типа как в списке
- Информационные блоки для автора, описания, получателей, одобрившего
- Форма комментариев с правильным стилем message-field--compact
- Emoji picker с поддержкой dropdown-menu поверх контента
- AJAX обработка отправки и удаления комментариев
- Новый CSS файл request-detail.css с адаптивностью
- Добавлена функция _add_type_display() в views.py для отображения типа

Улучшения:
- Единство дизайна со страницей списка заявлений
- Правильное позиционирование emoji picker (fixed positioning)
- Sticky боковая панель с комментариями
- Форма комментария как в ленте (message-field--compact)
- Кнопки одобрения/отклонения с правильными иконками
This commit is contained in:
Игорь Надеин 2025-12-29 14:27:47 +03:00
parent dd219d4752
commit 0890d02efc
11 changed files with 1166 additions and 1367 deletions

View File

@ -10,8 +10,7 @@ from requests_app.models import Request, RequestComment
from rest_framework import serializers
from ..employees.serializers import EmployeeBriefSerializer
from .validators import (RequestApproverNotEmployeeValidator,
RequestDatesByTypeValidator)
from .validators import RequestApproverNotEmployeeValidator, RequestDatesByTypeValidator
User = get_user_model()
@ -24,24 +23,25 @@ class RecipientIDsField(serializers.ListField):
- CSV: {"recipient_ids": "1,2,3"}
- JSON list: {"recipient_ids": [1,2,3]}
"""
child = serializers.IntegerField(min_value=1)
def to_internal_value(self, data):
"""Нормализует различные форматы в list[int]."""
if not data:
return []
# Если уже список
if isinstance(data, (list, tuple)):
return super().to_internal_value(data)
# Если строка JSON
if isinstance(data, str):
data = data.strip()
# JSON массив
if data.startswith('[') and data.endswith(']'):
if data.startswith("[") and data.endswith("]"):
import json
try:
parsed = json.loads(data)
if isinstance(parsed, list):
@ -49,15 +49,15 @@ class RecipientIDsField(serializers.ListField):
except (json.JSONDecodeError, ValueError):
pass
# CSV
if ',' in data:
parts = [p.strip() for p in data.split(',') if p.strip()]
if "," in data:
parts = [p.strip() for p in data.split(",") if p.strip()]
return super().to_internal_value(parts)
# Одно число
try:
return super().to_internal_value([int(data)])
except (ValueError, TypeError):
pass
# QueryDict.getlist() возвращает list - отдаём как есть
return super().to_internal_value(data)
@ -72,17 +72,15 @@ class RequestReadSerializer(serializers.ModelSerializer):
approver = EmployeeBriefSerializer(read_only=True)
display_title = serializers.CharField(read_only=True)
is_final = serializers.BooleanField(read_only=True)
# URL для вложенного файла (аналогично file_url в DocumentReadSerializer)
attachment_url = serializers.FileField(source="attachment", read_only=True)
# Новые поля для получателей
departments = serializers.PrimaryKeyRelatedField(
many=True, read_only=True
)
departments = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
recipients = EmployeeBriefSerializer(many=True, read_only=True)
cc_users = EmployeeBriefSerializer(many=True, read_only=True)
# Вычисляемые поля
recipient_count = serializers.SerializerMethodField()
cc_count = serializers.SerializerMethodField()
@ -137,29 +135,32 @@ class RequestReadSerializer(serializers.ModelSerializer):
"is_recipient",
"comments_count",
)
def get_recipient_count(self, obj):
"""Количество получателей"""
if obj.sent_to_all_department:
# Считаем всех сотрудников выбранных отделов
count = obj.departments.aggregate(
total=models.Count(
'employeedepartment',
filter=models.Q(employeedepartment__is_active=True),
distinct=True
)
)['total'] or 0
count = (
obj.departments.aggregate(
total=models.Count(
"employeedepartment",
filter=models.Q(employeedepartment__is_active=True),
distinct=True,
)
)["total"]
or 0
)
return count
return obj.recipients.count()
def get_cc_count(self, obj):
"""Количество пользователей в копии"""
return obj.cc_users.count()
def get_is_recipient(self, obj):
"""Является ли текущий пользователь получателем"""
request = self.context.get('request')
if not request or not hasattr(request, 'user'):
request = self.context.get("request")
if not request or not hasattr(request, "user"):
return False
user = request.user
if not user or not user.is_authenticated:
@ -190,26 +191,25 @@ class RequestWriteSerializer(serializers.ModelSerializer):
approver = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), required=False, allow_null=True
)
# Новые поля для получателей
department_ids = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Department.objects.all(),
source='departments',
source="departments",
required=False,
allow_empty=True
allow_empty=True,
)
recipient_ids = RecipientIDsField(required=False, allow_empty=True)
cc_user_ids = RecipientIDsField(required=False, allow_empty=True)
sent_to_all_department = serializers.BooleanField(
required=False, default=False
)
sent_to_all_department = serializers.BooleanField(required=False, default=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Импортируем здесь, чтобы избежать circular import
from employees.models import Department
self.fields['department_ids'].queryset = Department.objects.all()
self.fields["department_ids"].queryset = Department.objects.all()
class Meta:
model = Request
@ -268,7 +268,7 @@ class RequestWriteSerializer(serializers.ModelSerializer):
# ВАЖНО: data может быть QueryDict из DRF
# dict(QueryDict) превращает все значения в списки!
# Используем _mutable для безопасного удаления полей
if hasattr(data, '_mutable'):
if hasattr(data, "_mutable"):
# Это QueryDict, делаем его mutable
data._mutable = True
data.pop("employee", None)
@ -281,41 +281,39 @@ class RequestWriteSerializer(serializers.ModelSerializer):
data.pop("employee", None)
data.pop("status", None)
data.pop("approver", None)
return super().to_internal_value(data)
def validate(self, attrs):
"""Валидация получателей и отделов"""
sent_to_all = attrs.get('sent_to_all_department', False)
recipient_ids = attrs.get('recipient_ids', [])
departments = attrs.get('departments', [])
sent_to_all = attrs.get("sent_to_all_department", False)
recipient_ids = attrs.get("recipient_ids", [])
departments = attrs.get("departments", [])
# Если sent_to_all_department=True, должны быть указаны отделы
if sent_to_all and not departments:
raise serializers.ValidationError({
'department_ids': (
'Укажите отделы для массовой рассылки'
)
})
raise serializers.ValidationError(
{"department_ids": ("Укажите отделы для массовой рассылки")}
)
# Если sent_to_all_department=False и нет отделов,
# должны быть получатели
if not sent_to_all and not departments and not recipient_ids:
raise serializers.ValidationError({
'recipient_ids': 'Укажите получателей или отделы'
})
raise serializers.ValidationError(
{"recipient_ids": "Укажите получателей или отделы"}
)
# Автор не может быть в получателях
request = self.context.get('request')
if request and hasattr(request, 'user') and request.user:
employee_id = attrs.get('employee', request.user).id
request = self.context.get("request")
if request and hasattr(request, "user") and request.user:
employee_id = attrs.get("employee", request.user).id
if employee_id in recipient_ids:
raise serializers.ValidationError({
'recipient_ids': 'Автор не может быть получателем'
})
raise serializers.ValidationError(
{"recipient_ids": "Автор не может быть получателем"}
)
return attrs
def _set_recipients(self, request_obj, recipient_ids, is_cc=False):
"""Устанавливает получателей (фильтрует только активных)"""
if not recipient_ids:
@ -324,12 +322,9 @@ class RequestWriteSerializer(serializers.ModelSerializer):
else:
request_obj.recipients.clear()
return
users = User.objects.filter(
id__in=recipient_ids,
is_active=True
)
users = User.objects.filter(id__in=recipient_ids, is_active=True)
if is_cc:
request_obj.cc_users.set(users)
else:
@ -359,12 +354,14 @@ class RequestWriteSerializer(serializers.ModelSerializer):
)
# Извлекаем данные получателей до создания
recipient_ids = validated_data.pop('recipient_ids', [])
cc_user_ids = validated_data.pop('cc_user_ids', [])
departments = validated_data.pop('departments', [])
recipient_ids = validated_data.pop("recipient_ids", [])
cc_user_ids = validated_data.pop("cc_user_ids", [])
departments = validated_data.pop("departments", [])
# Отладка: проверяем что передается
print(f"📝 [SERIALIZER] create: recipient_ids={recipient_ids}, cc_user_ids={cc_user_ids}")
print(
f"📝 [SERIALIZER] create: recipient_ids={recipient_ids}, cc_user_ids={cc_user_ids}"
)
if not self._is_power():
# обычный пользователь: запрещённые поля убираем/переписываем
@ -382,42 +379,46 @@ class RequestWriteSerializer(serializers.ModelSerializer):
# Создаем заявку
request_obj = super().create(validated_data)
print(f"✅ [SERIALIZER] Заявление #{request_obj.id} создано, устанавливаем recipients...")
print(
f"✅ [SERIALIZER] Заявление #{request_obj.id} создано, устанавливаем recipients..."
)
# Устанавливаем связи ManyToMany
if departments:
request_obj.departments.set(departments)
self._set_recipients(request_obj, recipient_ids, is_cc=False)
self._set_recipients(request_obj, cc_user_ids, is_cc=True)
# Проверяем что сохранилось
recipients_count = request_obj.recipients.count()
cc_count = request_obj.cc_users.count()
print(f"✅ [SERIALIZER] Recipients установлены: {recipients_count} основных, {cc_count} в копии")
print(
f"✅ [SERIALIZER] Recipients установлены: {recipients_count} основных, {cc_count} в копии"
)
return request_obj
def update(self, instance, validated_data: dict[str, Any]) -> Request:
"""Обновление заявки с получателями"""
# Извлекаем данные получателей
recipient_ids = validated_data.pop('recipient_ids', None)
cc_user_ids = validated_data.pop('cc_user_ids', None)
departments = validated_data.pop('departments', None)
recipient_ids = validated_data.pop("recipient_ids", None)
cc_user_ids = validated_data.pop("cc_user_ids", None)
departments = validated_data.pop("departments", None)
# Обновляем основные поля
instance = super().update(instance, validated_data)
# Обновляем связи только если переданы
if departments is not None:
instance.departments.set(departments)
if recipient_ids is not None:
self._set_recipients(instance, recipient_ids, is_cc=False)
if cc_user_ids is not None:
self._set_recipients(instance, cc_user_ids, is_cc=True)
return instance

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_front", namespace="requests")),
path("requests/", include("requests_app.urls", namespace="requests")),
path("employees/", include("employees.urls_front", namespace="employees")),
path("communications/", include("communications.urls", namespace="communications")),
path("notifications/", include("notifications.urls", namespace="notifications")),

View File

@ -11,7 +11,7 @@ Signals для автоматической генерации уведомле
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models.signals import post_save, pre_save, m2m_changed
from django.db.models.signals import m2m_changed, post_save, pre_save
from django.dispatch import receiver
from notifications.services import NotificationService
@ -36,12 +36,16 @@ def create_request_notifications(sender, instance, created, **kwargs):
request_obj = instance
# Логирование для отладки
print(f"🔔 [SIGNAL] create_request_notifications: created={created}, status={request_obj.status}, id={request_obj.id}")
print(
f"🔔 [SIGNAL] create_request_notifications: created={created}, status={request_obj.status}, id={request_obj.id}"
)
if created and request_obj.status != "draft":
# Помечаем заявление как ожидающее отправки уведомлений
# Уведомления будут отправлены после установки recipients через m2m_changed
print(f"📌 [SIGNAL] Помечаем заявление #{request_obj.id} для отправки уведомлений после установки recipients")
print(
f"📌 [SIGNAL] Помечаем заявление #{request_obj.id} для отправки уведомлений после установки recipients"
)
_pending_new_requests.add(request_obj.id)
else:
print(f"⏭️ [SIGNAL] Пропускаем: created={created}, status={request_obj.status}")
@ -74,12 +78,14 @@ def track_status_change(sender, instance, **kwargs):
def notify_on_recipients_changed(sender, instance, action, **kwargs):
"""
Отправляет уведомления о новом заявлении ПОСЛЕ установки recipients.
Срабатывает когда recipients устанавливаются через .set(), .add() и т.д.
"""
# Проверяем что это завершение операции установки recipients
if action == "post_add" and instance.id in _pending_new_requests:
print(f"🎯 [M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, отправляем уведомления")
print(
f"🎯 [M2M_SIGNAL] Recipients установлены для заявления #{instance.id}, отправляем уведомления"
)
_pending_new_requests.discard(instance.id)
notify_new_request(instance)
@ -88,7 +94,7 @@ def notify_on_recipients_changed(sender, instance, action, **kwargs):
def notify_on_cc_users_changed(sender, instance, action, **kwargs):
"""
Дополнительная проверка для cc_users.
Если recipients не были установлены, но установлены cc_users,
также отправляем уведомления.
"""
@ -96,7 +102,9 @@ def notify_on_cc_users_changed(sender, instance, action, **kwargs):
if action == "post_add" and instance.id in _pending_new_requests:
# Проверяем что recipients пусты (значит уведомления еще не отправлены)
if instance.recipients.count() == 0:
print(f"🎯 [M2M_SIGNAL] CC users установлены для заявления #{instance.id} (без recipients), отправляем уведомления")
print(
f"🎯 [M2M_SIGNAL] CC users установлены для заявления #{instance.id} (без recipients), отправляем уведомления"
)
_pending_new_requests.discard(instance.id)
notify_new_request(instance)
@ -202,7 +210,9 @@ def notify_new_request(request_obj):
print(f" Recipients в БД: {recipients_count}")
for recipient in request_obj.recipients.filter(is_active=True):
recipients_set.add(recipient)
print(f" ✅ Добавлен основной получатель: {recipient.username} (ID: {recipient.id})")
print(
f" ✅ Добавлен основной получатель: {recipient.username} (ID: {recipient.id})"
)
# 2. Копия (CC)
cc_count = request_obj.cc_users.count()
@ -259,11 +269,15 @@ def notify_new_request(request_obj):
recipients_set.update(dept_processors)
# Итоговое логирование
print(f"📊 [notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}")
print(
f"📊 [notify_new_request] Всего получателей для заявления #{request_obj.id}: {len(recipients_set)}"
)
if len(recipients_set) == 0:
print(f"⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не будут отправлены.")
print(
f"⚠️ [notify_new_request] НЕТ ПОЛУЧАТЕЛЕЙ! Уведомления не будут отправлены."
)
return
for r in recipients_set:
print(f" 👤 {r.username} (ID: {r.id})")

View File

@ -1,25 +1,28 @@
# backend/requests_app/urls.py
# urls.py
from django.urls import path
from . import views
app_name = "requests_app"
from .views import (
RequestDetailView,
RequestsView,
request_comment_add,
request_comment_delete,
request_comments,
)
app_name = "requests"
urlpatterns = [
# Коллекция
path("my/", views.my_requests, name="my_requests"),
path("all/", views.all_requests, name="all_requests"),
path("new/", views.request_create, name="request_create"),
# Элемент (по pk)
path("<int:pk>/", views.request_detail, name="request_detail"),
path("<int:pk>/process/", views.request_process, name="request_process"),
path("<int:pk>/cancel/", views.request_cancel, name="request_cancel"),
# Комментарии к заявке
path("", RequestsView.as_view(), name="request_list"),
path("<int:pk>/", RequestDetailView.as_view(), name="request_detail"),
path("comments/<int:pk>/", request_comments, name="request_comments"),
path(
"<int:pk>/comments/add/", views.request_comment_add, name="request_comment_add"
"comments/<int:pk>/add/",
request_comment_add,
name="request_comment_add",
),
path(
"<int:pk>/comments/<int:comment_id>/delete/",
views.request_comment_delete,
"comments/<int:pk>/delete/<int:comment_id>/",
request_comment_delete,
name="request_comment_delete",
),
]

View File

@ -1,28 +0,0 @@
# urls.py
from django.urls import path
from .views_front import (
RequestDetailView,
RequestsView,
request_comment_add,
request_comment_delete,
request_comments,
)
app_name = "requests"
urlpatterns = [
path("", RequestsView.as_view(), name="request_list"),
path("<int:pk>/", RequestDetailView.as_view(), name="request_detail"),
path("comments/<int:pk>/", request_comments, name="request_comments"),
path(
"comments/<int:pk>/add/",
request_comment_add,
name="request_comment_add",
),
path(
"comments/<int:pk>/delete/<int:comment_id>/",
request_comment_delete,
name="request_comment_delete",
),
]

View File

@ -1,510 +1,479 @@
# backend/requests_app/views.py
import logging
import time
from datetime import datetime
from __future__ import annotations
from django.conf import settings
from typing import Any, Dict, List, Mapping, Optional, Tuple
from api.client import get_api_client
from api.decorators import require_api_auth
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import transaction
from django.db.models import Prefetch
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_http_methods
from .constants import ALLOWED_SORTS_ALL, MAX_COMMENT_LEN, PAGINATE_ALL, PAGINATE_MY
from .forms import RequestForm, RequestStatusForm
from .models import Request, RequestComment
logger = logging.getLogger(__name__)
# ---- Опционально: django-ratelimit (лучше для прод-нагрузки)
try:
from ratelimit.decorators import ratelimit # type: ignore
RATE_LIMIT_AVAILABLE = True
except Exception:
RATE_LIMIT_AVAILABLE = False
logger.warning(
"django-ratelimit не установлен: включён упрощённый cache-rate-limit fallback. "
"Поставьте django-ratelimit + Redis для защищённой работы под нагрузкой."
)
def ratelimit(*_, **__): # no-op для отсутствующего пакета
def _noop(view):
return view
return _noop
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest, JsonResponse
from django.utils.dateparse import parse_datetime
from django.utils.decorators import method_decorator
from django.utils.timezone import localtime
from django.views.decorators.csrf import csrf_protect
from django.views.generic import TemplateView
from employees.models import Department, EmployeeDepartment # добавлены импорты
# ---------- Rate-limit cache fallback (fixed-window, атомарный incr, без локов) ----------
def _client_ident(request) -> str:
def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]:
"""Извлекает ok/json/status из ответа API-обёртки.
Args:
resp (Any): Ответ клиентской обёртки (ожидаются .ok, .json/.json(), .status).
Returns:
tuple[bool, dict[str, Any], int]: ok, payload, status.
Raises:
AttributeError: Если в ответе нет ожидаемых атрибутов.
"""
user:<id> для аутентифицированных; ip:<addr> для гостей.
Если REMOTE_ADDR — доверенный прокси (settings.TRUSTED_PROXIES), берём первый IP из X-Forwarded-For.
Иначе — REMOTE_ADDR. Для продакшена рекомендуем real-ip middleware/ingress.
"""
user = getattr(request, "user", None)
if user and user.is_authenticated:
return f"user:{user.pk}"
remote = request.META.get("REMOTE_ADDR", "anon")
xff = request.META.get("HTTP_X_FORWARDED_FOR")
trusted = set(getattr(settings, "TRUSTED_PROXIES", []))
if xff and remote in trusted:
# стандартный формат: "client, proxy1, proxy2, ..."
ip = xff.split(",")[0].strip()
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:
ip = remote
return f"ip:{ip}"
data = data_attr or {}
return ok, data, status
def cache_rate_limit_fixed_window(window_sec: int, max_hits: int, name: str):
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)
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 _fmt_date(val, fmt: str = "%d.%m.%Y") -> str | None:
"""Форматирует дату из строки 'YYYY-MM-DD' в читаемый вид"""
if not val:
return None
try:
from datetime import datetime
if isinstance(val, str) and len(val) == 10: # YYYY-MM-DD
dt = datetime.strptime(val, "%Y-%m-%d")
return dt.strftime(fmt)
return val
except Exception:
return val
# Словарь типов заявлений для отображения
REQUEST_TYPE_DISPLAY = {
"vacation": "Отпуск",
"sick_leave": "Больничный",
"day_off": "Отгул",
"transfer": "Перевод",
"dismissal": "Увольнение",
"other": "Другое",
}
def _add_type_display(data: Dict[str, Any]) -> Dict[str, Any]:
"""Добавляет get_type_display к данным заявления"""
if isinstance(data, dict) and "type" in data:
data["get_type_display"] = REQUEST_TYPE_DISPLAY.get(
data["type"], data["type"]
)
return data
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:
"""Определяет, показывать ли расширенный скоуп (вкладку «Все» и действия).
True если:
- staff / superuser;
- есть модельное право `requests_app.can_process_requests`;
- руководитель хотя бы одного отдела;
- есть департаментное право `can_process_requests` или `view_request`.
"""
Fixed-window счётчик, ключ привязан к «слоту» времени:
slot = int(now / window)
key = f"rl:{name}:{ident}:{slot}"
- Первая запись — cache.add(key, 1, timeout=window)
- Дальше — cache.incr(key, 1) (атомарно для locmem/memcached/redis)
- При превышении — 429
Без каких-либо ретраев/локов → не блокирует воркеры под нагрузкой.
"""
def _decorator(view):
def _wrapped(request, *args, **kwargs):
ident = _client_ident(request)
now = int(time.time())
slot = now // window_sec
key = f"rl:{name}:{ident}:{slot}"
# инициализация ведра
if cache.add(key, 1, timeout=window_sec):
return view(request, *args, **kwargs)
# атомарное инкрементирование (если backend поддерживает)
try:
count = cache.incr(key, 1) # type: ignore[no-untyped-call]
except Exception:
# крайней случай: backend без incr → считаем, что лимит исчерпан
logger.warning(
"Cache backend не поддерживает incr — fallback RL менее надёжен."
)
return HttpResponse(status=429)
if count > max_hits:
return HttpResponse(status=429)
return view(request, *args, **kwargs)
return _wrapped
return _decorator
def maybe_cache_rl(window_sec: int, max_hits: int, name: str):
"""Включаем cache-fallback только если нет django-ratelimit."""
def _decorator(view):
if RATE_LIMIT_AVAILABLE:
return view
return cache_rate_limit_fixed_window(window_sec, max_hits, name)(view)
return _decorator
# ---------- Константы / QuerySets ----------
VALID_STATUSES = {s for s, _ in Request.STATUS_CHOICES}
VALID_TYPES = {t for t, _ in Request.TYPE_CHOICES}
COMMENTS_PREFETCH = Prefetch(
"comments",
queryset=RequestComment.objects.select_related("author").order_by("created_at"),
)
BASE_REQUEST_QS = Request.objects.select_related("employee", "approver", "department")
DETAIL_REQUEST_QS = BASE_REQUEST_QS.prefetch_related(
COMMENTS_PREFETCH,
"recipients",
"cc_users"
)
# ---------- Права ----------
def is_hr(user) -> bool:
return user.is_staff or user.groups.filter(name="HR").exists()
def is_department_head(user) -> bool:
return user.headed_departments.exists()
def is_hr_or_head(user) -> bool:
return is_hr(user) or is_department_head(user)
def can_process_requests(user) -> bool:
"""Проверяет, может ли пользователь обрабатывать заявки вообще.
True если: HR/staff, руководитель отдела, или есть модельное право.
"""
if is_hr(user):
return True
if is_department_head(user):
if not user or getattr(user, "is_anonymous", False):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if user.has_perm("requests_app.can_process_requests"):
return True
return False
if Department.objects.filter(head_id=user.id).exists():
return True
dept_perm_codes = {"can_process_requests", "view_request"}
has_dept_perm = EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code__in=dept_perm_codes,
).exists()
return has_dept_perm
def get_allowed_request_qs(user):
"""Возвращает заявки, которые пользователь может обрабатывать.
@method_decorator([csrf_protect, require_api_auth], name="dispatch")
class RequestsView(LoginRequiredMixin, TemplateView):
"""Единая фронтовая вью, покрывающая весь API заявлений.
HR/staff — все заявки;
Руководитель отдела — заявки сотрудников своих отделов;
Получатель — заявки, где он указан как recipient.
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/` и кладёт их в контекст).
Финальная проверка в request_process дополнительно проверяет,
что пользователь является получателем конкретной заявки.
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: При невалидных входных данных для конкретных экшенов.
"""
if is_hr(user):
return BASE_REQUEST_QS
from django.db.models import Q
template_name = "requests_app/request_list_full.html"
# Заявки от сотрудников отделов, где пользователь руководитель
dept_head_qs = Q(employee__departments_links__department__head=user)
# ---------- GET ----------
# Заявки где пользователь указан как получатель
recipient_qs = Q(recipients=user)
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
"""Формирует контекст страницы списка, фильтров и комментариев.
return BASE_REQUEST_QS.filter(dept_head_qs | recipient_qs).distinct()
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().strip()
mine_raw = (request.GET.get("mine") or "").strip().lower()
# ---------- Утилиты ----------
def _parse_date(value: str | None):
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return None
if scope == "mine" or mine_raw in {"1", "true", "yes", "on"}:
params["view"] = "mine"
scope = "mine"
elif scope == "addressed":
params["addressed_to_me"] = "true"
scope = "addressed"
else:
scope = ""
type_ = (request.GET.get("type") or "").strip()
status_ = (request.GET.get("status") or "").strip()
if type_:
params["type"] = type_
if status_:
params["status"] = status_
def _apply_filters(qs, request, *, for_all: bool):
errors: list[str] = []
# NB: в API сортировки нет — параметр `ordering` не прокидываем.
status = request.GET.get("status")
if status and status in VALID_STATUSES:
qs = qs.filter(status=status)
rtype = request.GET.get("type")
if rtype and rtype in VALID_TYPES:
qs = qs.filter(type=rtype)
raw_from = request.GET.get("from", "")
raw_to = request.GET.get("to", "")
d_from = _parse_date(raw_from) if raw_from else None
d_to = _parse_date(raw_to) if raw_to else None
if raw_from and not d_from:
errors.append("Неверный формат даты 'from' (YYYY-MM-DD).")
if raw_to and not d_to:
errors.append("Неверный формат даты 'to' (YYYY-MM-DD).")
if d_from:
qs = qs.filter(created_at__date__gte=d_from)
if d_to:
qs = qs.filter(created_at__date__lte=d_to)
sort = request.GET.get("sort") if for_all else None
if for_all:
qs = (
qs.order_by(sort)
if sort in ALLOWED_SORTS_ALL
else qs.order_by("-created_at")
)
filters_ctx = {
"status": status or "",
"type": rtype or "",
"from": raw_from,
"to": raw_to,
"sort": sort or "",
}
return qs, filters_ctx, errors
def _safe_back_url(request, default_url: str) -> str:
next_url = request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
):
return next_url
return default_url
def _paginate(qs, per_page: int, page_param):
paginator = Paginator(qs, per_page)
page_param = page_param or 1
try:
return paginator.page(page_param)
except (PageNotAnInteger, EmptyPage):
return paginator.page(1)
def _detail_or_process_name(user):
return (
"requests_app:request_process"
if is_hr_or_head(user)
else "requests_app:request_detail"
)
def _get_request_for_comment_access(user, pk: int) -> Request:
if is_hr_or_head(user):
return get_object_or_404(get_allowed_request_qs(user), pk=pk)
return get_object_or_404(Request, pk=pk, employee=user)
# ---------- Пользователь ----------
@login_required
def my_requests(request):
# Редирект на новую страницу заявлений
return redirect("requests:request_list")
@login_required
def request_detail(request, pk):
req = get_object_or_404(DETAIL_REQUEST_QS, pk=pk, employee=request.user)
# Проверяем, может ли пользователь обрабатывать заявления
# (является получателем этого заявления или имеет права)
user_can_process = (
request.user in req.recipients.all() or
can_process_requests(request.user)
)
return render(request, "requests_app/request_detail.html", {
"request_obj": req,
"comments": req.comments.all(),
"can_process": user_can_process
})
@login_required
@require_http_methods(["GET", "POST"])
@ratelimit(key="user_or_ip", rate="15/m", block=True)
@maybe_cache_rl(60, 15, name="req_create")
def request_create(request):
if request.method == "POST":
form = RequestForm(request.POST, request.FILES)
if form.is_valid():
obj = form.save(commit=False)
obj.employee = request.user
obj.save()
messages.success(request, "Заявление отправлено!")
return redirect("requests:request_list")
messages.error(request, "Исправьте ошибки в форме.")
else:
form = RequestForm()
return render(request, "requests_app/request_form.html", {"form": form})
@login_required
@require_http_methods(["GET", "POST"])
def request_cancel(request, pk):
req = get_object_or_404(Request, pk=pk, employee=request.user)
if req.status == Request.STATUS_CANCELLED:
messages.info(request, "Заявление уже отменено.")
return redirect("requests:request_list")
if req.is_final and req.status != Request.STATUS_CANCELLED:
messages.error(
request, "Нельзя отозвать заявление, по которому уже принято решение."
)
return redirect("requests:request_list")
if request.method == "POST":
req.cancel()
messages.success(request, "Заявление отменено.")
return redirect("requests:request_list")
return render(
request, "requests_app/request_confirm_cancel.html", {"request_obj": req}
)
# ---------- HR / Heads ----------
@user_passes_test(is_hr_or_head)
def all_requests(request):
qs = get_allowed_request_qs(request.user)
qs, filters_ctx, errors = _apply_filters(qs, request, for_all=True)
if errors:
messages.error(request, " ".join(errors))
page = _paginate(qs, PAGINATE_ALL, request.GET.get("page"))
return render(
request,
"requests_app/all_requests.html",
{"page": page, "filters": filters_ctx},
)
@user_passes_test(can_process_requests)
@require_http_methods(["GET", "POST"])
def request_process(request, pk):
allowed_qs = get_allowed_request_qs(request.user)
if request.method == "POST":
with transaction.atomic():
req = get_object_or_404(allowed_qs.select_for_update(), pk=pk)
req.refresh_from_db()
# Проверка: не-staff должен быть получателем заявки
if (
not is_hr(request.user)
and not req.recipients.filter(id=request.user.id).exists()
):
raise PermissionDenied("Вы не являетесь получателем этой заявки.")
post = request.POST.copy()
# Фоллбек: если status отсутствует (или кнопка без JS), используем имя кнопки
if not post.get("status"):
do = post.get("do")
if do in {
Request.STATUS_APPROVED,
Request.STATUS_REJECTED,
Request.STATUS_CANCELLED,
}:
post["status"] = do
form = RequestStatusForm(
post, request.FILES, instance=req, user=request.user
)
if not form.is_valid():
messages.error(request, "Исправьте ошибки в форме.")
return render(
request,
"requests_app/request_process.html",
{
"request_obj": req,
"form": form,
"can_cancel": is_hr(
request.user
), # чтобы шаблон не показывал «Отменить» начальникам
},
)
new_status = form.cleaned_data["status"]
old_status = req.status
status_changed = new_status != old_status
need_side_effects = (
new_status == Request.STATUS_APPROVED
and (not req.approver_id or not getattr(req, "decided_at", None))
) or (
new_status in {Request.STATUS_REJECTED, Request.STATUS_CANCELLED}
and not getattr(req, "decided_at", None)
)
if status_changed or need_side_effects:
if new_status == Request.STATUS_APPROVED:
req.approve(by_user=request.user)
elif new_status == Request.STATUS_REJECTED:
req.reject(by_user=request.user)
elif new_status == Request.STATUS_CANCELLED:
req.cancel()
req.save() # <-- сохраняем гарантированно
messages.success(request, "Решение сохранено.")
# «мягкие» поля
soft_updates = []
if "comment" in form.changed_data:
req.comment = form.cleaned_data["comment"]
soft_updates.append("comment")
if "attachment" in form.changed_data:
req.attachment = form.cleaned_data["attachment"]
soft_updates.append("attachment")
if soft_updates:
req.save(update_fields=soft_updates + ["updated_at"])
if not status_changed:
messages.success(request, "Изменения сохранены.")
if not status_changed and not soft_updates:
messages.info(request, "Изменений не обнаружено.")
# Возвращаем обновленную страницу вместо редиректа
# (AJAX форма перезагружает контент на месте)
req.refresh_from_db()
form = RequestStatusForm(instance=req, user=request.user)
return render(
# --- список заявлений ---
ok, data, status = _api_unpack(api.get("v1/requests/", params=params))
if not ok:
messages.error(
request,
"requests_app/request_process.html",
{"request_obj": req, "form": form, "can_cancel": is_hr(request.user)},
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,
},
}
)
# --- прицельная подгрузка комментариев к одному заявлению ---
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
class RequestDetailView(LoginRequiredMixin, TemplateView):
"""
Детальный просмотр заявления.
Загружает одно заявление и его комментарии через API.
Может быть использовано как для отдельной страницы, так и для модального окна.
GET /requests/{pk}/:
- Загружает заявление через GET /api/v1/requests/{pk}/
- Загружает комментарии через GET /api/v1/requests/{pk}/comments/
- Проверяет права доступа (делает ошибку если нет прав)
Контекст:
- request_obj: dict с данными заявления
- comments: list[dict] с комментариями
- can_process: bool - может ли пользователь обрабатывать заявку
- error: str - описание ошибки (если она была)
- error_code: int - HTTP код ошибки
"""
template_name = "requests_app/request_detail.html"
def get_context_data(self, pk: int, **kwargs: Any) -> Dict[str, Any]:
"""
Загружает данные заявления и комментарии.
Args:
pk: ID заявления
Returns:
Dict с контекстом для шаблона
"""
ctx = super().get_context_data(**kwargs)
request = self.request
api = get_api_client(request)
# Загружаем основные данные заявления
ok, data, status = _api_unpack(api.get(f"v1/requests/{pk}/"))
if not ok:
# Обработка ошибок (404, 403 и т.д.)
error_msg = data.get("detail", f"HTTP {status}")
if isinstance(error_msg, list):
error_msg = "; ".join(str(e) for e in error_msg)
ctx.update(
{
"request_obj": None,
"comments": [],
"can_process": _user_can_process_requests(request.user),
"error": error_msg,
"error_code": status,
}
)
return ctx
request_data = data
# Загружаем комментарии
comments = []
ok_c, data_c, st_c = _api_unpack(api.get(f"v1/requests/{pk}/comments/"))
if ok_c:
results_c, _, _, _ = _parse_page_payload(data_c)
comments = _format_datetime_fields(
results_c, fields=("created_at", "updated_at")
)
# GET
req = get_object_or_404(allowed_qs.prefetch_related(COMMENTS_PREFETCH), pk=pk)
form = RequestStatusForm(instance=req, user=request.user)
return render(
request,
"requests_app/request_process.html",
{"request_obj": req, "form": form, "can_cancel": is_hr(request.user)},
)
# Форматируем даты в request_data
formatted_request = _format_datetime_fields(
[request_data], fields=("created_at", "updated_at", "decided_at")
)[0]
# Форматируем date_from и date_to отдельно
if formatted_request.get('date_from'):
formatted_request['date_from'] = _fmt_date(formatted_request['date_from'])
if formatted_request.get('date_to'):
formatted_request['date_to'] = _fmt_date(formatted_request['date_to'])
# Добавляем человекочитаемый тип
_add_type_display(formatted_request)
# ---------- Комментарии ----------
@login_required
@require_http_methods(["POST"])
@ratelimit(key="user_or_ip", rate="60/m", block=True)
@maybe_cache_rl(60, 60, name="req_comment_add")
def request_comment_add(request, pk):
req = _get_request_for_comment_access(request.user, pk)
text = request.POST.get("text", "").strip()
if not text:
messages.error(request, "Комментарий не может быть пустым.")
default_url = reverse(_detail_or_process_name(request.user), args=[pk])
return redirect(_safe_back_url(request, default_url))
if len(text) > MAX_COMMENT_LEN:
messages.error(
request, f"Комментарий слишком длинный (>{MAX_COMMENT_LEN} символов)."
ctx.update(
{
"request_obj": formatted_request,
"comments": comments,
"can_process": _user_can_process_requests(request.user),
}
)
default_url = reverse(_detail_or_process_name(request.user), args=[pk])
return redirect(_safe_back_url(request, default_url))
RequestComment.objects.create(request=req, author=request.user, text=text)
messages.success(request, "Комментарий добавлен.")
default_url = reverse(_detail_or_process_name(request.user), args=[pk])
return redirect(_safe_back_url(request, default_url))
return ctx
@login_required
@require_http_methods(["POST"])
def request_comment_delete(request, pk, comment_id):
def request_comments(request: HttpRequest, pk: int) -> JsonResponse:
"""Прокси: список комментариев (JSON) для заявки.
Требует авторизации (используем ту же обёртку клиента, что и основная вью).
"""
HR/Head: удаляет комментарии в доступных им заявках.
Пользователь: только свои комментарии в своих заявках.
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
api = get_api_client(request)
ok, data, st = _api_unpack(api.get(f"v1/requests/{pk}/comments/"))
if not ok:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
results, *_ = _parse_page_payload(data)
return JsonResponse(results, safe=False)
def request_comment_add(request: HttpRequest, pk: int) -> JsonResponse:
"""Прокси: добавление комментария.
POST: принимает form/multipart или JSON с полем text.
"""
base = RequestComment.objects.select_related("author", "request")
if is_hr_or_head(request.user):
base = base.filter(request__in=get_allowed_request_qs(request.user))
if request.method != "POST":
return JsonResponse({"detail": "Method not allowed"}, status=405)
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
text: str = ""
ct = (request.content_type or "").split(";")[0].strip().lower()
if ct == "application/json":
try:
import json as _json
payload = _json.loads(request.body or b"{}")
text = (payload.get("text") or "").strip()
except Exception:
text = ""
else:
base = base.filter(author=request.user, request__employee=request.user)
text = (request.POST.get("text") or "").strip()
comment = get_object_or_404(base, pk=comment_id, request_id=pk)
if not text:
return JsonResponse({"detail": "Empty comment"}, status=400)
if comment.author_id != request.user.id and not is_hr_or_head(request.user):
raise PermissionDenied("Недостаточно прав для удаления комментария.")
api = get_api_client(request)
ok, data, st = _api_unpack(
api.post(f"v1/requests/{pk}/comments/", json={"text": text})
)
if not ok and st != 201:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
return JsonResponse(data, status=201)
comment.delete()
messages.success(request, f"Комментарий №{comment_id} удалён.")
default_url = reverse(_detail_or_process_name(request.user), args=[pk])
return redirect(_safe_back_url(request, default_url))
def request_comment_delete(
request: HttpRequest, pk: int, comment_id: int
) -> JsonResponse:
"""Прокси: удаление комментария.
DELETE /api/v1/requests/{pk}/comments/{comment_id}/
Требует авторизации и проверяет права (только автор может удалить свой комментарий).
"""
if request.method != "POST":
return JsonResponse({"detail": "Method not allowed"}, status=405)
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
api = get_api_client(request)
ok, data, st = _api_unpack(api.delete(f"v1/requests/{pk}/comments/{comment_id}/"))
if not ok:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
return JsonResponse({"detail": "Comment deleted"}, status=204)

View File

@ -1,433 +0,0 @@
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Tuple
from api.client import get_api_client
from api.decorators import require_api_auth
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import AbstractBaseUser
from django.http import HttpRequest, JsonResponse
from django.utils.dateparse import parse_datetime
from django.utils.decorators import method_decorator
from django.utils.timezone import localtime
from django.views.decorators.csrf import csrf_protect
from django.views.generic import TemplateView
from employees.models import Department, EmployeeDepartment # добавлены импорты
def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]:
"""Извлекает ok/json/status из ответа API-обёртки.
Args:
resp (Any): Ответ клиентской обёртки (ожидаются .ok, .json/.json(), .status).
Returns:
tuple[bool, dict[str, Any], int]: ok, payload, status.
Raises:
AttributeError: Если в ответе нет ожидаемых атрибутов.
"""
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:
data = data_attr or {}
return ok, data, status
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)
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:
"""Определяет, показывать ли расширенный скоуп (вкладку «Все» и действия).
True если:
- staff / superuser;
- есть модельное право `requests_app.can_process_requests`;
- руководитель хотя бы одного отдела;
- есть департаментное право `can_process_requests` или `view_request`.
"""
if not user or getattr(user, "is_anonymous", False):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if user.has_perm("requests_app.can_process_requests"):
return True
if Department.objects.filter(head_id=user.id).exists():
return True
dept_perm_codes = {"can_process_requests", "view_request"}
has_dept_perm = EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code__in=dept_perm_codes,
).exists()
return has_dept_perm
@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_app/request_list_full.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().strip()
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"
elif scope == "addressed":
params["addressed_to_me"] = "true"
scope = "addressed"
else:
scope = ""
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,
},
}
)
# --- прицельная подгрузка комментариев к одному заявлению ---
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
class RequestDetailView(LoginRequiredMixin, TemplateView):
"""
Детальный просмотр заявления.
Загружает одно заявление и его комментарии через API.
Может быть использовано как для отдельной страницы, так и для модального окна.
GET /requests/{pk}/:
- Загружает заявление через GET /api/v1/requests/{pk}/
- Загружает комментарии через GET /api/v1/requests/{pk}/comments/
- Проверяет права доступа (делает ошибку если нет прав)
Контекст:
- request_obj: dict с данными заявления
- comments: list[dict] с комментариями
- can_process: bool - может ли пользователь обрабатывать заявку
- error: str - описание ошибки (если она была)
- error_code: int - HTTP код ошибки
"""
template_name = "requests_app/request_detail.html"
def get_context_data(self, pk: int, **kwargs: Any) -> Dict[str, Any]:
"""
Загружает данные заявления и комментарии.
Args:
pk: ID заявления
Returns:
Dict с контекстом для шаблона
"""
ctx = super().get_context_data(**kwargs)
request = self.request
api = get_api_client(request)
# Загружаем основные данные заявления
ok, data, status = _api_unpack(api.get(f"v1/requests/{pk}/"))
if not ok:
# Обработка ошибок (404, 403 и т.д.)
error_msg = data.get("detail", f"HTTP {status}")
if isinstance(error_msg, list):
error_msg = "; ".join(str(e) for e in error_msg)
ctx.update(
{
"request_obj": None,
"comments": [],
"can_process": _user_can_process_requests(request.user),
"error": error_msg,
"error_code": status,
}
)
return ctx
request_data = data
# Загружаем комментарии
comments = []
ok_c, data_c, st_c = _api_unpack(api.get(f"v1/requests/{pk}/comments/"))
if ok_c:
results_c, _, _, _ = _parse_page_payload(data_c)
comments = _format_datetime_fields(
results_c, fields=("created_at", "updated_at")
)
ctx.update(
{
"request_obj": _format_datetime_fields(
[request_data], fields=("created_at", "updated_at", "decided_at")
)[0],
"comments": comments,
"can_process": _user_can_process_requests(request.user),
}
)
return ctx
def request_comments(request: HttpRequest, pk: int) -> JsonResponse:
"""Прокси: список комментариев (JSON) для заявки.
Требует авторизации (используем ту же обёртку клиента, что и основная вью).
"""
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
api = get_api_client(request)
ok, data, st = _api_unpack(api.get(f"v1/requests/{pk}/comments/"))
if not ok:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
results, *_ = _parse_page_payload(data)
return JsonResponse(results, safe=False)
def request_comment_add(request: HttpRequest, pk: int) -> JsonResponse:
"""Прокси: добавление комментария.
POST: принимает form/multipart или JSON с полем text.
"""
if request.method != "POST":
return JsonResponse({"detail": "Method not allowed"}, status=405)
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
text: str = ""
ct = (request.content_type or "").split(";")[0].strip().lower()
if ct == "application/json":
try:
import json as _json
payload = _json.loads(request.body or b"{}")
text = (payload.get("text") or "").strip()
except Exception:
text = ""
else:
text = (request.POST.get("text") or "").strip()
if not text:
return JsonResponse({"detail": "Empty comment"}, status=400)
api = get_api_client(request)
ok, data, st = _api_unpack(
api.post(f"v1/requests/{pk}/comments/", json={"text": text})
)
if not ok and st != 201:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
return JsonResponse(data, status=201)
def request_comment_delete(
request: HttpRequest, pk: int, comment_id: int
) -> JsonResponse:
"""Прокси: удаление комментария.
DELETE /api/v1/requests/{pk}/comments/{comment_id}/
Требует авторизации и проверяет права (только автор может удалить свой комментарий).
"""
if request.method != "POST":
return JsonResponse({"detail": "Method not allowed"}, status=405)
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
api = get_api_client(request)
ok, data, st = _api_unpack(api.delete(f"v1/requests/{pk}/comments/{comment_id}/"))
if not ok:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
return JsonResponse({"detail": "Comment deleted"}, status=204)

View File

@ -0,0 +1,201 @@
/**
* request-detail.css
*
* Стили для страницы детального просмотра заявления.
* Использует общие стили из request-list.css для единообразия.
*/
/* ===== Заголовок карточки ===== */
.request-detail-card .req-detail-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
}
.request-detail-card .req-detail-row {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.request-detail-card .req-detail-icon {
flex-shrink: 0;
}
.request-detail-card .req-detail-icon .card-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bs-tertiary-bg);
border-radius: 0.5rem;
font-size: 1.5rem;
}
.request-detail-card .req-detail-content {
flex: 1;
min-width: 0;
}
.request-detail-card .req-detail-content h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
line-height: 1.3;
}
.request-detail-card .req-detail-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
/* ===== Информационные блоки ===== */
.req-info-block {
display: flex;
padding: 0.75rem 0;
border-bottom: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 50%);
}
.req-info-block:last-child {
border-bottom: none;
padding-bottom: 0;
}
.req-info-block:first-child {
padding-top: 0;
}
.req-info-label {
flex: 0 0 140px;
font-size: 0.875rem;
color: var(--bs-secondary-color);
font-weight: 500;
}
.req-info-value {
flex: 1;
min-width: 0;
font-size: 0.9375rem;
}
.req-comment-text {
padding: 0.75rem 1rem;
background: var(--bs-tertiary-bg);
border-radius: 0.375rem;
line-height: 1.5;
}
/* ===== Секция комментариев ===== */
#comments-section {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
max-height: 80vh;
overflow: visible;
}
#comments-section .card-header {
background: var(--bs-body-bg);
padding: 0.75rem 1rem;
font-size: 0.9375rem;
}
#comments-section .card-body {
flex: 1;
min-height: 100px;
overflow-y: auto;
overflow-x: visible;
}
#comments-section .list-group-item {
padding: 0.75rem 1rem;
border-left: 0;
border-right: 0;
}
#comments-section .list-group-item:first-child {
border-top: 0;
}
#comments-section .card-footer {
padding: 0.75rem 1rem;
position: relative;
z-index: 100;
overflow: visible;
flex-shrink: 0;
border-top: 1px solid var(--bs-border-color);
}
#comments-section .card-footer .message-field {
background: var(--bs-tertiary-bg);
}
#comments-section .dropdown {
position: static;
}
#comments-section .dropdown-menu {
z-index: 2000 !important;
position: fixed !important;
max-height: 350px;
overflow-y: auto;
width: auto !important;
min-width: 300px;
}
/* ===== Адаптивность ===== */
@media (max-width: 992px) {
.request-detail-card .req-detail-row {
flex-wrap: wrap;
}
.request-detail-card .req-detail-content {
flex: 1 1 calc(100% - 64px);
}
.request-detail-card .req-detail-actions {
width: 100%;
margin-top: 0.75rem;
}
#comments-section {
position: static;
}
.req-info-block {
flex-direction: column;
gap: 0.25rem;
}
.req-info-label {
flex: none;
font-size: 0.75rem;
}
}
@media (max-width: 576px) {
.request-detail-card .req-detail-header {
padding: 0.75rem 1rem;
}
.request-detail-card .req-detail-icon .card-icon {
width: 40px;
height: 40px;
font-size: 1.25rem;
}
.request-detail-card .req-detail-content h1 {
font-size: 1.1rem;
}
.request-detail-card .req-detail-actions {
flex-direction: column;
}
.request-detail-card .req-detail-actions .btn {
width: 100%;
}
}

View File

@ -14,7 +14,7 @@
<dt>Тип:</dt><dd>{{ request_obj.get_type_display }}</dd>
<dt>Период:</dt><dd>
{% if request_obj.date_from or request_obj.date_to %}
{{ request_obj.date_from|date:'d.m.Y' }}{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{{ request_obj.date_from }}{% if request_obj.date_to %} — {{ request_obj.date_to }}{% endif %}
{% else %}—{% endif %}
</dd>
<dt>Комментарий:</dt><dd>{{ request_obj.comment|default:"—" }}</dd>

View File

@ -1,234 +1,270 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Заявление #{{ request.id }}{% endblock %}
{% block title %}Заявление #{{ request_obj.id }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/card-list.css' %}">
<link rel="stylesheet" href="{% static 'css/components/message-field.css' %}">
<link rel="stylesheet" href="{% static 'css/components/request-list.css' %}">
<link rel="stylesheet" href="{% static 'css/components/request-detail.css' %}">
{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
{# ===== Хлебные крошки ===== #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="{% url 'requests:request_list' %}" class="text-decoration-none">
<i class="bi-clipboard-check me-1"></i>Заявления
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
#{{ request_obj.id }}
</li>
</ol>
</nav>
{% if error %}
<div class="card mb-3">
<div class="card-body text-center py-5">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
<h5 class="mt-3">{{ error }}</h5>
<p class="text-muted">Код ошибки: {{ error_code }}</p>
<a href="{% url 'requests:request_list' %}" class="btn btn-primary mt-3">
<i class="bi-arrow-left me-2"></i>Вернуться к списку
</a>
</div>
</div>
{% else %}
{% if error %}
{# ===== Ошибка ===== #}
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
<h5 class="mt-3">{{ error }}</h5>
<p class="text-body-secondary">Код ошибки: {{ error_code }}</p>
<a href="{% url 'requests:request_list' %}" class="btn btn-primary mt-3">
<i class="bi-arrow-left me-2"></i>Вернуться к списку
</a>
</div>
</div>
{% else %}
{# ===== Основная информация о заявлении ===== #}
<article class="card mb-3">
<header class="card-header">
<div class="card-icon" style="width:48px;height:48px;">
{% if request_obj.employee.avatar %}
<img src="{{ request_obj.employee.avatar }}" alt="Аватар">
{% else %}
<i class="bi-person"></i>
<div class="row g-3">
{# ===== Основная карточка заявления ===== #}
<div class="col-12 col-lg-8">
<article class="card request-detail-card" id="reqList">
{# Шапка как в списке #}
<div class="req-detail-header">
<div class="req-detail-row">
{# Иконка типа #}
<div class="req-detail-icon">
{% if request_obj.type == 'vacation' %}
<div class="card-icon"><i class="bi-calendar-check text-primary"></i></div>
{% elif request_obj.type == 'sick_leave' %}
<div class="card-icon"><i class="bi-capsule text-danger"></i></div>
{% elif request_obj.type == 'day_off' %}
<div class="card-icon"><i class="bi-umbrella-fill text-info"></i></div>
{% elif request_obj.type == 'transfer' %}
<div class="card-icon"><i class="bi-box-arrow-right text-info"></i></div>
{% elif request_obj.type == 'dismissal' %}
<div class="card-icon"><i class="bi-door-open text-warning"></i></div>
{% else %}
<div class="card-icon"><i class="bi-clipboard-check text-secondary"></i></div>
{% endif %}
</div>
{# Заголовок и мета #}
<div class="req-detail-content">
<div class="d-flex align-items-center flex-wrap gap-2 mb-1">
<h1 class="h5 mb-0">{{ request_obj.title|default:"Без заголовка" }}</h1>
{# Бейдж типа #}
<span class="badge text-bg-light">{{ request_obj.get_type_display }}</span>
{# Бейдж статуса #}
{% if request_obj.status == 'draft' %}
<span class="badge text-bg-info">Черновик</span>
{% elif request_obj.status == 'pending' %}
<span class="badge text-bg-warning">На рассмотрении</span>
{% elif request_obj.status == 'approved' %}
<span class="badge text-bg-success">Одобрено</span>
{% elif request_obj.status == 'rejected' %}
<span class="badge text-bg-danger">Отклонено</span>
{% elif request_obj.status == 'cancelled' %}
<span class="badge text-bg-secondary">Отменено</span>
{% endif %}
</div>
<div class="text-body-secondary small">
Создано: {{ request_obj.created_at }}
{% if request_obj.date_from or request_obj.date_to %}
<span class="mx-1">·</span>
Период: {{ request_obj.date_from|default:"—" }}{% if request_obj.date_to %} — {{ request_obj.date_to }}{% endif %}
{% endif %}
</div>
</div>
{# Кнопки действий #}
{% if can_process and request_obj.status == 'pending' %}
<div class="req-detail-actions">
<button type="button" class="btn btn-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#reqApproveModal"
data-id="{{ request_obj.id }}">
<i class="bi-hand-thumbs-up me-1"></i>Одобрить
</button>
<button type="button" class="btn btn-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#reqRejectModal"
data-id="{{ request_obj.id }}">
<i class="bi-hand-thumbs-down me-1"></i>Отклонить
</button>
</div>
{% endif %}
</div>
</div>
<div class="card-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
{% if request_obj.employee and request_obj.employee.id %}
<a href="{% url 'employees:employee_detail' request_obj.employee.id %}" class="card-title text-decoration-none">
{# Тело карточки #}
<div class="card-body">
{# Автор заявления #}
<div class="req-info-block">
<div class="req-info-label">
<i class="bi-person me-1"></i>Автор
</div>
<div class="req-info-value">
{% if request_obj.employee.id %}
<a href="{% url 'employees:employee_detail' request_obj.employee.id %}" class="text-decoration-none">
{{ request_obj.employee.full_name|default:request_obj.employee.display_name }}
</a>
{% else %}
<span class="card-title">
{{ request_obj.employee.full_name|default:request_obj.employee.display_name }}
</span>
{% endif %}
{# статус — бейдж #}
{% if request_obj.status == 'pending' %}
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">На рассмотрении</span>
{% elif request_obj.status == 'approved' %}
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle">Одобрено</span>
{% elif request_obj.status == 'rejected' %}
<span class="badge bg-danger-subtle text-danger-emphasis border border-danger-subtle">Отклонено</span>
{% elif request_obj.status == 'cancelled' %}
<span class="badge bg-dark-subtle text-dark-emphasis border border-dark-subtle">Отменено</span>
{% elif request_obj.status == 'draft' %}
<span class="badge bg-light text-body border">Черновик</span>
{% else %}
<span class="badge bg-light text-body border">Неизвестно</span>
{% endif %}
{# тип заявления #}
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">
{{ request_obj.get_type_display }}
</span>
</div>
<div class="card-subtitle mt-1">
<time datetime="{{ request_obj.created_at|date:'c' }}">{{ request_obj.created_at|date:"d.m.Y H:i" }}</time>
{% if request_obj.date_from or request_obj.date_to %}
· Период: {{ request_obj.date_from|date:'d.m.Y' }}{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{{ request_obj.employee.full_name|default:request_obj.employee.display_name }}
{% endif %}
</div>
</div>
{# Кнопки действий #}
{% if can_process and request_obj.status == 'pending' %}
<div class="d-flex gap-2 align-items-start">
<button type="button" class="btn btn-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#reqApproveModal"
data-id="{{ request_obj.id }}">
<i class="bi-check-circle me-1"></i>Одобрить
</button>
<button type="button" class="btn btn-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#reqRejectModal"
data-id="{{ request_obj.id }}">
<i class="bi-x-circle me-1"></i>Отклонить
</button>
{# Комментарий автора #}
{% if request_obj.comment %}
<div class="req-info-block">
<div class="req-info-label">
<i class="bi-chat-left-text me-1"></i>Описание
</div>
<div class="req-info-value req-comment-text">
{{ request_obj.comment|linebreaksbr }}
</div>
</div>
{% endif %}
</header>
<div class="card-body">
{# Тема заявления #}
{% if request_obj.title %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">Тема:</span>
<div class="fw-semibold">{{ request_obj.title }}</div>
</div>
{% endif %}
{# Комментарий сотрудника #}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">Комментарий:</span>
<div>{{ request_obj.comment|default:"—"|linebreaksbr }}</div>
</div>
{# Получатели #}
{% if request_obj.recipients.exists %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">
<i class="bi-person-check me-1"></i>Получатели:
</span>
<div class="d-flex flex-wrap gap-2">
{% for recipient in request_obj.recipients.all %}
{% if request_obj.recipients %}
<div class="req-info-block">
<div class="req-info-label">
<i class="bi-people me-1"></i>Получатели
</div>
<div class="req-info-value">
<div class="d-flex flex-wrap gap-1">
{% for recipient in request_obj.recipients %}
{% if recipient.id %}
<a href="{% url 'employees:employee_detail' recipient.id %}"
class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle text-decoration-none">
class="badge text-bg-primary text-decoration-none">
{{ recipient.full_name|default:recipient.display_name }}
</a>
{% else %}
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">
<span class="badge text-bg-primary">
{{ recipient.full_name|default:recipient.display_name }}
</span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Копия #}
{% if request_obj.cc_users.exists %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">
<i class="bi-people me-1"></i>В копии:
</span>
<div class="d-flex flex-wrap gap-2">
{% for cc_user in request_obj.cc_users.all %}
{% if cc_user.id %}
<a href="{% url 'employees:employee_detail' cc_user.id %}"
class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle text-decoration-none">
{{ cc_user.full_name|default:cc_user.display_name }}
{# В копии #}
{% if request_obj.cc_users %}
<div class="req-info-block">
<div class="req-info-label">
<i class="bi-people-fill me-1"></i>В копии
</div>
<div class="req-info-value">
<div class="d-flex flex-wrap gap-1">
{% for cc in request_obj.cc_users %}
{% if cc.id %}
<a href="{% url 'employees:employee_detail' cc.id %}"
class="badge text-bg-secondary text-decoration-none">
{{ cc.full_name|default:cc.display_name }}
</a>
{% else %}
<span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">
{{ cc_user.full_name|default:cc_user.display_name }}
<span class="badge text-bg-secondary">
{{ cc.full_name|default:cc.display_name }}
</span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Согласующий #}
{# Кто обработал #}
{% if request_obj.approver %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">
<i class="bi-person-check-fill me-1"></i>Согласовал:
</span>
<div class="req-info-block">
<div class="req-info-label">
<i class="bi-person-check me-1"></i>
{% if request_obj.status == 'approved' %}Одобрил{% elif request_obj.status == 'rejected' %}Отклонил{% else %}Обработал{% endif %}
</div>
<div class="req-info-value">
{% if request_obj.approver.id %}
<a href="{% url 'employees:employee_detail' request_obj.approver.id %}"
class="badge bg-success-subtle text-success-emphasis border border-success-subtle text-decoration-none">
class="badge {% if request_obj.status == 'approved' %}text-bg-success{% elif request_obj.status == 'rejected' %}text-bg-danger{% else %}text-bg-secondary{% endif %} text-decoration-none">
{{ request_obj.approver.full_name|default:request_obj.approver.display_name }}
</a>
{% else %}
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle">
<span class="badge {% if request_obj.status == 'approved' %}text-bg-success{% elif request_obj.status == 'rejected' %}text-bg-danger{% else %}text-bg-secondary{% endif %}">
{{ request_obj.approver.full_name|default:request_obj.approver.display_name }}
</span>
{% endif %}
{% if request_obj.decided_at %}
<span class="text-secondary small ms-2">
{{ request_obj.decided_at|date:"d.m.Y H:i" }}
</span>
<span class="text-body-secondary small ms-2">{{ request_obj.decided_at }}</span>
{% endif %}
</div>
</div>
{% endif %}
{# Вложение #}
{% if request_obj.attachment_url %}
<div class="mb-2">
<a class="btn btn-ghost d-inline-flex align-items-center gap-2"
href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener">
<i class="bi-paperclip"></i>
<span>Вложение</span>
<div class="req-info-block">
<div class="req-info-label">
<i class="bi-paperclip me-1"></i>Вложение
</div>
<div class="req-info-value">
<a href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener"
class="btn btn-sm btn-outline-secondary">
<i class="bi-download me-1"></i>Скачать
</a>
</div>
</div>
{% endif %}
</div>
</article>
</div>
{# ===== Комментарии ===== #}
<article class="card" id="comments">
<header class="card-header">
<div class="card-icon" style="width:44px;height:44px;">
<i class="bi-chat-dots"></i>
</div>
<div class="card-meta">
<div class="card-title">Комментарии</div>
<div class="card-subtitle">Всего: <span class="comments-count">{{ comments|length }}</span></div>
</div>
</header>
{# ===== Боковая панель с комментариями ===== #}
<div class="col-12 col-lg-4">
<article class="card" id="comments-section">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi-chat-dots"></i>
<span class="fw-semibold">Комментарии</span>
<span class="badge text-bg-secondary ms-auto">{{ comments|length }}</span>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if comments %}
<div class="d-flex flex-column gap-2 mb-3">
<div class="list-group list-group-flush">
{% for c in comments %}
<div class="card-list-item">
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<strong>{{ c.author.full_name|default:c.author.display_name }}</strong>
<span class="text-secondary mx-1">·</span>
<time class="text-muted small">{{ c.created_at|date:"d.m.Y H:i" }}</time>
<strong class="small">{{ c.author.full_name|default:c.author.display_name }}</strong>
<span class="text-body-secondary small ms-1">{{ c.created_at }}</span>
</div>
{# удалить — только автору комментария #}
{# Удаление — только автору #}
{% if c.author.id == request.user.id %}
<form method="post" action="{% url 'requests:request_comment_delete' request_obj.id c.id %}" class="d-inline">
{% csrf_token %}
<button class="btn btn-sm btn-ghost text-danger" type="submit" title="Удалить">
<i class="bi-trash"></i>
</button>
</form>
<button class="btn btn-link btn-sm text-danger p-0 delete-comment-btn"
type="button" title="Удалить"
data-request-id="{{ request_obj.id }}"
data-comment-id="{{ c.id }}">
<i class="bi-trash"></i>
</button>
{% endif %}
</div>
<div class="small">{{ c.text|linebreaksbr }}</div>
@ -236,19 +272,22 @@
{% endfor %}
</div>
{% else %}
<div class="text-secondary small text-center py-3">Комментариев пока нет.</div>
<div class="text-body-secondary small text-center py-4">
Комментариев пока нет
</div>
{% endif %}
</div>
{# Форма добавления комментария #}
<form class="mt-3 request-comment-form" method="post" action="{% url 'requests:request_comment_add' request_obj.id %}">
{# Форма добавления комментария #}
<div class="card-footer">
<form method="post" action="{% url 'requests:request_comment_add' request_obj.id %}"
class="request-comment-form">
{% csrf_token %}
<div class="message-field message-field--compact">
<!-- Кнопка выбора эмодзи -->
{# Кнопка эмодзи #}
<div class="dropdown message-emoji">
<button type="button" class="btn btn-ghost btn-emoji message-icon-btn"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Вставить смайлик">
data-bs-toggle="dropdown" aria-expanded="false" title="Вставить смайлик">
<i class="bi-emoji-smile"></i>
</button>
<div class="dropdown-menu dropdown-menu-start message-emoji-menu">
@ -256,177 +295,49 @@
</div>
</div>
<textarea
name="text"
class="message-input"
placeholder="Добавить комментарий..."
rows="2"
required
></textarea>
{# Поле ввода #}
<textarea name="text" class="form-control message-input" rows="1"
placeholder="Добавить комментарий..." required></textarea>
<button type="submit" class="btn btn-primary message-send-btn">
<i class="bi-send-fill"></i>
{# Кнопка отправки #}
<button type="submit" class="btn btn-primary message-send" title="Отправить">
<i class="bi-send"></i>
</button>
</div>
</form>
</div>
</article>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}
{# ===== Модальные окна ===== #}
{% block extra_js %}
<script type="module">
import RequestCrudHandler from "{% static 'js/components/requestCrudHandler.js' %}";
import { initEmojiPicker } from "{% static 'js/components/emojiPicker.js' %}";
import { initTextareaAutoExpand } from "{% static 'js/components/textareaAutoExpand.js' %}";
// Инициализация обработчика CRUD
const crudHandler = new RequestCrudHandler();
// Обработка формы одобрения
const approveForm = document.getElementById('reqApproveForm');
const approveModal = document.getElementById('reqApproveModal');
if (approveModal && approveForm) {
approveModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const requestId = button.getAttribute('data-id');
document.getElementById('approveId').value = requestId;
});
approveForm.addEventListener('submit', async (e) => {
e.preventDefault();
const requestId = document.getElementById('approveId').value;
await crudHandler.handleApprove(requestId);
// Перезагрузка после закрытия модала
setTimeout(() => window.location.reload(), 500);
});
}
// Обработка формы отклонения
const rejectForm = document.getElementById('reqRejectForm');
const rejectModal = document.getElementById('reqRejectModal');
if (rejectModal && rejectForm) {
rejectModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const requestId = button.getAttribute('data-id');
document.getElementById('rejectId').value = requestId;
});
rejectForm.addEventListener('submit', async (e) => {
e.preventDefault();
const requestId = document.getElementById('rejectId').value;
await crudHandler.handleReject(requestId);
// Перезагрузка после закрытия модала
setTimeout(() => window.location.reload(), 500);
});
}
// Инициализация emoji picker для формы комментария
const commentForm = document.querySelector('.request-comment-form');
if (commentForm) {
const textarea = commentForm.querySelector('textarea[name="text"]');
const emojiButton = commentForm.querySelector('[data-bs-toggle="dropdown"]');
const emojiPicker = commentForm.querySelector('emoji-picker');
if (textarea && emojiButton && emojiPicker) {
initEmojiPicker(emojiButton, emojiPicker, textarea);
initTextareaAutoExpand(textarea);
}
// Обработка отправки формы
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
const text = textarea.value.trim();
if (!text) {
return;
}
const submitBtn = commentForm.querySelector('button[type="submit"]');
const originalBtnContent = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const formData = new FormData(commentForm);
const response = await fetch(commentForm.action, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Перезагрузить страницу для отображения нового комментария
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('Ошибка при добавлении комментария: ' + error.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnContent;
}
});
}
// Обработка форм удаления комментариев
document.querySelectorAll('form[action*="request_comment_delete"]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!confirm('Вы уверены, что хотите удалить этот комментарий?')) {
return;
}
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Перезагрузить страницу
window.location.reload();
} catch (error) {
console.error('Error:', error);
alert('Ошибка при удалении комментария: ' + error.message);
}
});
});
</script>
{% endblock %}
{# Модальные окна для действий #}
<!-- Approve -->
<div class="modal fade" id="reqApproveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="reqApproveForm">
{% csrf_token %}
<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 class="modal-header border-0">
<div class="d-flex align-items-center gap-2">
<i class="bi-hand-thumbs-up text-success fs-4"></i>
<h5 class="modal-title mb-0">Одобрить заявление?</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-footer">
<div class="modal-body pt-0">
<p class="text-body-secondary mb-0">
Заявление #{{ request_obj.id }} будет одобрено. Автор получит уведомление.
</p>
</div>
<div class="modal-footer border-0">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-success" type="submit">Одобрить</button>
<button class="btn btn-success" type="submit">
<i class="bi-check-lg me-1"></i>Одобрить
</button>
</div>
</form>
</div>
@ -438,13 +349,174 @@
<form class="modal-content" id="reqRejectForm">
{% csrf_token %}
<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 class="modal-header border-0">
<div class="d-flex align-items-center gap-2">
<i class="bi-hand-thumbs-down text-danger fs-4"></i>
<h5 class="modal-title mb-0">Отклонить заявление?</h5>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-footer">
<button class="btn btn-danger" type="submit">Отклонить</button>
<div class="modal-body pt-0">
<p class="text-body-secondary mb-0">
Заявление #{{ request_obj.id }} будет отклонено. Автор получит уведомление.
</p>
</div>
<div class="modal-footer border-0">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-danger" type="submit">
<i class="bi-x-lg me-1"></i>Отклонить
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script>
(function() {
const requestId = {{ request_obj.id|default:0 }};
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
// ===== Обработка Approve =====
const approveForm = document.getElementById('reqApproveForm');
const approveModal = document.getElementById('reqApproveModal');
if (approveModal && approveForm) {
approveModal.addEventListener('show.bs.modal', function(e) {
const btn = e.relatedTarget;
document.getElementById('approveId').value = btn?.dataset.id || requestId;
});
approveForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('approveId').value;
try {
const res = await fetch(`/api/v1/requests/${id}/approve/`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error('Ошибка');
window.location.reload();
} catch (err) {
alert('Ошибка при одобрении: ' + err.message);
}
});
}
// ===== Обработка Reject =====
const rejectForm = document.getElementById('reqRejectForm');
const rejectModal = document.getElementById('reqRejectModal');
if (rejectModal && rejectForm) {
rejectModal.addEventListener('show.bs.modal', function(e) {
const btn = e.relatedTarget;
document.getElementById('rejectId').value = btn?.dataset.id || requestId;
});
rejectForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = document.getElementById('rejectId').value;
try {
const res = await fetch(`/api/v1/requests/${id}/reject/`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error('Ошибка');
window.location.reload();
} catch (err) {
alert('Ошибка при отклонении: ' + err.message);
}
});
}
// ===== Обработка комментариев =====
const commentForm = document.querySelector('.request-comment-form');
if (commentForm) {
const textarea = commentForm.querySelector('textarea[name="text"]');
const emojiPicker = commentForm.querySelector('emoji-picker');
const submitBtn = commentForm.querySelector('button[type="submit"]');
// Emoji picker
if (textarea && emojiPicker) {
emojiPicker.addEventListener('emoji-click', (e) => {
textarea.value += e.detail.unicode;
textarea.focus();
});
}
// AJAX отправка комментария
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
const text = textarea.value.trim();
if (!text) return;
submitBtn.disabled = true;
const originalHTML = submitBtn.innerHTML;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const formData = new FormData();
formData.append('text', text);
const res = await fetch(commentForm.action, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: formData
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
// Успех — очищаем форму и перезагружаем
textarea.value = '';
window.location.reload();
} catch (err) {
alert('Ошибка: ' + err.message);
submitBtn.disabled = false;
submitBtn.innerHTML = originalHTML;
}
});
}
// ===== Delete comments =====
document.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.delete-comment-btn');
if (!deleteBtn) return;
if (!confirm('Удалить комментарий?')) return;
const requestId = deleteBtn.dataset.requestId;
const commentId = deleteBtn.dataset.commentId;
try {
const res = await fetch(
`/requests/comments/${requestId}/delete/${commentId}/`,
{
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
}
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
window.location.reload();
} catch (err) {
alert('Ошибка: ' + err.message);
}
});
})();
</script>
{% endblock %}

View File

@ -62,15 +62,15 @@
</div>
<div class="card-subtitle mt-1">
{# Дата создания — ISO в datetime для машин, формат для людей #}
<time datetime="{{ request_obj.created_at|date:'c' }}">
{{ request_obj.created_at|date:"d.m.Y H:i" }}
{# Дата создания #}
<time datetime="">
{{ request_obj.created_at }}
</time>
{# Период, если указан #}
{% if request_obj.date_from or request_obj.date_to %}
· Период:
{{ request_obj.date_from|date:'d.m.Y' }}
{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{{ request_obj.date_from }}
{% if request_obj.date_to %} — {{ request_obj.date_to }}{% endif %}
{% endif %}
</div>
</div>
@ -171,7 +171,7 @@
{{ c.author.get_full_name|default:c.author.username }}
</span>
<span class="text-secondary">·</span>
<time class="text-muted">{{ c.created_at|date:"d.m.Y H:i" }}</time>
<time class="text-muted">{{ c.created_at }}</time>
</div>
<form method="post"