Corp/backend/requests_app/views_front.py
Игорь Надеин 52f2aed5fd From: You <you@example.com>
Date: Mon, 22 Sep 2025 10:30:00 +0300
Subject: [requests] Drawer «Рассмотреть», корректный чекбокс «Черновик», дружелюбные даты,
 robust-обработка ошибок reject, и глобальный бейдж непрочитанных чатов

- Список заявлений:
  - вместо россыпи кнопок добавлен аккуратный выезжающий блок действий (collapse drawer)
    по клику «Рассмотреть»; компактные нейтральные стили, мягкая анимация.
  - в модале «Изменить» добавлена галочка «Сохранить как черновик» с правильной
    семантикой: hidden `_save_as=submit` по умолчанию + checkbox `_save_as=draft`.
    Теперь снятие галочки действительно снимает draft.
  - улучшены стили и структура разметки.

- Даты:
  - на фронт-обёртке нормализуем datetime-поля (created_at/updated_at/decided_at)
    в человекочитаемый вид, чтобы не светить сырые ISO 8601.

- Обработка ошибок:
  - фикс падения при попытке отклонить уже одобренную заявку: ответ 400 у DRF
    может приходить списком ошибок; теперь сообщение извлекается безопасно.

- Чаты:
  - синхронизировали название ключа контекста с проектом (`chat_unread_total`);
  - бейдж «Чаты» обновляется глобальным событием `chats:unread-total`, даже если
    список чатов не открыт (fallback — суммирование счётчиков на странице).

Примечание: пути к шаблонам могут отличаться в вашем проекте — ниже даны
типовые варианты (`backend/requests_app/templates/...`). Если у вас другой
layout, внесите изменения в соответствующие файлы шаблонов.
2025-09-22 13:01:20 +03:00

