From 6e6d5755befb31b4609533dd97b5c84dbfa037ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 29 Dec 2025 14:27:47 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D1=82=D1=8C=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=83=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=20=D0=BD=D1=83=D0=BB=D1=8F=20=D1=81=D0=BE=20=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=B8=D0=B7=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Полная переделка шаблона 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) - Кнопки одобрения/отклонения с правильными иконками --- backend/api/v1/requests_app/serializers.py | 173 ++-- backend/eusrr_backend/urls.py | 2 +- backend/requests_app/notification_signals.py | 36 +- backend/requests_app/urls.py | 33 +- backend/requests_app/urls_front.py | 28 - backend/requests_app/views.py | 905 +++++++++--------- backend/requests_app/views_front.py | 433 --------- .../static/css/components/request-detail.css | 201 ++++ .../requests_app/request_confirm_cancel.html | 2 +- .../requests_app/request_detail.html | 708 ++++++++------ .../requests_app/request_process.html | 12 +- 11 files changed, 1166 insertions(+), 1367 deletions(-) delete mode 100644 backend/requests_app/urls_front.py delete mode 100644 backend/requests_app/views_front.py create mode 100644 backend/static/css/components/request-detail.css diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index 56a55fe3..c94cc33f 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -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 diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 160671d5..4322258d 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -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")), diff --git a/backend/requests_app/notification_signals.py b/backend/requests_app/notification_signals.py index 37cb91fd..7f6f6423 100644 --- a/backend/requests_app/notification_signals.py +++ b/backend/requests_app/notification_signals.py @@ -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})") diff --git a/backend/requests_app/urls.py b/backend/requests_app/urls.py index cbb648ca..fd6f5aae 100644 --- a/backend/requests_app/urls.py +++ b/backend/requests_app/urls.py @@ -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("/", views.request_detail, name="request_detail"), - path("/process/", views.request_process, name="request_process"), - path("/cancel/", views.request_cancel, name="request_cancel"), - # Комментарии к заявке + path("", RequestsView.as_view(), name="request_list"), + path("/", RequestDetailView.as_view(), name="request_detail"), + path("comments//", request_comments, name="request_comments"), path( - "/comments/add/", views.request_comment_add, name="request_comment_add" + "comments//add/", + request_comment_add, + name="request_comment_add", ), path( - "/comments//delete/", - views.request_comment_delete, + "comments//delete//", + request_comment_delete, name="request_comment_delete", ), ] diff --git a/backend/requests_app/urls_front.py b/backend/requests_app/urls_front.py deleted file mode 100644 index 9ab607b4..00000000 --- a/backend/requests_app/urls_front.py +++ /dev/null @@ -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("/", RequestDetailView.as_view(), name="request_detail"), - path("comments//", request_comments, name="request_comments"), - path( - "comments//add/", - request_comment_add, - name="request_comment_add", - ), - path( - "comments//delete//", - request_comment_delete, - name="request_comment_delete", - ), -] diff --git a/backend/requests_app/views.py b/backend/requests_app/views.py index 3a0a7c00..a56d4001 100644 --- a/backend/requests_app/views.py +++ b/backend/requests_app/views.py @@ -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: для аутентифицированных; ip: для гостей. - Если 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=` + (делает `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) diff --git a/backend/requests_app/views_front.py b/backend/requests_app/views_front.py deleted file mode 100644 index 98d40aeb..00000000 --- a/backend/requests_app/views_front.py +++ /dev/null @@ -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=` - (делает `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) diff --git a/backend/static/css/components/request-detail.css b/backend/static/css/components/request-detail.css new file mode 100644 index 00000000..31553656 --- /dev/null +++ b/backend/static/css/components/request-detail.css @@ -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%; + } +} diff --git a/backend/templates/requests_app/request_confirm_cancel.html b/backend/templates/requests_app/request_confirm_cancel.html index e8eb4022..dff6e5ef 100644 --- a/backend/templates/requests_app/request_confirm_cancel.html +++ b/backend/templates/requests_app/request_confirm_cancel.html @@ -14,7 +14,7 @@
Тип:
{{ request_obj.get_type_display }}
Период:
{% 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 %}
Комментарий:
{{ request_obj.comment|default:"—" }}
diff --git a/backend/templates/requests_app/request_detail.html b/backend/templates/requests_app/request_detail.html index 6ea679bf..fed81781 100644 --- a/backend/templates/requests_app/request_detail.html +++ b/backend/templates/requests_app/request_detail.html @@ -1,234 +1,270 @@ {% extends "base.html" %} {% load static %} -{% block title %}Заявление #{{ request.id }}{% endblock %} +{% block title %}Заявление #{{ request_obj.id }}{% endblock %} {% block extra_css %} - - + + {% endblock %} {% block content %}
-
-
+ {# ===== Хлебные крошки ===== #} + - {% if error %} -
-
- -
{{ error }}
-

Код ошибки: {{ error_code }}

- - Вернуться к списку - -
-
- {% else %} + {% if error %} + {# ===== Ошибка ===== #} +
+
+ +
{{ error }}
+

Код ошибки: {{ error_code }}

+ + Вернуться к списку + +
+
+ {% else %} - {# ===== Основная информация о заявлении ===== #} -
-
-
- {% if request_obj.employee.avatar %} - Аватар - {% else %} - +
+ {# ===== Основная карточка заявления ===== #} +
+
+ {# Шапка как в списке #} +
+
+ {# Иконка типа #} +
+ {% if request_obj.type == 'vacation' %} +
+ {% elif request_obj.type == 'sick_leave' %} +
+ {% elif request_obj.type == 'day_off' %} +
+ {% elif request_obj.type == 'transfer' %} +
+ {% elif request_obj.type == 'dismissal' %} +
+ {% else %} +
+ {% endif %} +
+ + {# Заголовок и мета #} +
+
+

{{ request_obj.title|default:"Без заголовка" }}

+ + {# Бейдж типа #} + {{ request_obj.get_type_display }} + + {# Бейдж статуса #} + {% if request_obj.status == 'draft' %} + Черновик + {% elif request_obj.status == 'pending' %} + На рассмотрении + {% elif request_obj.status == 'approved' %} + Одобрено + {% elif request_obj.status == 'rejected' %} + Отклонено + {% elif request_obj.status == 'cancelled' %} + Отменено + {% endif %} +
+ +
+ Создано: {{ request_obj.created_at }} + {% if request_obj.date_from or request_obj.date_to %} + · + Период: {{ request_obj.date_from|default:"—" }}{% if request_obj.date_to %} — {{ request_obj.date_to }}{% endif %} + {% endif %} +
+
+ + {# Кнопки действий #} + {% if can_process and request_obj.status == 'pending' %} +
+ + +
{% endif %}
+
-
-
- {% if request_obj.employee and request_obj.employee.id %} - + {# Тело карточки #} +
+ {# Автор заявления #} +
+
+ Автор +
+
+ {% if request_obj.employee.id %} + {{ request_obj.employee.full_name|default:request_obj.employee.display_name }} {% else %} - - {{ request_obj.employee.full_name|default:request_obj.employee.display_name }} - - {% endif %} - - {# статус — бейдж #} - {% if request_obj.status == 'pending' %} - На рассмотрении - {% elif request_obj.status == 'approved' %} - Одобрено - {% elif request_obj.status == 'rejected' %} - Отклонено - {% elif request_obj.status == 'cancelled' %} - Отменено - {% elif request_obj.status == 'draft' %} - Черновик - {% else %} - Неизвестно - {% endif %} - - {# тип заявления #} - - {{ request_obj.get_type_display }} - -
- -
- - {% 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 %}
- {# Кнопки действий #} - {% if can_process and request_obj.status == 'pending' %} -
- - + {# Комментарий автора #} + {% if request_obj.comment %} +
+
+ Описание +
+
+ {{ request_obj.comment|linebreaksbr }} +
{% endif %} -
- -
- {# Тема заявления #} - {% if request_obj.title %} -
- Тема: -
{{ request_obj.title }}
-
- {% endif %} - - {# Комментарий сотрудника #} -
- Комментарий: -
{{ request_obj.comment|default:"—"|linebreaksbr }}
-
- {# Получатели #} - {% if request_obj.recipients.exists %} -
- - Получатели: - -
- {% for recipient in request_obj.recipients.all %} + {% if request_obj.recipients %} +
+
+ Получатели +
+
+
+ {% for recipient in request_obj.recipients %} {% if recipient.id %} + class="badge text-bg-primary text-decoration-none"> {{ recipient.full_name|default:recipient.display_name }} {% else %} - + {{ recipient.full_name|default:recipient.display_name }} {% endif %} {% endfor %}
+
{% endif %} - {# Копия #} - {% if request_obj.cc_users.exists %} -
- - В копии: - -
- {% for cc_user in request_obj.cc_users.all %} - {% if cc_user.id %} - - {{ cc_user.full_name|default:cc_user.display_name }} + {# В копии #} + {% if request_obj.cc_users %} +
+
+ В копии +
+
+
+ {% for cc in request_obj.cc_users %} + {% if cc.id %} + + {{ cc.full_name|default:cc.display_name }} {% else %} - - {{ cc_user.full_name|default:cc_user.display_name }} + + {{ cc.full_name|default:cc.display_name }} {% endif %} {% endfor %}
+
{% endif %} - {# Согласующий #} + {# Кто обработал #} {% if request_obj.approver %} -
- - Согласовал: - +
+
+ + {% if request_obj.status == 'approved' %}Одобрил{% elif request_obj.status == 'rejected' %}Отклонил{% else %}Обработал{% endif %} +
+
{% if request_obj.approver.id %} + 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 }} {% else %} - + {{ request_obj.approver.full_name|default:request_obj.approver.display_name }} {% endif %} {% if request_obj.decided_at %} - - {{ request_obj.decided_at|date:"d.m.Y H:i" }} - + {{ request_obj.decided_at }} {% endif %}
+
{% endif %} {# Вложение #} {% if request_obj.attachment_url %} -
+
- {# ===== Комментарии ===== #} -
-
-
- -
-
-
Комментарии
-
Всего: {{ comments|length }}
-
-
+ {# ===== Боковая панель с комментариями ===== #} +
+
+
+ + Комментарии + {{ comments|length }} +
-
+
{% if comments %} -
+
{% for c in comments %} -
+
- {{ c.author.full_name|default:c.author.display_name }} - · - + {{ c.author.full_name|default:c.author.display_name }} + {{ c.created_at }}
- - {# удалить — только автору комментария #} + + {# Удаление — только автору #} {% if c.author.id == request.user.id %} -
- {% csrf_token %} - -
+ {% endif %}
{{ c.text|linebreaksbr }}
@@ -236,19 +272,22 @@ {% endfor %}
{% else %} -
Комментариев пока нет.
+
+ Комментариев пока нет +
{% endif %} +
- {# Форма добавления комментария #} -
+ {# Форма добавления комментария #} +
- - {% endif %}
+ + {% endif %}
-{% endblock %} +{# ===== Модальные окна ===== #} -{% block extra_js %} - -{% endblock %} - -{# Модальные окна для действий #}