- Упрощен DocumentView - не загружает документы с сервера - Шаблон использует спиннер вместо серверного рендеринга - Удалена старая пагинация из шаблона - Все документы загружаются через documentListHandler.js - Предотвращена двойная загрузка (сервер + клиент)
436 lines
17 KiB
Python
436 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, Iterable, List, Mapping, 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.utils.dateparse import parse_datetime
|
||
from django.utils.timezone import localtime
|
||
from django.views.decorators.csrf import csrf_protect
|
||
from django.views.generic import TemplateView
|
||
|
||
|
||
# ==============================
|
||
# Вспомогательные функции
|
||
# ==============================
|
||
|
||
|
||
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,
|
||
) -> Tuple[List[Dict[str, Any]], Optional[str], Optional[str], Optional[int]]:
|
||
"""Нормализует ответ API к (results, next, previous, count).
|
||
|
||
Поддерживает форматы:
|
||
- пагинация DRF: {"count": N, "next": URL, "previous": URL,
|
||
"results": [...]}
|
||
- сырой список: [ ... ]
|
||
|
||
Args:
|
||
payload (Any): Десериализованный JSON из API.
|
||
|
||
Returns:
|
||
Tuple: (results, next_url, prev_url, count)
|
||
"""
|
||
# 1) Непагинированный список
|
||
if isinstance(payload, list):
|
||
results = _format_datetime_fields(payload)
|
||
return results, None, None, len(results)
|
||
|
||
# 2) Словарь (пагинированный или нет)
|
||
if isinstance(payload, dict):
|
||
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_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 _fmt_dt(val, fmt: str = "%d.%m.%Y %H:%M") -> str | None:
|
||
"""Превращает ISO-строку/дату в локализованную строку.
|
||
|
||
Args:
|
||
val: ISO строка даты или datetime объект.
|
||
fmt: Формат вывода даты.
|
||
|
||
Returns:
|
||
str | None: Отформатированная строка или None.
|
||
"""
|
||
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, ...] = ("uploaded_at", "created_at", "updated_at"),
|
||
) -> List[Any]:
|
||
"""Форматирует поля дат в списке объектов.
|
||
|
||
Args:
|
||
items: Список объектов (словарей).
|
||
fields: Кортеж имен полей с датами.
|
||
|
||
Returns:
|
||
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 _err_msg(payload: Any, fallback: str = "") -> str:
|
||
"""Извлекает сообщение об ошибке из ответа API.
|
||
|
||
Args:
|
||
payload: Полезная нагрузка ответа API.
|
||
fallback: Сообщение по умолчанию.
|
||
|
||
Returns:
|
||
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 _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`, или
|
||
- в `departments` есть отдел, где пользователь является активным сотрудником, или
|
||
- пользователь является загрузившим документ (`uploaded_by.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
|
||
|
||
# 1. Документы для всех
|
||
if it.get("sent_to_all"):
|
||
result.append(it)
|
||
continue
|
||
|
||
# 2. Документы, загруженные текущим пользователем
|
||
uploaded_by = it.get("uploaded_by")
|
||
if uploaded_by and isinstance(uploaded_by, dict):
|
||
try:
|
||
if int(uploaded_by.get("id")) == user_id:
|
||
result.append(it)
|
||
continue
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# 3. Проверяем recipients
|
||
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)
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
# 4. Проверяем departments
|
||
depts = it.get("departments") or []
|
||
try:
|
||
# Если в документе есть отдел, нужно проверить членство
|
||
# Но API возвращает только id и name отдела, без списка сотрудников
|
||
# Поэтому нужно загрузить эту информацию отдельно или доверять backend'у
|
||
# Для упрощения, если есть departments - считаем что пользователь может видеть
|
||
# (реальная проверка происходит на backend при создании уведомлений)
|
||
if depts:
|
||
# TODO: добавить проверку членства в отделе через отдельный API запрос
|
||
# Пока просто пропускаем - backend сам решит, нужно ли показывать
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return result
|
||
|
||
|
||
# ==============================
|
||
# Вьюхи
|
||
# ==============================
|
||
|
||
@method_decorator([login_required, require_api_auth, csrf_protect], name="dispatch")
|
||
class DocumentView(LoginRequiredMixin, TemplateView):
|
||
"""Страница списка документов с SSR + API для CRUD.
|
||
|
||
GET:
|
||
- Загружает список документов через API (GET /api/v1/documents/).
|
||
- Понимает фильтры: scope=mine|all, ack_status=acked|not_acked.
|
||
- Для scope=mine загружает список ознакомленных документов.
|
||
|
||
Контекст шаблона:
|
||
documents, acked_ids, next_url, prev_url, count, scope,
|
||
show_admin_controls, can_manage_documents, filters, perms,
|
||
api_document_list_url, api_document_detail_base.
|
||
"""
|
||
|
||
template_name = "documents/document_list.html"
|
||
|
||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||
"""Формирует контекст страницы списка, фильтров и пагинации."""
|
||
ctx = super().get_context_data(**kwargs)
|
||
request = self.request
|
||
user = request.user
|
||
|
||
# --- Определяем права ---
|
||
can_manage = _user_can_manage_documents(user)
|
||
show_admin_controls = can_manage
|
||
|
||
# --- Читаем фильтры из GET ---
|
||
scope = (request.GET.get("scope") or "mine").strip().lower()
|
||
if scope not in ("mine", "all"):
|
||
scope = "mine"
|
||
|
||
# Если нет прав управления - принудительно mine
|
||
if not can_manage:
|
||
scope = "mine"
|
||
|
||
ack_status = (request.GET.get("ack_status") or "").strip()
|
||
|
||
# --- Формируем контекст без загрузки документов ---
|
||
# Документы загружаются через AJAX
|
||
ctx.update({
|
||
"documents": [], # Пустой список - загрузка через JS
|
||
"acked_ids": set(),
|
||
"scope": scope,
|
||
"show_admin_controls": show_admin_controls,
|
||
"can_manage_documents": can_manage,
|
||
"filters": {
|
||
"ack_status": ack_status,
|
||
},
|
||
"perms": {
|
||
"documents": {
|
||
"add_document": (
|
||
user.has_perm("documents.add_document") or
|
||
user.is_staff
|
||
),
|
||
"change_document": (
|
||
user.has_perm("documents.change_document") or
|
||
user.is_staff
|
||
),
|
||
"delete_document": (
|
||
user.has_perm("documents.delete_document") or
|
||
user.is_staff
|
||
),
|
||
}
|
||
},
|
||
"api_document_list_url": reverse("api:v1:documents-list"),
|
||
"api_document_detail_base": reverse("api:v1:documents-list"),
|
||
})
|
||
|
||
return ctx
|
||
|
||
|
||
@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
|
||
|
||
# Получаем документ для редиректа на файл
|
||
from documents.models import Document
|
||
try:
|
||
doc = Document.objects.get(id=doc_id)
|
||
file_url = doc.file.url if doc.file else None
|
||
except Document.DoesNotExist:
|
||
messages.error(request, "Документ не найден.")
|
||
return redirect(reverse("documents:document_list"))
|
||
|
||
# Отмечаем ознакомление через API
|
||
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 "Не удалось отметить документ.")
|
||
|
||
# Редирект на файл или обратно на список
|
||
if file_url:
|
||
return redirect(file_url)
|
||
else:
|
||
return redirect(reverse("documents:document_list"))
|