### Основное * Политика доступа для документов: * **CRUD** — только `is_staff` или обладатели модельных прав (`add/change/delete_document`). * **Чтение/скачивание/ACK** — любой **аутентифицированный**, но **только** свои документы (получатель активен) или если `sent_to_all=True`. * Для календаря: `GET /api/v1/calendar/events/` (компания) доступен авторизованным пользователям. ### permissions / views * Введён `DocumentReadOrModelPerms` на базе `DjangoModelPermissions`: * `GET/HEAD/OPTIONS` не требуют model-perms (только `IsAuthenticated`). * Объектно: staff/любой model-perm → доступ; иначе только `sent_to_all` или пользователь в `recipients(is_active=True)`. * Небезопасные методы требуют `add/change/delete_document` (или staff); **не требуем** дополнительно `view_document`. * `DocumentViewSet.get_queryset()` фильтрует **list** для пользователей без `view_document`: только `sent_to_all` и документы, где текущий пользователь — активный получатель. * Экшен `acknowledge` больше не переопределяет `permission_classes`; вызывается `self.get_object()` → срабатывает объектная проверка доступа. * (dev) Раздача медиа: убедились, что подключён `urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)` — скачивание по `file_url` отдаёт `200`. ### serializers * Добавлен `RecipientIDsField`: принимает `recipient_ids` в форматах **repeat-params / JSON-строка / CSV**, корректно читает `QueryDict.getlist()`. * `DocumentWriteSerializer`: * `file` теперь **необязателен** на PUT/PATCH, обязателен на POST (проверяется в `validate()`). * Валидация `sent_to_all`/`recipient_ids` учитывает текущее состояние при апдейте; пустой список при `sent_to_all=False` запрещён. * `_set_recipients()` назначает **только активных** пользователей и очищает список при `sent_to_all=True`. * `validate_file()` — отказ по лимиту размера (`DATA_UPLOAD_MAX_MEMORY_SIZE`) с 400. * `DocumentReadSerializer` — `file_url` через `FileField(source="file")`, `is_acknowledged` рассчитывается корректно. ### create response * В `DocumentViewSet.create()` рассчитываем `recipient_count` из **нормализованных входных ID** (до `save()`), считая только активных; при `sent_to_all=True` возвращаем `0`. ### employees * `EmployeeBriefSerializer` дополнен полем `full_name` (для тестов сериализации документов). ### Исправленные тесты/регрессии * `TestCreate::test_create_sent_to_all_false_with_recipients_variants` — теперь `recipient_count` корректен для repeat/JSON/CSV. * `TestUpdate::*` (PUT/PATCH) — больше не требуют `file`, возвращают `200`. * `TestDelete::test_delete_and_cascade_acknowledgements` — `delete_document` достаточно, `403` устранён. * `TestAcknowledge::test_ack_forbidden_if_not_recipient_and_not_sent_to_all` — теперь **403** для чужих документов с `sent_to_all=False`. * `TestSerialization::test_file_url_downloadable_in_dev` — `200` при скачивании `file_url` в dev. * `TestSecurityAndPolicy::test_policy_without_perms_list_is_200_detail_and_ack_require_access` — `list` даёт `200`, `detail/ack` чужого документа с `sent_to_all=False` дают `403`. * `calendar_app`: `TestAuthAndPermissions.test_company_get_ok_regular_user` — `GET` по компании даёт `200`. ### Затронутые файлы (основные) * `backend/api/v1/documents/permissions.py` (+ `DocumentReadOrModelPerms`) * `backend/api/v1/documents/serializers.py` (+ `RecipientIDsField`, правки `DocumentWriteSerializer`, `DocumentReadSerializer`) * `backend/api/v1/documents/views.py` (фильтрация `get_queryset()`, логика `create()`, `acknowledge`) * `backend/api/v1/employees/serializers.py` (+ `full_name`) * `eusrr_backend/urls.py` (проверка `static()` для MEDIA) * *(календарь)* `backend/api/v1/calendar/views.py` (`get_permissions()` для company list) --- **BREAKING**: ACK для документов с `sent_to_all=False` теперь доступен **только получателям** (или при наличии модельных прав/staff).
311 lines
12 KiB
Python
311 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any, Dict, Sequence, Iterable
|
||
|
||
from django.conf import settings
|
||
from django.http import QueryDict
|
||
from django.contrib.auth import get_user_model
|
||
from django.core.files.uploadedfile import UploadedFile
|
||
from django.template.defaultfilters import filesizeformat
|
||
from documents.models import Document, DocumentAcknowledgement
|
||
from rest_framework import serializers
|
||
|
||
from ..employees.serializers import EmployeeBriefSerializer
|
||
|
||
User = get_user_model()
|
||
|
||
|
||
class RecipientIDsField(serializers.Field):
|
||
"""Принимает recipient_ids в форматах: список, JSON-строка или CSV.
|
||
|
||
Примеры:
|
||
- [1, 2, 3]
|
||
- "[1,2,3]"
|
||
- "1,2,3"
|
||
|
||
Возвращает:
|
||
list[int]: Нормализованный список целых ID.
|
||
|
||
Raises:
|
||
serializers.ValidationError: Некорректный формат/значения.
|
||
"""
|
||
|
||
def to_internal_value(self, data: Any) -> list[int]:
|
||
"""Парсит raw-значение из запроса в список int.
|
||
|
||
При multipart (QueryDict) поддерживает repeat-params: recipient_ids=1&recipient_ids=2...
|
||
"""
|
||
# 1) Спец. случай: multipart/QueryDict с repeat-params
|
||
# DRF в обычное Field передаёт "последнее" значение, поэтому
|
||
# забираем полный список через parent.initial_data.getlist(self.field_name)
|
||
parent_data = getattr(self.parent, "initial_data", None) # type: ignore[attr-defined]
|
||
if isinstance(parent_data, QueryDict):
|
||
values = parent_data.getlist(self.field_name) # type: ignore[attr-defined]
|
||
if len(values) > 1:
|
||
data = values
|
||
elif len(values) == 1 and not isinstance(data, (list, tuple)):
|
||
# один элемент: оставим строкой — ниже распарсится как JSON/CSV/число
|
||
data = values[0]
|
||
|
||
# 2) Уже список/кортеж?
|
||
if isinstance(data, (list, tuple)):
|
||
# если это ['1','2','3'] — ок; если это ['[1,2]'] — распакуем как строку
|
||
if (
|
||
len(data) == 1
|
||
and isinstance(data[0], str)
|
||
and (data[0].strip().startswith("[") or "," in data[0])
|
||
):
|
||
data = data[0]
|
||
else:
|
||
vals = list(data)
|
||
# 3) Строка: JSON-массив или CSV или одно число
|
||
if isinstance(data, str):
|
||
s = data.strip()
|
||
try:
|
||
if s.startswith("[") and s.endswith("]"):
|
||
parsed = json.loads(s)
|
||
if not isinstance(parsed, list):
|
||
raise serializers.ValidationError("Ожидается JSON-массив ID.")
|
||
vals = parsed
|
||
elif "," in s:
|
||
vals = [p.strip() for p in s.split(",") if p.strip()]
|
||
else:
|
||
vals = [s]
|
||
except serializers.ValidationError:
|
||
raise
|
||
except Exception as e:
|
||
raise serializers.ValidationError(
|
||
f"Неверный формат recipient_ids: {e!s}"
|
||
)
|
||
elif data in (None, ""):
|
||
vals = []
|
||
elif not isinstance(data, (list, tuple)):
|
||
raise serializers.ValidationError(
|
||
"Ожидается список, JSON-строка или CSV-строка."
|
||
)
|
||
|
||
# Приведение к int
|
||
try:
|
||
return [int(x) for x in vals]
|
||
except Exception:
|
||
raise serializers.ValidationError(
|
||
"ID получателей должны быть целыми числами."
|
||
)
|
||
|
||
def to_representation(self, value: Iterable[int]) -> list[int]:
|
||
"""Отдаёт список ID (на всякий случай; поле write_only)."""
|
||
if value is None:
|
||
return []
|
||
return list(value)
|
||
|
||
|
||
class DocumentReadSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для чтения документа.
|
||
|
||
Включает список получателей и флаг, ознакомился ли текущий пользователь.
|
||
"""
|
||
|
||
uploaded_by = EmployeeBriefSerializer(read_only=True)
|
||
recipients = EmployeeBriefSerializer(many=True, read_only=True)
|
||
file_url = serializers.FileField(source="file", read_only=True)
|
||
is_acknowledged = serializers.SerializerMethodField()
|
||
|
||
class Meta:
|
||
model = Document
|
||
fields = (
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"uploaded_by",
|
||
"uploaded_at",
|
||
"sent_to_all",
|
||
"recipients",
|
||
"file_url",
|
||
"is_acknowledged",
|
||
)
|
||
|
||
def get_is_acknowledged(self, obj: Document) -> bool:
|
||
"""Возвращает флаг ознакомления текущего пользователя.
|
||
|
||
Args:
|
||
obj (Document): Документ.
|
||
|
||
Returns:
|
||
bool: True, если текущий пользователь уже ознакомился.
|
||
"""
|
||
annotated = getattr(obj, "_is_acknowledged", None)
|
||
if annotated is not None:
|
||
return bool(annotated)
|
||
|
||
request = self.context.get("request")
|
||
if not request or not request.user.is_authenticated:
|
||
return False
|
||
return DocumentAcknowledgement.objects.filter(
|
||
document=obj, user=request.user
|
||
).exists()
|
||
|
||
|
||
class DocumentWriteSerializer(serializers.ModelSerializer):
|
||
"""Сериализатор для записи/обновления документа.
|
||
|
||
Поддерживает:
|
||
- загрузку файла (multipart)
|
||
- sent_to_all
|
||
- recipient_ids (repeat/JSON/CSV) при sent_to_all=false
|
||
"""
|
||
|
||
recipient_ids = RecipientIDsField(
|
||
write_only=True,
|
||
required=False,
|
||
help_text="Список ID сотрудников (repeat/JSON/CSV) при sent_to_all=false.",
|
||
)
|
||
|
||
class Meta:
|
||
model = Document
|
||
fields = (
|
||
"id",
|
||
"title",
|
||
"description",
|
||
"file",
|
||
"sent_to_all",
|
||
"recipient_ids",
|
||
)
|
||
extra_kwargs = {
|
||
"file": {"required": False, "allow_null": True},
|
||
}
|
||
|
||
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Согласованность полей.
|
||
|
||
Args:
|
||
attrs (dict): Данные после field-level валидаций.
|
||
|
||
Returns:
|
||
dict: Те же данные.
|
||
|
||
Raises:
|
||
serializers.ValidationError: Если sent_to_all=false и нет recipient_ids.
|
||
"""
|
||
if self.instance is None and not attrs.get("file"):
|
||
raise serializers.ValidationError({"file": "Обязательное поле."})
|
||
|
||
sent_to_all = attrs.get("sent_to_all", True)
|
||
recipient_ids = attrs.get("recipient_ids", [])
|
||
if sent_to_all is False and not recipient_ids:
|
||
raise serializers.ValidationError(
|
||
{
|
||
"recipient_ids": "Укажите получателей или установите sent_to_all=true."
|
||
}
|
||
)
|
||
return attrs
|
||
|
||
def _set_recipients(self, doc: Document, recipient_ids: Sequence[int]) -> int:
|
||
"""Привязывает получателей по списку ID (только активные).
|
||
|
||
Args:
|
||
doc (Document): Документ.
|
||
recipient_ids (Sequence[int]): Идентификаторы пользователей.
|
||
|
||
Returns:
|
||
int: Количество привязанных получателей.
|
||
"""
|
||
if not recipient_ids:
|
||
doc.recipients.clear()
|
||
return 0
|
||
users = User.objects.filter(is_active=True, id__in=set(recipient_ids))
|
||
doc.recipients.set(users)
|
||
return users.count()
|
||
|
||
def create(self, validated_data: Dict[str, Any]) -> Document:
|
||
"""Создание документа с корректной установкой получателей и уведомлением.
|
||
|
||
Args:
|
||
validated_data (dict): Данные.
|
||
|
||
Returns:
|
||
Document: Созданный документ.
|
||
"""
|
||
recipient_ids = validated_data.pop("recipient_ids", [])
|
||
request = self.context.get("request")
|
||
uploader = getattr(request, "user", None)
|
||
|
||
# временно отключаем сигнал, чтобы M2M успели установиться
|
||
from django.db.models.signals import post_save
|
||
|
||
try:
|
||
from documents.signals import on_document_saved # type: ignore
|
||
except Exception:
|
||
on_document_saved = None
|
||
|
||
if on_document_saved:
|
||
post_save.disconnect(on_document_saved, sender=Document)
|
||
|
||
try:
|
||
doc = Document.objects.create(uploaded_by=uploader, **validated_data) # type: ignore[arg-type]
|
||
if validated_data.get("sent_to_all") is False:
|
||
self._set_recipients(doc, recipient_ids)
|
||
finally:
|
||
if on_document_saved:
|
||
post_save.connect(on_document_saved, sender=Document)
|
||
|
||
# уведомление после полной подготовки
|
||
try:
|
||
from documents.notification import notify_users_about_document # type: ignore
|
||
|
||
notify_users_about_document(doc)
|
||
except Exception:
|
||
pass
|
||
|
||
return doc
|
||
|
||
def update(self, instance: Document, validated_data: Dict[str, Any]) -> Document:
|
||
"""Частичное обновление документа.
|
||
|
||
Args:
|
||
instance (Document): Экземпляр.
|
||
validated_data (dict): Данные.
|
||
|
||
Returns:
|
||
Document: Обновлённый документ.
|
||
"""
|
||
recipient_ids = validated_data.pop("recipient_ids", None)
|
||
for f in ("title", "description", "sent_to_all", "file"):
|
||
if f in validated_data:
|
||
setattr(instance, f, validated_data[f])
|
||
instance.save()
|
||
|
||
# очищаем получателей при sent_to_all=true
|
||
if "sent_to_all" in validated_data and instance.sent_to_all:
|
||
instance.recipients.clear()
|
||
elif recipient_ids is not None:
|
||
self._set_recipients(instance, recipient_ids)
|
||
|
||
return instance
|
||
|
||
def validate_file(self, f: UploadedFile) -> UploadedFile:
|
||
"""Проверяет, что размер загружаемого файла не превышает системный лимит.
|
||
|
||
Лимит берётся из settings.DATA_UPLOAD_MAX_MEMORY_SIZE. Если он задан (truthy),
|
||
и размер файла больше лимита, возвращается 400 (ValidationError).
|
||
|
||
Args:
|
||
f (UploadedFile): Загружаемый файл.
|
||
|
||
Returns:
|
||
UploadedFile: Исходный файл при успешной проверке.
|
||
|
||
Raises:
|
||
serializers.ValidationError: Размер файла превышает допустимый лимит.
|
||
TypeError: Если невозможно определить размер файла.
|
||
"""
|
||
limit = getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", None)
|
||
if limit:
|
||
size = getattr(f, "size", None)
|
||
if size is None:
|
||
raise TypeError("Невозможно определить размер файла")
|
||
if int(size) > int(limit):
|
||
human = filesizeformat(limit)
|
||
raise serializers.ValidationError(f"Файл слишком большой: > {human}.")
|
||
return f
|