547 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
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
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:
"""Проверяет, может ли пользователь обрабатывать заявки (approve/reject).
Args:
user (AbstractBaseUser): Пользователь.
Returns:
bool: True, если is_staff или есть perm `requests_app.can_process_requests`.
"""
if getattr(user, "is_staff", False):
return True
return user.has_perm("requests_app.can_process_requests")
@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/request_list.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
# ---------- POST router ----------
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
"""Маршрутизирует действие формы по полю `_action`.
Returns:
HttpResponse: Редирект обратно на индекс.
"""
action = (request.POST.get("_action") or "").strip().lower()
try:
if action == "create":
return self._create(request)
if action == "update":
return self._update(request)
if action == "delete":
return self._delete(request)
if action == "approve":
return self._approve(request)
if action == "reject":
return self._reject(request)
if action == "cancel":
return self._cancel(request)
if action == "comment":
return self._comment(request)
except ValueError:
pass # сообщения уже добавлены в хендлерах
messages.error(request, "Неизвестное действие.")
return redirect(reverse("requests:request_list"))
# ---------- Handlers ----------
def _create(self, request: HttpRequest) -> HttpResponse:
"""Создаёт заявление через API.
Поля:
type (str), title (str|optional), date_from (YYYY-MM-DD|optional),
date_to (YYYY-MM-DD|optional), comment (str|optional),
attachment (file|optional).
Дополнительно:
Если передан `_save_as=draft`, запрос уходит как «черновик»:
- в API добавляется `?save_as=draft`;
- из payload удаляются пустые/None поля (чтобы не ловить 400 на ChoiceField).
Returns:
HttpResponse: Редирект на индекс.
"""
save_as = (
(request.POST.get("_save_as") or "").strip().lower()
) # "draft" | "submit" | ""
# Нормализуем поля: "" -> None
def _n(v: Optional[str]) -> Optional[str]:
return (v or "").strip() or None
payload = {
"type": _n(request.POST.get("type")),
"title": _n(request.POST.get("title")),
"date_from": _n(request.POST.get("date_from")),
"date_to": _n(request.POST.get("date_to")),
"comment": _n(request.POST.get("comment")),
}
# Для черновика удаляем ключи с None, чтобы не отправлять пустые значения
if save_as == "draft":
payload = {k: v for k, v in payload.items() if v is not None}
files = {}
upload = request.FILES.get("attachment")
if upload:
files = {
"attachment": (
upload.name,
upload,
getattr(upload, "content_type", "application/octet-stream"),
)
}
api = get_api_client(request)
path = "v1/requests/"
if save_as == "draft":
path += "?save_as=draft"
ok, j, st = _api_unpack(
api.post(
path,
data=payload if files else None,
json=None if files else payload,
files=files or None,
)
)
if ok or st == 201:
messages.success(
request,
(
"Черновик сохранён."
if save_as == "draft"
else "Заявление отправлено на рассмотрение."
),
)
else:
messages.error(request, _err_msg(j, f"Ошибка создания (HTTP {st})."))
return redirect(reverse("requests:request_list"))
def _update(self, request: HttpRequest) -> HttpResponse:
"""Частично обновляет заявку (PATCH).
Поля (optional): type, title, date_from, date_to, comment, attachment.
Поддерживает черновик через `_save_as=draft` → `?save_as=draft`.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
save_as = (request.POST.get("_save_as") or "").strip().lower() # "draft" | ""
path = f"v1/requests/{req_id}/"
if save_as in {"draft", "submit"}:
path += f"?save_as={save_as}"
payload = {
"type": (request.POST.get("type") or "").strip() or None,
"title": (request.POST.get("title") or "").strip() or None,
"date_from": (request.POST.get("date_from") or "").strip() or None,
"date_to": (request.POST.get("date_to") or "").strip() or None,
"comment": (request.POST.get("comment") or "").strip() or None,
}
payload = {k: v for k, v in payload.items() if v is not None}
upload = request.FILES.get("attachment")
api = get_api_client(request)
if upload:
files = {
"attachment": (
upload.name,
upload,
getattr(upload, "content_type", "application/octet-stream"),
)
}
ok, j, st = _api_unpack(api.patch(path, data=payload, files=files))
else:
ok, j, st = _api_unpack(api.patch(path, json=payload))
if ok:
messages.success(request, "Заявление обновлено.")
else:
messages.error(request, _err_msg(j, f"Не удалось обновить (HTTP {st})."))
return redirect(reverse("requests:request_list"))
def _delete(self, request: HttpRequest) -> HttpResponse:
"""Удаляет заявку (DELETE).
Ожидаемые поля:
id (int): Идентификатор заявки.
Returns:
HttpResponse: Редирект на индекс.
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.delete(f"v1/requests/{req_id}/"))
if ok:
messages.success(request, "Заявление удалено.")
else:
messages.error(request, _err_msg(j, f"Не удалось удалить (HTTP {st})."))
return redirect(reverse("requests:request_list"))
def _approve(self, request: HttpRequest) -> HttpResponse:
"""Одобряет заявление через API.
Ожидаемые поля:
id (int): Идентификатор заявления.
Returns:
HttpResponse: Редирект на индекс.
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/approve/"))
(
messages.success(request, "Заявление одобрено.")
if ok
else messages.error(
request, _err_msg(j, f"Не удалось одобрить (HTTP {st}).")
)
)
return redirect(reverse("requests:request_list"))
def _reject(self, request: HttpRequest) -> HttpResponse:
"""Отклоняет заявление через API.
Ожидаемые поля:
id (int): Идентификатор.
Returns:
HttpResponse: Редирект на индекс.
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/reject/"))
(
messages.success(request, "Заявление отклонено.")
if ok
else messages.error(
request, _err_msg(j, f"Не удалось отклонить (HTTP {st}).")
)
)
return redirect(reverse("requests:request_list"))
def _cancel(self, request: HttpRequest) -> HttpResponse:
"""Отменяет (владельцем) своё заявление через API.
Ожидаемые поля:
id (int): Идентификатор.
Returns:
HttpResponse: Редирект на индекс.
Raises:
ValueError: Если `id` не число.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(api.post(f"v1/requests/{req_id}/cancel/"))
(
messages.success(request, "Заявление отменено.")
if ok
else messages.error(
request, _err_msg(j, f"Не удалось отменить (HTTP {st}).")
)
)
return redirect(reverse("requests:request_list"))
def _comment(self, request: HttpRequest) -> HttpResponse:
"""Добавляет комментарий к заявлению через API.
Ожидаемые поля:
id (int): Идентификатор.
text (str): Текст комментария.
Returns:
HttpResponse: Редирект на индекс.
Raises:
ValueError: Если `id` не число или `text` пуст.
"""
raw_id = request.POST.get("id")
text = (request.POST.get("text") or "").strip()
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор заявления.")
raise ValueError("Invalid request id")
if not text:
messages.error(request, "Комментарий не может быть пустым.")
raise ValueError("Empty comment")
req_id = int(raw_id)
api = get_api_client(request)
ok, j, st = _api_unpack(
api.post(f"v1/requests/{req_id}/comments/", json={"text": text})
)
(
messages.success(request, "Комментарий добавлен.")
if ok or st == 201
else messages.error(
request,
_err_msg(j, "Не удалось добавить комментарий (HTTP {st})."),
)
)
return redirect(reverse("requests:request_list"))