Игорь Надеин 81bce43f8a **feat(documents): страница документов через API, «мои/все», CRUD-кнопки и модалка ознакомлений**
### Коротко

* Перевёл фронтовые вьюхи/шаблон документов на взаимодействие с DRF API.
* Добавил переключатель области показа «Мои / Все» для админов и обладателей модельных прав.
* Во «Все» появились кнопки **Создать/Редактировать/Удалить**, во «Мои» — только личные документы и кнопка «Ознакомиться».
* В «Все» убрал кнопку «Ознакомиться» и добавил **модалку** с ведомостью (кто ознакомился / не ознакомился) и общим поиском.
* Исправил CSRF и пару багов/острых мест в JS и разметке.

---

### Детали изменений

#### Вьюха

* **refactor(documents/views.py)**:

  * Вьюха страницы документов работает как оркестратор: тянет список из `/api/v1/documents/`, формирует контекст, поддерживает `scope=mine|all`.
  * **Всегда** выполняется локальная фильтрация для `scope='mine'`: документ считается «моим», если `sent_to_all == true` **или** текущий пользователь есть в `recipients[].id`. Это гарантирует корректность даже при сервисном/админском токене к API.
  * В контекст добавлены флаги: `can_manage_documents`, `show_admin_controls`, `scope_urls`, `perm_can_add|change|delete`.
  * Вынесены утилиты: нормализация булевых (`_as_bool`), сборка повторяемых полей для multipart (`_repeat_field_pairs`), распаковка ответа API (`_api_unpack`), парсинг пагинации в dataclass `ApiPage`, проверка модельных прав пользователя.
  * (Опционально) подготовлена логика серверного POST `_action=create|edit|delete` как прокси к API (оставили AJAX, но серверный вариант задокументирован).

#### Шаблон

* **templates/documents/document\_list.html**:

  * Привёл к общему стилю проекта (`ios-*`, `feed-*`, `bi-*`).
  * Добавил переключатель «Мои / Все» (виден только при админских/модельных правах).
  * Во вкладке **«Все»**:

    * убрана кнопка «Ознакомиться»;
    * добавлена кнопка **«Ознакомления»**, открывающая **модальное окно** с двумя списками («Ознакомились», «Не ознакомились»), общим поиском по имени/email и порционным показом (по 100 элементов с «Показать ещё»).
    * центрирование модалки: `modal-dialog-centered`; ограничение высоты списков: `.acks-list{ max-height: 50vh; }`.
  * Реализован **RecipientPicker**:

    * поиск получателей по `/api/v1/employees/?search=...` (имя/email/телефон), мультивыбор чекбоксами;
    * при `sent_to_all=true` блок получателей скрывается;
    * безопасный рендер (экранирование `name/email/avatar/payload`), вынесены стили аватарки в `.feed-ava` и `.rp-avatar`.
  * **CSRF/фолбэк**:

    * добавлены `{% csrf_token %}` в формы модалок (как страховка на случай нативного submit);
    * рекомендации: кнопки «Сохранить» можно держать `type="button"` и вызывать `fetch`, чтобы не ловить 403 при падении JS.
  * Удалён устаревший «инлайновый» контейнер ведомости под документом и связанный слушатель `data-action="toggle-acks"`.

#### JS

* CRUD (создание/редактирование/удаление) — через `fetch` в API (`Authorization: Bearer <meta api-access>`), multipart FormData, обработка ошибок.
* RecipientPicker:

  * дебаунс поиска, синхронизация «чипов» и чекбоксов, экранирование данных.
  * фикс бага с «двойной обратной кавычкой» в шаблонной строке аватарки.
* Модалка «Ознакомления»:

  * загрузка `GET /api/v1/documents/{id}/acknowledgements/`;
  * общий поиск (локальный; если API добавит `?search=`, можно переключить на серверную фильтрацию);
  * порционный рендер и счётчики.

#### API

* **feat(api):** добавлен экшен `GET /api/v1/documents/{id}/acknowledgements/` (ведомость ознакомлений).
* **security:** экшен защищён через `AdminOrActionOrModelPerms` — доступен **только** админам/обладателям модельных прав (на SAFE в этом классе тоже требуются модельные права).
* Поддержаны поля `acknowledged_at` для «ознакомились» и сводные `counts`. (Опционально — поддержка `?search=` и пагинации колонок в будущем.)

