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.decorators import method_decorator from django.views.decorators.csrf import csrf_protect from django.views.generic import TemplateView from django.utils.dateparse import parse_datetime from django.utils.timezone import localtime from employees.models import Department, EmployeeDepartment # добавлены импорты def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]: """Извлекает ok/json/status из ответа API-обёртки. Args: resp (Any): Ответ клиентской обёртки (ожидаются .ok, .json/.json(), .status). Returns: tuple[bool, dict[str, Any], int]: ok, payload, status. Raises: AttributeError: Если в ответе нет ожидаемых атрибутов. """ ok: bool = getattr(resp, "ok") status: int = getattr(resp, "status") data_attr = getattr(resp, "json", None) data: Dict[str, Any] if callable(data_attr): data = data_attr() or {} else: data = data_attr or {} return ok, data, status def _err_msg(payload: Any, fallback: str = "") -> str: # dict: detail / non_field_errors / первое поле со строкой if isinstance(payload, dict): if isinstance(payload.get("detail"), (str, list)): d = payload["detail"] return d if isinstance(d, str) else "; ".join(map(str, d)) if isinstance(payload.get("non_field_errors"), list): return "; ".join(map(str, payload["non_field_errors"])) # первая строка из значений полей for v in payload.values(): if isinstance(v, list) and v and isinstance(v[0], str): return "; ".join(map(str, v)) if isinstance(v, str): return v # list: склеить if isinstance(payload, list): return "; ".join(map(str, payload)) # str или что-то иное return str(payload or fallback) def _fmt_dt(val, fmt: str = "%d.%m.%Y %H:%M") -> str | None: """Превращает ISO-строку/дату в локализованную строку. Если не получилось — возвращает исходное значение.""" if not val: return None # если уже datetime — ок, если строка — парсим dt = parse_datetime(val) if isinstance(val, str) else val if dt is None: return val # на всякий случай оставим как есть try: return localtime(dt).strftime(fmt) # уважим таймзону пользователя except Exception: return val def _format_datetime_fields( items: List[Any], fields: tuple[str, ...] = ("created_at", "updated_at", "decided_at"), ) -> List[Any]: out: List[Any] = [] for it in items: if isinstance(it, Mapping): d = dict(it) # копия, не мутируем исходник for f in fields: if f in d: d[f] = _fmt_dt(d.get(f)) out.append(d) else: out.append(it) return out def _parse_page_payload( payload: Any, ) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str], Optional[int]]: """Нормализует ответ API к (results, next, previous, count). Поддерживает: - DRF-страницу {"count","next","previous","results":[...]} - непагинированный список [...].""" # 1) Непагинированный список if isinstance(payload, list): results = _format_datetime_fields(payload) return results, None, None, len(results) # 2) Словарь (пагинированный или нет) if isinstance(payload, Mapping): if "results" in payload: raw = payload.get("results") or [] results = _format_datetime_fields(list(raw)) return results, payload.get("next"), payload.get("previous"), payload.get("count") # словарь, но не страница — пустой список return [], None, None, None # 3) Непонятный формат return [], None, None, None def _user_can_process_requests(user: AbstractBaseUser) -> bool: """Определяет, показывать ли расширенный скоуп (вкладку «Все» и действия). True если: - staff / superuser; - есть модельное право `requests_app.can_process_requests`; - руководитель хотя бы одного отдела; - есть департаментное право `can_process_requests` или `view_request`. """ if not user or getattr(user, "is_anonymous", False): return False if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): return True if user.has_perm("requests_app.can_process_requests"): return True if Department.objects.filter(head_id=user.id).exists(): return True dept_perm_codes = {"can_process_requests", "view_request"} has_dept_perm = EmployeeDepartment.objects.filter( employee_id=user.id, is_active=True, role__scoped_permissions__code__in=dept_perm_codes, ).exists() return has_dept_perm @method_decorator([csrf_protect, require_api_auth], name="dispatch") class RequestsView(LoginRequiredMixin, TemplateView): """Единая фронтовая вью, покрывающая весь API заявлений. GET: - Загружает список заявлений через API (`GET /api/v1/requests/`). - Понимает фильтры API: `view=mine` ИЛИ `mine=1|true|yes|on`, `type`, `status`. - Поддерживает просмотр комментариев к одному заявлению через `?comments_for=` (делает `GET /api/v1/requests/{id}/comments/` и кладёт их в контекст). POST (по `_action`): - create → `POST /api/v1/requests/` (multipart при наличии файла). - update → `PATCH /api/v1/requests/{id}/` (multipart при наличии файла). - delete → `DELETE /api/v1/requests/{id}/`. - approve → `POST /api/v1/requests/{id}/approve/`. - reject → `POST /api/v1/requests/{id}/reject/`. - cancel → `POST /api/v1/requests/{id}/cancel/`. - comment → `POST /api/v1/requests/{id}/comments/` (JSON). Контекст шаблона: requests, next_url, prev_url, count, scope, can_process, filters (dict с применёнными фильтрами), comments_for (int|None), comments (list[dict]) — при запросе комментариев. Raises: ValueError: При невалидных входных данных для конкретных экшенов. """ template_name = "requests_app/request_list_full.html" # ---------- GET ---------- def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: """Формирует контекст страницы списка, фильтров и комментариев. Returns: Dict[str, Any]: Контекст шаблона. """ ctx = super().get_context_data(**kwargs) request = self.request api = get_api_client(request) # --- фильтры API --- params: Dict[str, Any] = {} scope = (request.GET.get("view") or "").lower() 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" else: scope = "all" 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 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)