**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:
parent
8e73603436
commit
81bce43f8a
@ -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):
|
||||
"""Чтение/скачивание/ознакомление: любой аутентифицированный,
|
||||
но доступ только к 'своим' документам (или если есть модельные права/админ).
|
||||
Создание/редактирование/удаление: только админы и обладатели модельных прав.
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
const escAttr = (s) =>
|
||||
String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[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}">×</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=>({ '&':'&','<':'<','>':'>','"':'"' }[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 %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user