# 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: для аутентифицированных; ip: для гостей. Если REMOTE_ADDR — доверенный прокси (settings.TRUSTED_PROXIES), берём первый IP из X-Forwarded-For. Иначе — REMOTE_ADDR. Для продакшена рекомендуем real-ip middleware/ingress. """ user = getattr(request, "user", None) if user and user.is_authenticated: return f"user:{user.pk}" remote = request.META.get("REMOTE_ADDR", "anon") xff = request.META.get("HTTP_X_FORWARDED_FOR") trusted = set(getattr(settings, "TRUSTED_PROXIES", [])) if xff and remote in trusted: # стандартный формат: "client, proxy1, proxy2, ..." ip = xff.split(",")[0].strip() 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): qs = BASE_REQUEST_QS.filter(employee=request.user) qs, filters_ctx, errors = _apply_filters(qs, request, for_all=False) if errors: messages.error(request, " ".join(errors)) page = _paginate(qs, PAGINATE_MY, request.GET.get("page")) return render( request, "requests_app/my_requests.html", {"page": page, "filters": filters_ctx} ) @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_app:my_requests") 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_app:my_requests") if req.is_final and req.status != Request.STATUS_CANCELLED: messages.error( request, "Нельзя отозвать заявление, по которому уже принято решение." ) return redirect("requests_app:my_requests") if request.method == "POST": req.cancel() messages.success(request, "Заявление отменено.") return redirect("requests_app:my_requests") 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_app:all_requests") # 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))