#### Безопасность и стабильность

* Экранирование вставок HTML/атрибутов в шаблонных строках (XSS-hardening) в виджете получателей и модалке.
* Исправлен `403 CSRF token missing` при нативной отправке: CSRF-токены в формах + рекомендация `type="button"` для кнопок сабмита.
* Очистка мёртвого кода (устаревший блок «toggle-acks»).

---

### Что оставить на потом (не блокирует)

* Вынести JS в `static/js/` и собрать минифицировано.
* Добавить серверный `?search=` и пагинацию в экшене `acknowledgements`, если списки большие.
* (По желанию) Переименовать класс вьюхи в `DocumentsPageView` — точнее отражает роль «страничного оркестратора», роуты не менять.

---

### Затронутые файлы (основные)

* `backend/documents/views.py` — рефакторинг, локальная фильтрация «мои», утилиты, (опциональные) POST-handlers.
* `backend/templates/documents/document_list.html` — крупное обновление шаблона, модалки, JS, стили.
* `backend/api/v1/documents/views.py` — экшен `acknowledgements` (и `permission_classes=[AdminOrActionOrModelPerms]` для него).
* (Косметика CSS) классы `.feed-ava`, `.rp-avatar`, `.acks-list` и пр. добавлены в шаблон.
2025-09-13 17:15:09 +03:00

