### Коротко
* Перевёл фронтовые вьюхи/шаблон документов на взаимодействие с 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` и пр. добавлены в шаблон.
173 lines
7.1 KiB
Python
173 lines
7.1 KiB
Python
from __future__ import annotations
|
||
|
||
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, 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.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
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
class DocumentViewSet(ModelViewSet):
|
||
"""Полный CRUD по документам + экшен `acknowledge`.
|
||
|
||
Создание/обновление поддерживает multipart (для файла) и назначение получателей.
|
||
"""
|
||
|
||
queryset: QuerySet[Document] = (
|
||
Document.objects.all()
|
||
.select_related("uploaded_by")
|
||
.prefetch_related("recipients")
|
||
)
|
||
permission_classes = [DocumentReadOrModelPerms]
|
||
parser_classes = (MultiPartParser, FormParser, JSONParser)
|
||
|
||
def get_serializer_class(self):
|
||
"""Возвращает сериализатор в зависимости от действия.
|
||
|
||
Returns:
|
||
type: Класс сериализатора для чтения/записи.
|
||
"""
|
||
if self.action in ("create", "update", "partial_update"):
|
||
return DocumentWriteSerializer
|
||
return DocumentReadSerializer
|
||
|
||
def get_queryset(self) -> QuerySet[Document]:
|
||
"""Базовый queryset + аннотация флага ознакомления текущего пользователя.
|
||
|
||
Returns:
|
||
QuerySet[Document]: оптимизированный для списка без N+1.
|
||
"""
|
||
qs = super().get_queryset()
|
||
request = getattr(self, "request", None)
|
||
user = getattr(request, "user", None)
|
||
|
||
# Для аутентифицированных аннотируем флаг "я ознакомился"
|
||
if user and user.is_authenticated:
|
||
subq = DocumentAcknowledgement.objects.filter(
|
||
document=OuterRef("pk"), user=user
|
||
)
|
||
qs = qs.annotate(_is_acknowledged=Exists(subq))
|
||
return qs
|
||
|
||
@transaction.atomic
|
||
def create(self, request, *args, **kwargs):
|
||
"""Создаёт документ и возвращает read-схему + recipient_count.
|
||
|
||
recipient_count:
|
||
- 0, если sent_to_all=True;
|
||
- число активных пользователей из переданных recipient_ids (неактивные/несуществующие игнорируются).
|
||
"""
|
||
serializer = self.get_serializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
# Сохраним нормализованные ID до save(), т.к. serializer.save() их вытаскивает.
|
||
input_recipient_ids = list(serializer.validated_data.get("recipient_ids", []))
|
||
|
||
doc = serializer.save()
|
||
|
||
read = DocumentReadSerializer(doc, context={"request": request})
|
||
data = dict(read.data)
|
||
|
||
if doc.sent_to_all:
|
||
data["recipient_count"] = 0
|
||
else:
|
||
active_count = User.objects.filter(
|
||
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))
|
||
|
||
return Response(data, status=status.HTTP_201_CREATED)
|
||
|
||
def update(self, request, *args, **kwargs):
|
||
"""Полная замена документа.
|
||
|
||
Returns:
|
||
Response: Тело ответа в формате read-сериализатора (с file_url).
|
||
"""
|
||
partial = kwargs.pop("partial", False)
|
||
instance = self.get_object()
|
||
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||
serializer.is_valid(raise_exception=True)
|
||
doc = serializer.save()
|
||
|
||
read = DocumentReadSerializer(doc, context={"request": request})
|
||
return Response(read.data, status=status.HTTP_200_OK)
|
||
|
||
def partial_update(self, request, *args, **kwargs):
|
||
"""Частичное обновление (PATCH) — аналогично update, но partial=True."""
|
||
kwargs["partial"] = True
|
||
return self.update(request, *args, **kwargs)
|
||
|
||
@action(methods=["post"], detail=True)
|
||
def acknowledge(self, request, pk=None):
|
||
"""Отметить ознакомление текущего пользователя с документом.
|
||
|
||
Args:
|
||
request: DRF Request
|
||
pk: ID документа
|
||
|
||
Returns:
|
||
Response: {"ok": true, "already": bool}
|
||
"""
|
||
doc = self.get_object()
|
||
obj, created = DocumentAcknowledgement.objects.get_or_create(
|
||
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(),
|
||
},
|
||
}
|
||
)
|