From 81bce43f8a9dec6aba6598f4d7b4c74e0cc169b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 13 Sep 2025 17:15:09 +0300 Subject: [PATCH] =?UTF-8?q?**feat(documents):=20=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20API?= =?UTF-8?q?,=20=C2=AB=D0=BC=D0=BE=D0=B8/=D0=B2=D1=81=D0=B5=C2=BB,=20CRUD-?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=BA=D0=B0=20=D0=BE=D0=B7=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B9**?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Коротко * Перевёл фронтовые вьюхи/шаблон документов на взаимодействие с 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 `), 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` и пр. добавлены в шаблон. --- backend/api/v1/documents/permissions.py | 3 +- backend/api/v1/documents/views.py | 50 +- backend/documents/urls.py | 4 +- backend/documents/views.py | 524 +++++++++++- .../templates/documents/document_list.html | 770 +++++++++++++++++- 5 files changed, 1287 insertions(+), 64 deletions(-) diff --git a/backend/api/v1/documents/permissions.py b/backend/api/v1/documents/permissions.py index efecc15f..af530819 100644 --- a/backend/api/v1/documents/permissions.py +++ b/backend/api/v1/documents/permissions.py @@ -7,6 +7,7 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS from rest_framework.permissions import DjangoModelPermissions from rest_framework.request import Request from rest_framework.views import View +from ..permissions import AdminOrActionOrModelPerms def _has_any_model_perm( @@ -20,7 +21,7 @@ def _has_any_model_perm( return False -class DocumentReadOrModelPerms(DjangoModelPermissions): +class DocumentReadOrModelPerms(AdminOrActionOrModelPerms): """Чтение/скачивание/ознакомление: любой аутентифицированный, но доступ только к 'своим' документам (или если есть модельные права/админ). Создание/редактирование/удаление: только админы и обладатели модельных прав. diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index 0c572cc8..29215c59 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -4,16 +4,16 @@ from typing import Any from django.contrib.auth import get_user_model from django.db import transaction -from django.db.models import Count, Exists, OuterRef, QuerySet +from django.db.models import Count, Exists, OuterRef, QuerySet, Q from documents.models import Document, DocumentAcknowledgement from rest_framework import status from rest_framework.decorators import action from rest_framework.parsers import FormParser, JSONParser, MultiPartParser -from rest_framework.permissions import (SAFE_METHODS, BasePermission, - IsAuthenticated) +from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from ..employees.serializers import EmployeeBriefSerializer from ..permissions import AdminOrActionOrModelPerms from .permissions import DocumentReadOrModelPerms from .serializers import DocumentReadSerializer, DocumentWriteSerializer @@ -89,7 +89,7 @@ class DocumentViewSet(ModelViewSet): is_active=True, id__in=input_recipient_ids ).aggregate(n=Count("id", distinct=True))["n"] data["recipient_count"] = active_count - print(('input_recipient_ids', input_recipient_ids)) + print(("input_recipient_ids", input_recipient_ids)) return Response(data, status=status.HTTP_201_CREATED) @@ -129,4 +129,44 @@ class DocumentViewSet(ModelViewSet): document=doc, user=request.user ) return Response({"ok": True, "already": not created}) - + + @action( + detail=True, methods=["get"], permission_classes=[AdminOrActionOrModelPerms] + ) + def acknowledgements(self, request, pk=None): + """Ведомость ознакомлений: доступна только staff/модельные права. + Поддерживает ?search= для фильтра и отдаёт непагинированно (или подключите пагинацию). + """ + doc = self.get_object() + q = (request.query_params.get("search") or "").strip() + base = User.objects.filter(is_active=True) + if not doc.sent_to_all: + base = base.filter(pk__in=doc.recipients.values_list("pk", flat=True)) + if q: + base = base.filter( + Q(email__icontains=q) + | Q(first_name__icontains=q) + | Q(last_name__icontains=q) + | Q(patronymic__icontains=q) + ) + acked_qs = base.filter(document_acknowledgements__document=doc) + unacked_qs = base.exclude(pk__in=acked_qs.values("pk")) + + acked = EmployeeBriefSerializer( + acked_qs, many=True, context={"request": request} + ).data + unacked = EmployeeBriefSerializer( + unacked_qs, many=True, context={"request": request} + ).data + # при желании подключите PageNumberPagination и верните {"acknowledged": page1, "unacknowledged": page2} + return Response( + { + "acknowledged": acked, + "unacknowledged": unacked, + "counts": { + "acknowledged": acked_qs.count(), + "unacknowledged": unacked_qs.count(), + "total": base.count(), + }, + } + ) diff --git a/backend/documents/urls.py b/backend/documents/urls.py index 222ceb34..a84c8b06 100644 --- a/backend/documents/urls.py +++ b/backend/documents/urls.py @@ -1,10 +1,10 @@ # documents/urls.py from django.urls import path -from .views import DocumentListView, acknowledge_document +from .views import DocumentView, acknowledge_document app_name = 'documents' urlpatterns = [ - path('', DocumentListView.as_view(), name='document_list'), + path('', DocumentView.as_view(), name='document_list'), path('ack//', acknowledge_document, name='acknowledge'), ] diff --git a/backend/documents/views.py b/backend/documents/views.py index f42e313d..6d2f33d5 100644 --- a/backend/documents/views.py +++ b/backend/documents/views.py @@ -1,39 +1,511 @@ +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.db.models import Q -from django.shortcuts import get_object_or_404, redirect -from django.views.generic import ListView - -from .models import Document, DocumentAcknowledgement +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 -class DocumentListView(LoginRequiredMixin, ListView): - model = Document +# ============================== +# Вспомогательные структуры/утилиты +# ============================== + +@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" - context_object_name = "documents" - paginate_by = 20 - def get_queryset(self): - user = self.request.user - return ( - Document.objects.filter(Q(sent_to_all=True) | Q(recipients=user)) - .distinct() - .order_by("-uploaded_at") - ) + @property + def paginate_by(self) -> int: + """Размер страницы для обратной совместимости (реальная пагинация на стороне API). - def get_context_data(self, **kwargs): + 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) - user = self.request.user - # получаем id документов, с которыми пользователь уже ознакомился - acked = DocumentAcknowledgement.objects.filter(user=user).values_list( - "document_id", flat=True + + 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, + } ) - ctx["acked_ids"] = set(acked) 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 -def acknowledge_document(request, pk): - doc = get_object_or_404(Document, pk=pk) - DocumentAcknowledgement.objects.get_or_create(document=doc, user=request.user) - return redirect("documents:document_list") +@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")) diff --git a/backend/templates/documents/document_list.html b/backend/templates/documents/document_list.html index 010d251b..282ec8b7 100644 --- a/backend/templates/documents/document_list.html +++ b/backend/templates/documents/document_list.html @@ -1,33 +1,743 @@ {% extends "base.html" %} -{% block title %}Документы{% endblock %} -{% block content %} -

Документы

+{% load static %} + +{% block title %}Документы{% endblock %} + +{% block extra_css %} + + + +{% endblock %} + +{% block content %} +
+ + {# ===== Шапка страницы ===== #} +
+ +

Документы

+ + {% if can_manage_documents or user.is_staff or user.is_superuser %} +
+ + + + {% if perms.documents.add_document %} + + {% endif %} +
+ {% else %} + Показаны ваши документы + {% endif %} +
+ + {# ===== Карточка со списком ===== #} +
+ {% if documents %} +
+ {% for doc in documents %} +
+
+ + +
+
+ {% if doc.file_url %} + + {{ doc.title|default:"Без названия" }} + + {% else %} + {{ doc.title|default:"Без названия" }} + {% endif %} +
+
+ {{ doc.description|default:"— без описания —" }} + {% if doc.uploaded_at %} • Загружен: {{ doc.uploaded_at|date:"d.m.Y H:i" }}{% endif %} + {% if doc.sent_to_all %} • Для всех{% endif %} +
+
+ +
+ {# Вкладка "Мои" — как раньше #} + {% if scope == 'mine' %} + {% if doc.id in acked_ids %} + Ознакомлен + {% else %} + Ознакомиться + {% endif %} + + {# Вкладка "Все" — кнопка модалки #} + {% else %} + + {% endif %} + + {% if show_admin_controls %} + {% if perms.documents.change_document or user.is_staff or user.is_superuser %} + + {% endif %} + {% if perms.documents.delete_document or user.is_staff or user.is_superuser %} + + {% endif %} + {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
Нет доступных документов.
+ {% endif %} +
+ + {# ===== Пагинация (если понадобится — через next_url/prev_url) ===== #} + {% if prev_url or next_url %} + + {% endif %} + +
+ + + + + + +{# ===== Модалка ведомости ознакомлений ===== #} + + +{% endblock %} +{% block extra_js %} + + - {% if documents %} - - {# пагинация, как было ранее #} - {% else %} -

Нет доступных документов.

- {% endif %} {% endblock %}