Игорь Надеин ff41eb62b0 refactor: удалены неиспользуемые файлы заявлений
- Удалены шаблоны: my_requests.html, all_requests.html (старые)
- Удален JS компонент: requestListHandler.js (неиспользуемый)
- Обновлены views: my_requests() и all_requests() теперь редиректят на requests:request_list
- Обновлены все редиректы с requests_app:my_requests на requests:request_list
- Основная страница заявлений: /requests/ (request_list_full.html)
2025-12-05 14:33:15 +03:00

454 lines
16 KiB
Python

# backend/requests_app/views.py
import logging
import time
from datetime import datetime
from django.conf import settings
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
# ---------- Rate-limit cache fallback (fixed-window, атомарный incr, без локов) ----------
def _client_ident(request) -> str:
"""
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()
else:
ip = remote
return f"ip:{ip}"
def cache_rate_limit_fixed_window(window_sec: int, max_hits: int, name: str):
"""
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)
# ---------- Права ----------
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 get_allowed_request_qs(user):
"""HR — все; руководитель — заявки сотрудников своих отделов."""
if is_hr(user):
return BASE_REQUEST_QS
return BASE_REQUEST_QS.filter(
employee__departments_links__department__head=user
).distinct()
# ---------- Утилиты ----------
def _parse_date(value: str | None):
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return None
def _apply_filters(qs, request, *, for_all: bool):
errors: list[str] = []
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)
return render(request, "requests_app/request_detail.html", {"request_obj": req})
@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(is_hr_or_head)
@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()
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, "Изменений не обнаружено.")
return redirect("requests:request_list")
# 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)},
)
# ---------- Комментарии ----------
@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} символов)."
)
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))
@login_required
@require_http_methods(["POST"])
def request_comment_delete(request, pk, comment_id):
"""
HR/Head: удаляет комментарии в доступных им заявках.
Пользователь: только свои комментарии в своих заявках.
"""
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))
else:
base = base.filter(author=request.user, request__employee=request.user)
comment = get_object_or_404(base, pk=comment_id, request_id=pk)
if comment.author_id != request.user.id and not is_hr_or_head(request.user):
raise PermissionDenied("Недостаточно прав для удаления комментария.")
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))