**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` и пр. добавлены в шаблон.
This commit is contained in:
admin i 2025-09-13 17:15:09 +03:00
parent 25ae9a40b8
commit f1ed1c5f84
5 changed files with 1287 additions and 64 deletions

View File

@ -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):
"""Чтение/скачивание/ознакомление: любой аутентифицированный,
но доступ только к 'своим' документам (или если есть модельные права/админ).
Создание/редактирование/удаление: только админы и обладатели модельных прав.

View File

@ -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(),
},
}
)

View File

@ -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/<int:pk>/', acknowledge_document, name='acknowledge'),
]

View File

@ -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"))

View File

@ -1,33 +1,743 @@
{% extends "base.html" %}
{% block title %}Документы{% endblock %}
{% block content %}
<h1>Документы</h1>
{% load static %}
{% block title %}Документы{% endblock %}
{% block extra_css %}
<style>
/* Список документов в общей «карточке» */
#docList.feed-stream{ display:block; gap:0 !important; }
#docList .doc-row{ padding:.9rem 1rem !important; margin:0 !important; border-bottom:1px solid var(--bs-border-color); }
#docList .doc-row:last-child{ border-bottom:0; }
#docList .feed-hd{ display:grid; grid-template-columns: 28px 1fr auto; align-items:center; gap:.75rem; margin:0; padding:0 !important; }
#docList .feed-ico{ width:28px; height:28px; display:grid; place-items:center; border-radius:8px;
background: color-mix(in srgb, var(--bs-body-bg) 90%, transparent);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%); color: var(--bs-primary); }
.btn-icon{ width:36px; height:36px; display:inline-grid; place-items:center; border:1px solid var(--bs-border-color); border-radius:10px; background:transparent; }
.btn-icon--danger{ color:var(--bs-danger); }
.doc-actions{ display:flex; align-items:center; gap:.4rem; }
.badge-acked{ background:#d1e7dd; color:#0f5132; border:1px solid #badbcc; padding:.28rem .55rem; border-radius:999px; font-size:.8rem; }
[data-bs-theme="dark"] .badge-acked{ background:#234; color:#8fd4b3; border-color:#1f3b33; }
.scope-switch .btn{ min-height:36px; border-radius:999px; }
/* ===== Виджет выбора получателей ===== */
.recipient-picker{ border:1px solid var(--bs-border-color); border-radius:12px; padding:.75rem; background: var(--bs-body-bg); }
.recipient-picker .rp-selected{ display:flex; flex-wrap:wrap; gap:.4rem; margin-bottom:.5rem; }
.recipient-picker .rp-chip{ display:inline-flex; align-items:center; gap:.35rem; border:1px solid var(--bs-border-color); border-radius:999px; padding:.2rem .55rem; font-size:.85rem; }
.recipient-picker .rp-chip button{ border:0; background:transparent; padding:0 .2rem; line-height:1; }
.recipient-picker .rp-search{ position:relative; }
.recipient-picker .rp-search input{ width:100%; }
.recipient-picker .rp-results{ margin-top:.5rem; max-height:220px; overflow:auto; border:1px solid var(--bs-border-color); border-radius:10px; }
.recipient-picker .rp-item{ display:flex; align-items:center; gap:.5rem; padding:.45rem .6rem; border-bottom:1px solid var(--bs-border-color); }
.recipient-picker .rp-item:last-child{ border-bottom:0; }
.recipient-picker .rp-item small{ color:var(--bs-secondary-color); }
.recipient-picker .rp-empty{ padding:.6rem; text-align:center; color:var(--bs-secondary-color); }
.recipient-picker .rp-avatar{ width:26px; height:26px; border-radius:50%; background:color-mix(in srgb, var(--bs-primary) 12%, transparent); display:inline-grid; place-items:center; font-size:.7rem; }
.recipient-block[hidden]{ display:none !important; }
</style>
<style>
.acks-list{ max-height: 50vh; overflow:auto; margin:0; padding-left:1.1rem; }
.acks-empty{ color: var(--bs-secondary-color); }
.acks-more{ margin-top:.5rem; }
.feed-ava{
width:32px;height:32px;flex:0 0 32px;border-radius:50%;
overflow:hidden;display:flex;align-items:center;justify-content:center;
}
.rp-avatar{ width:100%;height:100%;object-fit:cover; }
</style>
{% endblock %}
{% block content %}
<div class="container ios-page">
{# ===== Шапка страницы ===== #}
<div class="ios-header feed-header d-flex align-items-center gap-2 flex-wrap">
<i class="bi-file-earmark-text fs-2 text-primary"></i>
<h2 class="title ios-title mb-0">Документы</h2>
{% if can_manage_documents or user.is_staff or user.is_superuser %}
<div class="ms-auto d-flex align-items-center gap-2 flex-wrap">
<!-- Переключатель области (Мои / Все) -->
<div class="btn-group scope-switch" role="group" aria-label="Область">
<a href="{{ scope_urls.mine }}"
class="btn btn-outline-secondary {% if scope == 'mine' %}active{% endif %}">
Мои
</a>
<a href="{{ scope_urls.all }}"
class="btn btn-outline-secondary {% if scope == 'all' %}active{% endif %}">
Все
</a>
</div>
{% if perms.documents.add_document %}
<button type="button"
class="btn btn-primary d-flex align-items-center gap-2"
data-bs-toggle="modal"
data-bs-target="#docCreateModal">
<i class="bi-plus-lg"></i><span>Создать документ</span>
</button>
{% endif %}
</div>
{% else %}
<span class="ms-2 small text-secondary">Показаны ваши документы</span>
{% endif %}
</div>
{# ===== Карточка со списком ===== #}
<div class="feed-card mb-3">
{% if documents %}
<div id="docList" class="feed-stream">
{% for doc in documents %}
<div class="doc-row" data-doc-id="{{ doc.id }}">
<div class="feed-hd">
<div class="feed-ico" aria-hidden="true"><i class="bi-file-earmark-text"></i></div>
<div class="feed-main">
<div class="feed-author">
{% if doc.file_url %}
<a href="{{ doc.file_url }}" class="text-decoration-none" target="_blank" rel="noopener">
{{ doc.title|default:"Без названия" }}
</a>
{% else %}
{{ doc.title|default:"Без названия" }}
{% endif %}
</div>
<div class="feed-sub">
{{ doc.description|default:"— без описания —" }}
{% if doc.uploaded_at %} • Загружен: {{ doc.uploaded_at|date:"d.m.Y H:i" }}{% endif %}
{% if doc.sent_to_all %} • Для всех{% endif %}
</div>
</div>
<div class="doc-actions">
{# Вкладка "Мои" — как раньше #}
{% if scope == 'mine' %}
{% if doc.id in acked_ids %}
<span class="badge-acked">Ознакомлен</span>
{% else %}
<a href="{% url 'documents:acknowledge' doc.id %}" class="btn btn-outline-primary">Ознакомиться</a>
{% endif %}
{# Вкладка "Все" — кнопка модалки #}
{% else %}
<button type="button"
class="btn btn-outline-secondary"
data-action="show-acks"
data-doc-id="{{ doc.id }}"
data-doc-title="{{ doc.title|default_if_none:'Документ'|escape }}">
Ознакомления
</button>
{% endif %}
{% if show_admin_controls %}
{% if perms.documents.change_document or user.is_staff or user.is_superuser %}
<button type="button"
class="btn-icon"
data-bs-toggle="tooltip"
title="Редактировать"
aria-label="Редактировать"
data-action="edit"
data-doc-id="{{ doc.id }}"
data-doc-title="{{ doc.title|default_if_none:'' }}"
data-doc-description="{{ doc.description|default_if_none:'' }}"
data-doc-sent_to_all="{{ doc.sent_to_all|yesno:'1,0' }}">
<i class="bi-pencil-square"></i>
</button>
{% endif %}
{% if perms.documents.delete_document or user.is_staff or user.is_superuser %}
<button type="button"
class="btn-icon btn-icon--danger"
data-bs-toggle="tooltip"
title="Удалить"
aria-label="Удалить"
data-action="delete"
data-doc-id="{{ doc.id }}">
<i class="bi-trash"></i>
</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-4 text-center text-secondary">Нет доступных документов.</div>
{% endif %}
</div>
{# ===== Пагинация (если понадобится — через next_url/prev_url) ===== #}
{% if prev_url or next_url %}
<div class="d-flex justify-content-between">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}" href="{{ prev_url|default:'#' }}">Назад</a>
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}" href="{{ next_url|default:'#' }}">Вперёд</a>
</div>
{% endif %}
</div>
<!-- ===== Модалки: Создать / Редактировать ===== -->
<div class="modal fade" id="docCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="docCreateForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Создать документ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Заголовок</label>
<input type="text" name="title" class="form-control" maxlength="255" required>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea name="description" class="form-control" rows="3" maxlength="2000" placeholder="Краткое описание (необязательно)"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Файл</label>
<input type="file" name="file" class="form-control" required>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="sent_to_all" id="createSentToAll" checked>
<label class="form-check-label" for="createSentToAll">Отправить всем</label>
</div>
<!-- Виджет получателей -->
<div class="recipient-block" id="createRecipientsBlock" hidden>
<label class="form-label">Получатели</label>
<div id="createRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
<div class="rp-selected" aria-live="polite"></div>
<div class="rp-search mb-2">
<input type="search" class="form-control" placeholder="Найти сотрудника (имя, почта, телефон)">
</div>
<div class="rp-results" role="listbox" aria-label="Сотрудники"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit"><i class="bi-check-lg"></i> Сохранить</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
</div>
</form>
</div>
</div>
<div class="modal fade" id="docEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="docEditForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Редактировать документ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<input type="hidden" name="id">
<div class="mb-3">
<label class="form-label">Заголовок</label>
<input type="text" name="title" class="form-control" maxlength="255" required>
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea name="description" class="form-control" rows="3" maxlength="2000"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Заменить файл (необязательно)</label>
<input type="file" name="file" class="form-control">
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" name="sent_to_all" id="editSentToAll">
<label class="form-check-label" for="editSentToAll">Отправить всем</label>
</div>
<!-- Виджет получателей -->
<div class="recipient-block" id="editRecipientsBlock" hidden>
<label class="form-label">Получатели</label>
<div id="editRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
<div class="rp-selected" aria-live="polite"></div>
<div class="rp-search mb-2">
<input type="search" class="form-control" placeholder="Найти сотрудника (имя, почта, телефон)">
</div>
<div class="rp-results" role="listbox" aria-label="Сотрудники"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" type="submit"><i class="bi-check-lg"></i> Сохранить</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Отмена</button>
</div>
</form>
</div>
</div>
{# ===== Модалка ведомости ознакомлений ===== #}
<div class="modal fade" id="acksModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Ознакомления</h5>
<div class="small text-secondary" id="acksDocTitle"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="row g-3 align-items-center mb-2">
<div class="col-md-6">
<input type="search" id="acksSearch" class="form-control" placeholder="Поиск по имени или email">
</div>
<div class="col text-end small text-secondary" id="acksTotals">—</div>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="fw-semibold mb-2">Ознакомились</div>
<div id="acksYes" class="border rounded p-2" style="min-height:140px;">
<div class="text-secondary">Загружается…</div>
</div>
</div>
<div class="col-md-6">
<div class="fw-semibold mb-2">Не ознакомились</div>
<div id="acksNo" class="border rounded p-2" style="min-height:140px;">
<div class="text-secondary">Загружается…</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function(){
const apiListUrl = "{{ api_document_list_url|default:'' }}";
const apiDetailBase = "{{ api_document_detail_base|default:'' }}"; // + {id}/
const employeesApi = "{% url 'api:v1:employees-list' %}";
const accessMeta = document.querySelector('meta[name="api-access"]');
const ACCESS = accessMeta ? accessMeta.content : "";
const headersAuth = ACCESS ? { 'Authorization': 'Bearer ' + ACCESS } : {};
/* ===== Утилиты ===== */
const debounce = (fn, ms=300) => {
let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); };
};
function initials(name){
const parts = (name || "").trim().split(/\s+/).slice(0,2);
return parts.map(p=>p[0]||"").join("").toUpperCase() || "U";
}
class RecipientPicker {
constructor(root, opts){
this.root = root;
this.headers = opts.headers || {};
this.apiUrl = opts.apiUrl;
this.q = "";
this.selected = new Map(); // id -> {id, display_name, email}
this.elSelected = root.querySelector('.rp-selected');
this.elSearch = root.querySelector('.rp-search input');
this.elResults = root.querySelector('.rp-results');
this._bind();
this._fetch();
}
_bind(){
this.elSearch.addEventListener('input', debounce(()=>{
this.q = this.elSearch.value.trim();
this._fetch();
}, 250));
this.elResults.addEventListener('change', (e)=>{
const cb = e.target.closest('input[type=checkbox][data-id]');
if(!cb) return;
const id = Number(cb.dataset.id);
const payload = JSON.parse(cb.dataset.payload || "{}");
if(cb.checked){
this.selected.set(id, payload);
}else{
this.selected.delete(id);
}
this._renderSelected();
});
this.elSelected.addEventListener('click', (e)=>{
const btn = e.target.closest('button[data-id]');
if(!btn) return;
const id = Number(btn.dataset.id);
this.selected.delete(id);
const cb = this.elResults.querySelector(`input[type=checkbox][data-id="${id}"]`);
if(cb) cb.checked = false;
this._renderSelected();
});
}
async _fetch(){
const url = new URL(this.apiUrl, window.location.origin);
if(this.q) url.searchParams.set('search', this.q);
url.searchParams.set('ordering', 'last_name,first_name');
url.searchParams.set('page_size', '20');
try{
const resp = await fetch(url.toString(), {headers: this.headers});
if(!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
const items = Array.isArray(data) ? data : (data.results || []);
this._renderResults(items);
}catch(err){
this.elResults.innerHTML = `<div class="rp-empty">Не удалось загрузить сотрудников</div>`;
}
}
_renderResults(items){
const escHtml = (s) =>
String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const escAttr = (s) =>
String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
if(!items.length){
this.elResults.innerHTML = `<div class="rp-empty">Ничего не найдено</div>`;
return;
}
this.elResults.innerHTML = items.map(emp=>{
const id = Number(emp.id);
const name = emp.display_name || emp.full_name || emp.email || ('#'+id);
const email = emp.email || '';
const checked = this.selected.has(id) ? 'checked' : '';
const payloadObj = { id, display_name: name, email };
const payload = escAttr(JSON.stringify(payloadObj));
const av = emp.avatar
? `<span class="feed-ava"><img src="${escAttr(emp.avatar)}" class="rp-avatar" alt=""></span>`
: `<span class="feed-ava"><i class="bi-person"></i></span>`;
return `<label class="rp-item">
${av}
<div class="flex-grow-1">
<div>${escHtml(name)}</div>
${email ? `<small>${escHtml(email)}</small>` : ''}
</div>
<input type="checkbox" data-id="${id}" data-payload='${payload}' ${checked} />
</label>`;
}).join('');
}
_renderSelected(){
const chips = Array.from(this.selected.values()).map(v=>{
const n = v.display_name || v.email || ('#'+v.id);
return `<span class="rp-chip">${n}<button type="button" title="Убрать" data-id="${v.id}">&times;</button></span>`;
});
this.elSelected.innerHTML = chips.join('') || `<span class="text-secondary">Никто не выбран</span>`;
}
/** Установить выбранных получателей массивом объектов {id, display_name, email} */
setSelected(list){
this.selected.clear();
(list||[]).forEach(v=>{ if(v && v.id) this.selected.set(Number(v.id), v); });
this._renderSelected();
// синхронизируем чекбоксы в текущем списке результатов
this.elResults.querySelectorAll('input[type=checkbox][data-id]').forEach(cb=>{
const id = Number(cb.dataset.id);
cb.checked = this.selected.has(id);
});
}
/** Получить массив выбранных ID */
getIds(){ return Array.from(this.selected.keys()); }
}
function toggleRecipientsBlock(checkbox, block){
block.hidden = checkbox.checked; // скрыть, если "всем"
}
function appendRecipientIdsToFD(fd, ids){
// API ждёт повторяемые recipient_ids
for(const id of ids){ fd.append("recipient_ids", String(id)); }
}
/* ===== CREATE ===== */
const createForm = document.getElementById('docCreateForm');
const createAll = document.getElementById('createSentToAll');
const createBlock = document.getElementById('createRecipientsBlock');
const createPicker = new RecipientPicker(
document.getElementById('createRecipients'),
{ headers: headersAuth, apiUrl: employeesApi }
);
toggleRecipientsBlock(createAll, createBlock);
createAll.addEventListener('change', ()=> toggleRecipientsBlock(createAll, createBlock));
if (createForm){
createForm.addEventListener('submit', async (e)=>{
e.preventDefault();
const fd = new FormData(createForm);
fd.set("sent_to_all", createAll.checked ? "true" : "false");
if(!createAll.checked){
const ids = createPicker.getIds();
if(ids.length === 0){
alert('Выберите получателей или включите «Отправить всем».');
return;
}
appendRecipientIdsToFD(fd, ids);
}else{
fd.delete("recipient_ids");
}
try{
const resp = await fetch(apiListUrl, { method:'POST', headers: headersAuth, body: fd });
if(!resp.ok) throw new Error('HTTP ' + resp.status);
await resp.json();
window.location.reload();
}catch(err){
alert('Не удалось создать документ: ' + err.message);
}
});
}
/* ===== EDIT / DELETE ===== */
const listEl = document.getElementById('docList');
const editModal = document.getElementById('docEditModal');
const editForm = document.getElementById('docEditForm');
const editAll = document.getElementById('editSentToAll');
const editBlock = document.getElementById('editRecipientsBlock');
const editPicker = new RecipientPicker(
document.getElementById('editRecipients'),
{ headers: headersAuth, apiUrl: employeesApi }
);
toggleRecipientsBlock(editAll, editBlock);
editAll.addEventListener('change', ()=> toggleRecipientsBlock(editAll, editBlock));
function openEdit(btn){
const id = btn.getAttribute('data-doc-id');
editForm.elements.id.value = id;
editForm.elements.title.value = btn.getAttribute('data-doc-title') || '';
editForm.elements.description.value = btn.getAttribute('data-doc-description') || '';
const all = btn.getAttribute('data-doc-sent_to_all') === '1';
editAll.checked = all;
toggleRecipientsBlock(editAll, editBlock);
// подтягиваем текущих получателей документа
fetch(apiDetailBase + id + '/', { headers: headersAuth })
.then(r=> r.ok ? r.json() : Promise.reject(r.status))
.then(data=>{
const recs = (data && data.recipients) ? data.recipients.map(r=>({
id: r.id, display_name: (r.display_name || r.full_name || r.email || ('#'+r.id)), email: r.email || ''
})) : [];
editPicker.setSelected(recs);
})
.catch(()=> editPicker.setSelected([]));
bootstrap.Modal.getOrCreateInstance(editModal).show();
}
async function doDelete(btn){
if(!confirm('Удалить документ?')) return;
const id = btn.getAttribute('data-doc-id');
try{
const resp = await fetch(apiDetailBase + id + '/', { method:'DELETE', headers: headersAuth });
if(resp.status === 204){ window.location.reload(); return; }
if(!resp.ok) throw new Error('HTTP ' + resp.status);
window.location.reload();
}catch(err){
alert('Не удалось удалить документ: ' + err.message);
}
}
if(listEl){
listEl.addEventListener('click', (e)=>{
const btn = e.target.closest('button[data-action]');
if(!btn) return;
const action = btn.getAttribute('data-action');
if(action === 'edit') openEdit(btn);
if(action === 'delete') doDelete(btn);
});
}
if (editForm){
editForm.addEventListener('submit', async (e)=>{
e.preventDefault();
const id = editForm.elements.id.value;
const fd = new FormData(editForm);
fd.set("sent_to_all", editAll.checked ? "true" : "false");
if(!editAll.checked){
const ids = editPicker.getIds();
if(ids.length === 0){
alert('Выберите получателей или включите «Отправить всем».');
return;
}
appendRecipientIdsToFD(fd, ids);
}else{
fd.delete("recipient_ids");
}
// если файл не выбран — не посылаем ключ
if(!(editForm.elements.file && editForm.elements.file.files.length)){
fd.delete('file');
}
try{
const resp = await fetch(apiDetailBase + id + '/', { method:'PATCH', headers: headersAuth, body: fd });
if(!resp.ok) throw new Error('HTTP ' + resp.status);
await resp.json();
window.location.reload();
}catch(err){
alert('Не удалось сохранить: ' + err.message);
}
});
}
})();
</script>
<script>
(function(){
const apiDetailBase = "{{ api_document_detail_base|default:'' }}"; // /api/v1/documents/
const accessMeta = document.querySelector('meta[name="api-access"]');
const ACCESS = accessMeta ? accessMeta.content : "";
const headersAuth = ACCESS ? { 'Authorization': 'Bearer ' + ACCESS } : {};
const modalEl = document.getElementById('acksModal');
const modalInst = () => bootstrap.Modal.getOrCreateInstance(modalEl);
const elTitle = document.getElementById('acksDocTitle');
const elTotals = document.getElementById('acksTotals');
const elYes = document.getElementById('acksYes');
const elNo = document.getElementById('acksNo');
const elSearch = document.getElementById('acksSearch');
// Храним загруженные данные и состояние пагинации вывода
let state = {
docId: null,
docTitle: "",
rawYes: [],
rawNo: [],
filter: "",
// сколько одновременно показываем (порциями)
pageSize: 100,
shownYes: 0,
shownNo: 0
};
const debounce = (fn, ms=300) => { let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; };
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
function buildItem(u){
const name = esc(u.display_name || u.full_name || u.email || ('#'+u.id));
const email = u.email ? ` <small class="text-secondary">${esc(u.email)}</small>` : '';
const when = u.acknowledged_at ? ` <small class="text-secondary">(${esc(u.acknowledged_at)})</small>` : '';
return `<li>${name}${email}${when}</li>`;
}
function applyFilter(list, term){
if(!term) return list;
const t = term.toLowerCase();
return list.filter(u => {
const name = (u.display_name || u.full_name || u.email || '').toLowerCase();
const email = (u.email || '').toLowerCase();
return name.includes(t) || email.includes(t);
});
}
function renderLists(){
const yes = applyFilter(state.rawYes, state.filter);
const no = applyFilter(state.rawNo, state.filter);
elTotals.textContent = `Ознакомились: ${yes.length} • Не ознакомились: ${no.length} • Всего: ${yes.length + no.length}`;
// Порционное отображение
const sliceYes = yes.slice(0, state.shownYes || state.pageSize);
const sliceNo = no.slice(0, state.shownNo || state.pageSize);
elYes.innerHTML = sliceYes.length
? `<ul class="acks-list">${sliceYes.map(buildItem).join('')}</ul>`
: `<div class="acks-empty">— никого —</div>`;
elNo.innerHTML = sliceNo.length
? `<ul class="acks-list">${sliceNo.map(buildItem).join('')}</ul>`
: `<div class="acks-empty">— никого —</div>`;
// Кнопки «Показать ещё» при необходимости
const needMoreYes = sliceYes.length < yes.length;
const needMoreNo = sliceNo.length < no.length;
if(needMoreYes){
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-secondary acks-more';
btn.textContent = `Показать ещё (${Math.min(state.pageSize, yes.length - sliceYes.length)})`;
btn.addEventListener('click', ()=>{
state.shownYes = sliceYes.length + state.pageSize;
renderLists();
});
elYes.appendChild(btn);
}
if(needMoreNo){
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-secondary acks-more';
btn.textContent = `Показать ещё (${Math.min(state.pageSize, no.length - sliceNo.length)})`;
btn.addEventListener('click', ()=>{
state.shownNo = sliceNo.length + state.pageSize;
renderLists();
});
elNo.appendChild(btn);
}
}
async function fetchAcks(docId, searchTerm){
// Если API поддерживает ?search= — используем его; иначе заберём всё и фильтруем локально
const url = new URL(apiDetailBase + docId + "/acknowledgements/", window.location.origin);
if(searchTerm) url.searchParams.set('search', searchTerm);
try{
const resp = await fetch(url.toString(), { headers: headersAuth });
if(!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
// Поддержка двух форматов:
// 1) непагинированный: { acknowledged: [...], unacknowledged: [...] }
// 2) с пагинацией: { acknowledged: {results: [...], count:..}, unacknowledged: {...} }
const ack = Array.isArray(data.acknowledged) ? data.acknowledged : (data.acknowledged?.results || []);
const un = Array.isArray(data.unacknowledged) ? data.unacknowledged : (data.unacknowledged?.results || []);
state.rawYes = ack;
state.rawNo = un;
state.shownYes = state.pageSize;
state.shownNo = state.pageSize;
renderLists();
}catch(err){
elYes.innerHTML = `<div class="text-danger small">Не удалось загрузить: ${esc(err.message)}</div>`;
elNo.innerHTML = `<div class="text-danger small">Не удалось загрузить: ${esc(err.message)}</div>`;
elTotals.textContent = '—';
}
}
function openAcks(docId, title){
state.docId = docId;
state.docTitle = title || '';
state.filter = '';
state.shownYes = state.pageSize;
state.shownNo = state.pageSize;
elTitle.textContent = title || '';
elSearch.value = '';
elYes.innerHTML = `<div class="text-secondary">Загружается…</div>`;
elNo.innerHTML = `<div class="text-secondary">Загружается…</div>`;
elTotals.textContent = '—';
modalInst().show();
fetchAcks(docId, /*search*/ null);
}
// Делегирование: кнопка "Ознакомления"
document.addEventListener('click', (e)=>{
const btn = e.target.closest('button[data-action="show-acks"]');
if(!btn) return;
const id = btn.getAttribute('data-doc-id');
const title = btn.getAttribute('data-doc-title') || '';
openAcks(id, title);
});
// Поиск (дебаунс)
elSearch.addEventListener('input', debounce(()=>{
state.filter = elSearch.value.trim();
// Если API поддерживает поиск — можно перезагрузить данными с сервера:
// fetchAcks(state.docId, state.filter);
// А пока — локальная фильтрация:
renderLists();
}, 250));
})();
</script>
{% if documents %}
<ul class="list-group">
{% for doc in documents %}
<li class="list-group-item d-flex justify-content-between align-items-start">
<div class="ms-2 me-auto">
<div class="fw-bold">
<a href="{{ doc.file_url }}" target="_blank">{{ doc.title }}</a>
</div>
{{ doc.description|default:"– без описания –" }}<br>
<small class="text-muted">Загружен: {{ doc.uploaded_at|date:"d.m.Y H:i" }}</small>
</div>
<div>
{% if doc.id in acked_ids %}
<span class="badge bg-success">Ознакомлен</span>
{% else %}
<a href="{% url 'documents:acknowledge' doc.pk %}" class="btn btn-sm btn-primary">
Ознакомиться
</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{# пагинация, как было ранее #}
{% else %}
<p>Нет доступных документов.</p>
{% endif %}
{% endblock %}