512 lines
21 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 dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, 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
from django.contrib.auth.models import AbstractBaseUser
from django.contrib.auth.mixins import LoginRequiredMixin
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
# ==============================
# Вспомогательные структуры/утилиты
# ==============================
@dataclass(frozen=True)
class ApiPage:
"""Результат пагинированного ответа API.
Attributes:
items (list[dict]): Список элементов (обычно объекты документов).
count (int): Общее количество элементов (если известно).
next_url (str|None): Ссылка на следующую страницу (как возвращает API).
prev_url (str|None): Ссылка на предыдущую страницу (как возвращает API).
"""
items: List[Dict[str, Any]]
count: int
next_url: Optional[str]
prev_url: Optional[str]
def _as_bool(value: Any) -> bool:
"""Нормализует булево значение из строк HTML-форм.
Args:
value: Строка/булево/None.
Returns:
bool: True, если значение похоже на истину ('1', 'true', 'on', 'yes').
Raises:
ValueError: Если значение имеет неожиданный тип, не приводимый к строке.
"""
if isinstance(value, bool):
return value
try:
s = str(value).strip().lower()
except Exception as exc: # pragma: no cover - защитный блок
raise ValueError("Unsupported boolean value") from exc
return s in {"1", "true", "on", "yes"}
def _repeat_field_pairs(field: str, values: Iterable[int | str]) -> List[Tuple[str, str]]:
"""Готовит список повторяемых пар (field, value) для multipart-формы.
Args:
field (str): Имя поля.
values (Iterable[int|str]): Значения (могут быть пустыми/None — игнорируются).
Returns:
list[tuple[str, str]]: Повторяемые пары для передачи в API-клиент.
Raises:
TypeError: Если field пустой или values неитерируемый.
"""
if not field:
raise TypeError("field must be a non-empty string")
out: List[Tuple[str, str]] = []
for v in values or []:
if v is None:
continue
s = str(v).strip()
if s:
out.append((field, s))
return out
def _api_unpack(resp: Any) -> Tuple[bool, Dict[str, Any], int]:
"""Безопасно извлекает признаки успеха, JSON и статус из ответа API.
Args:
resp: Объект ответа клиентской обёртки (ожидаются атрибуты .ok, .json, .status).
Returns:
tuple: (ok, data, status)
Raises:
AttributeError: Если у ответа нет ожидаемых атрибутов.
"""
ok: bool = getattr(resp, "ok")
status: int = getattr(resp, "status")
data: Dict[str, Any] = getattr(resp, "json") or {}
return ok, data, status
def _parse_page_payload(payload: Any) -> ApiPage:
"""Преобразует полезную нагрузку DRF в ApiPage.
Поддерживает форматы:
- пагинация DRF: {"count": N, "next": URL, "previous": URL, "results": [...]}
- сырой список: [ ... ]
Args:
payload (Any): Десериализованный JSON из API.
Returns:
ApiPage: Стандартизованный результат.
Raises:
TypeError: Если payload незнакомого формата.
"""
if isinstance(payload, dict) and "results" in payload:
items = list(payload.get("results") or [])
return ApiPage(
items=items,
count=int(payload.get("count") or 0),
next_url=payload.get("next"),
prev_url=payload.get("previous"),
)
if isinstance(payload, list):
return ApiPage(items=list(payload), count=len(payload), next_url=None, prev_url=None)
raise TypeError("Unsupported API payload format")
def _user_can_manage_documents(user: AbstractBaseUser) -> bool:
"""Проверяет, есть ли у пользователя административные/модельные права на Document.
Args:
user (AbstractBaseUser): Пользователь.
Returns:
bool: True, если is_staff или есть одно из прав add/change/delete/view.
"""
if getattr(user, "is_staff", False):
return True
model = "document"
app_label = "documents"
for act in ("view", "add", "change", "delete"):
if user.has_perm(f"{app_label}.{act}_{model}"):
return True
return False
def _current_url_with(request: HttpRequest, **new_params: Any) -> str:
"""Возвращает URL текущего пути с подменой/удалением query-параметров.
Args:
request (HttpRequest): Текущий запрос.
**new_params: Пары ключ-значение; None удаляет параметр.
Returns:
str: Абсолютный путь с новой query-строкой.
"""
q = request.GET.copy()
for k, v in new_params.items():
if v is None and k in q:
del q[k]
elif v is not None:
q[k] = str(v)
qs = q.urlencode()
return f"{request.path}?{qs}" if qs else request.path
def _filter_for_user(items: Sequence[Dict[str, Any]], user_id: Optional[int]) -> List[Dict[str, Any]]:
"""Фильтрует документы в область «Мои».
Документ считается «моим», если:
- он отправлен всем (`sent_to_all == true`), или
- в `recipients[].id` присутствует `user_id`.
Args:
items (Sequence[dict]): Полученный список документов из API.
user_id (int|None): Идентификатор текущего пользователя.
Returns:
list[dict]: Отфильтрованный список.
Raises:
ValueError: Если user_id не задан (None).
"""
if user_id is None:
raise ValueError("user_id is required for filtering")
result: List[Dict[str, Any]] = []
for it in items:
if not isinstance(it, dict):
continue
if it.get("sent_to_all"):
result.append(it)
continue
recips = it.get("recipients") or []
try:
if any(int(r.get("id")) == user_id for r in recips if isinstance(r, dict) and r.get("id") is not None):
result.append(it)
except Exception:
# если поле неверного формата — пропускаем элемент
continue
return result
# ==============================
# Вьюхи
# ==============================
@method_decorator([login_required, require_api_auth, csrf_protect], name="dispatch")
class DocumentView(LoginRequiredMixin, TemplateView):
"""Список документов + CRUD через POST на тот же URL.
Поведение:
- GET — загружает список документов через API и формирует контекст.
- POST — маршрутизирует действия форм `_action = create|edit|delete`:
создаёт, редактирует или удаляет документ через API.
Контекст (для шаблона `documents/document_list.html`):
documents (list[dict]): Список документов.
acked_ids (set[int]): Множество ID «ознакомленных» пользователем документов.
count (int): Общее количество элементов (если известно).
next_url (str|None): Ссылка на следующую страницу (как вернул API).
prev_url (str|None): Ссылка на предыдущую страницу (как вернул API).
page (int|str): Номер страницы (как пришёл из запроса).
scope (str): 'mine' или 'all' — активная область.
can_manage_documents (bool): Имеет ли пользователь модельные права/является staff.
show_admin_controls (bool): Показывать ли кнопки create/edit/delete (True при scope='all' и наличии прав).
scope_urls (dict): {'mine': url, 'all': url} — ссылки-переключатели.
api_document_list_url (str): URL API-эндпоинта списка документов.
api_document_detail_base (str): База detail (заканчивается '/documents/').
perm_can_add/change/delete (bool): Точечные права для тонкой логики в шаблоне.
"""
template_name = "documents/document_list.html"
@property
def paginate_by(self) -> int:
"""Размер страницы для обратной совместимости (реальная пагинация на стороне API).
Returns:
int: Желаемый размер страницы.
"""
return 20
# ---------- GET ----------
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
"""Формирует контекст страницы.
QueryParams:
page (int, optional): Номер страницы (проксируется в API).
scope ('mine'|'all'): Область показа. Обычным пользователям всегда 'mine'.
Returns:
dict: Контекст для шаблона.
Raises:
RuntimeError: Если API вернул неожиданный формат данных.
"""
ctx = super().get_context_data(**kwargs)
request = self.request
user = request.user
api = get_api_client(request)
can_manage = _user_can_manage_documents(user)
raw_scope = (request.GET.get("scope") or "mine").lower()
scope = raw_scope if (raw_scope in {"mine", "all"} and can_manage) else "mine"
page = request.GET.get("page") or 1
# Загружаем список документов через API
resp = api.get("v1/documents/", params={"page": page})
ok, data, _status = _api_unpack(resp)
if not ok:
messages.error(request, (data or {}).get("detail") or "Не удалось загрузить документы.")
page_data = ApiPage(items=[], count=0, next_url=None, prev_url=None)
else:
try:
page_data = _parse_page_payload(data)
except TypeError:
# Неожиданный формат ответа
messages.error(request, "Неожиданный ответ API при загрузке документов.")
page_data = ApiPage(items=[], count=0, next_url=None, prev_url=None)
# Всегда фильтруем «Мои» локально — это надёжно и не зависит от токена к API
items: List[Dict[str, Any]]
if scope == "mine":
try:
items = _filter_for_user(page_data.items, getattr(user, "id", None))
except ValueError:
items = [] # Без user.id не можем корректно отфильтровать
else:
items = page_data.items
# Множество ID ознакомленных документов
acked_ids: Set[int] = {
int(it.get("id"))
for it in items
if isinstance(it, dict) and it.get("is_acknowledged")
}
# Ссылки переключателя
scope_urls = {
"mine": _current_url_with(request, scope="mine", page=None),
"all": _current_url_with(request, scope="all", page=None),
}
# API URL для фронта (если нужен JS) — сохраняем совместимость с шаблоном
api_document_list_url = reverse("api:v1:documents-list")
ctx.update(
{
"documents": items,
"acked_ids": acked_ids,
"count": page_data.count,
"next_url": page_data.next_url,
"prev_url": page_data.prev_url,
"page": int(page) if str(page).isdigit() else page,
"scope": scope,
"can_manage_documents": can_manage,
"show_admin_controls": bool(can_manage and scope == "all"),
"scope_urls": scope_urls,
"api_document_list_url": api_document_list_url,
"api_document_detail_base": api_document_list_url, # + {id}/
"perm_can_add": user.has_perm("documents.add_document") or user.is_staff,
"perm_can_change": user.has_perm("documents.change_document") or user.is_staff,
"perm_can_delete": user.has_perm("documents.delete_document") or user.is_staff,
}
)
return ctx
# ---------- POST (CRUD через API) ----------
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
"""Маршрутизация действий форм по полю `_action`.
Returns:
HttpResponse: Редирект на список после выполнения действия.
Raises:
ValueError: Если `_action` отсутствует или имеет неизвестное значение.
"""
action = (request.POST.get("_action") or "").lower().strip()
if action == "create":
return self._create(request)
if action == "edit":
return self._edit(request)
if action == "delete":
return self._delete(request)
messages.error(request, "Неизвестное действие.")
raise ValueError("Unknown _action for DocumentListView.post")
# --- Handlers ---
def _create(self, request: HttpRequest) -> HttpResponse:
"""Создаёт документ через API (multipart).
Ожидаемые поля:
title (str), description (str, optional),
file (uploaded, required),
sent_to_all (bool),
recipient_ids (repeat, если sent_to_all=false).
Returns:
HttpResponse: Редирект на список документов.
Raises:
ValueError: Если отсутствует обязательный файл.
"""
title = (request.POST.get("title") or "").strip()
description = (request.POST.get("description") or "").strip()
sent_to_all = _as_bool(request.POST.get("sent_to_all"))
file = request.FILES.get("file")
if not file:
messages.error(request, "Файл обязателен.")
raise ValueError("file is required")
data: List[Tuple[str, str]] = [
("title", title),
("description", description),
("sent_to_all", "true" if sent_to_all else "false"),
]
if not sent_to_all:
data += _repeat_field_pairs("recipient_ids", request.POST.getlist("recipient_ids"))
files = {
"file": (getattr(file, "name", "file"), file, getattr(file, "content_type", None))
}
api = get_api_client(request)
resp = api.post("v1/documents/", data=data, files=files)
ok, j, status = _api_unpack(resp)
if ok:
messages.success(request, "Документ создан.")
else:
messages.error(request, j.get("detail") or f"Ошибка создания (HTTP {status}).")
return redirect(reverse("documents:document_list"))
def _edit(self, request: HttpRequest) -> HttpResponse:
"""Редактирует документ через API (multipart PATCH).
Ожидаемые поля:
id (int), title (str), description (str),
sent_to_all (bool),
recipient_ids (repeat, если sent_to_all=false),
file (uploaded, optional).
Returns:
HttpResponse: Редирект на список документов.
Raises:
ValueError: Если id некорректен.
"""
raw_id = request.POST.get("id")
if not str(raw_id).isdigit():
messages.error(request, "Некорректный идентификатор документа.")
raise ValueError("Invalid document id")
doc_id = int(raw_id)
title = (request.POST.get("title") or "").strip()
description = (request.POST.get("description") or "").strip()
sent_to_all = _as_bool(request.POST.get("sent_to_all"))
rids = request.POST.getlist("recipient_ids")
data: List[Tuple[str, str]] = [
("title", title),
("description", description),
("sent_to_all", "true" if sent_to_all else "false"),
]
if not sent_to_all:
data += _repeat_field_pairs("recipient_ids", rids)
file = request.FILES.get("file")
files = (
{"file": (getattr(file, "name", "file"), file, getattr(file, "content_type", None))}
if file
else None
)
api = get_api_client(request)
resp = api.patch(f"v1/documents/{doc_id}/", data=data, files=files)
ok, j, status = _api_unpack(resp)
if ok:
messages.success(request, "Документ сохранён.")
else:
messages.error(request, j.get("detail") or f"Не удалось сохранить (HTTP {status}).")
return redirect(reverse("documents:document_list"))
def _delete(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 document id")
doc_id = int(raw_id)
api = get_api_client(request)
resp = api.delete(f"v1/documents/{doc_id}/")
ok, j, status = _api_unpack(resp)
if ok or status == 204:
messages.success(request, "Документ удалён.")
else:
messages.error(request, (j or {}).get("detail") or f"Не удалось удалить (HTTP {status}).")
return redirect(reverse("documents:document_list"))
@login_required
@require_api_auth
def acknowledge_document(request: HttpRequest, pk: int) -> HttpResponse:
"""Отмечает документ «ознакомленным» через API.
Args:
request (HttpRequest): Текущий запрос.
pk (int): Идентификатор документа.
Returns:
HttpResponse: Редирект на список документов.
Raises:
ValueError: Если pk имеет неверный формат.
"""
try:
doc_id = int(pk)
except (TypeError, ValueError) as exc:
messages.error(request, "Некорректный идентификатор документа.")
raise ValueError("pk must be an integer") from exc
api = get_api_client(request)
resp = api.post(f"v1/documents/{doc_id}/acknowledge/")
ok, data, _status = _api_unpack(resp)
if ok:
if data.get("already"):
messages.info(request, "Вы уже отмечали ознакомление с этим документом.")
else:
messages.success(request, "Ознакомление с документом отмечено.")
else:
messages.error(request, data.get("detail") or "Не удалось отметить документ.")
return redirect(reverse("documents:document_list"))