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:
parent
dd219d4752
commit
0890d02efc
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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")),
|
||||
|
||||
@ -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})")
|
||||
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
201
backend/static/css/components/request-detail.css
Normal file
201
backend/static/css/components/request-detail.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user