**feat(documents, calendar): привести API к спецификации и починить тесты**
### Основное * Политика доступа для документов: * **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).
This commit is contained in:
parent
9529b3f268
commit
25ae9a40b8
@ -51,21 +51,16 @@ class CalendarEventsViewSet(ModelViewSet):
|
||||
# ---------- Подключение пермишенов ----------
|
||||
|
||||
def get_permissions(self):
|
||||
"""Для компании — AdminOrActionOrModelPerms, для отдела — ManageCalendarPerm.
|
||||
|
||||
ВАЖНО: прокидываем найденный department_id в kwargs, чтобы пермишен
|
||||
гарантированно его увидел на этапе has_permission().
|
||||
"""
|
||||
dep = self._dept_id(required=False)
|
||||
if dep is None:
|
||||
|
||||
# компания
|
||||
if getattr(self, "action", None) == "list":
|
||||
# список событий компании — достаточно быть залогиненным
|
||||
return [IsAuthenticated()]
|
||||
return [AdminOrActionOrModelPerms()]
|
||||
|
||||
# События отдела: подложим dep в kwargs (если его не было)
|
||||
# чтобы AdminOrDeptAllowed нашёл department_id без чтения request.data
|
||||
self.kwargs = dict(self.kwargs) # скопировать, т.к. может быть MappingProxy
|
||||
# отдел
|
||||
self.kwargs = dict(self.kwargs)
|
||||
self.kwargs.setdefault("department_id", dep)
|
||||
|
||||
return [self.ManageCalendarPerm()]
|
||||
|
||||
# ---------- Сериализаторы ----------
|
||||
|
||||
69
backend/api/v1/documents/permissions.py
Normal file
69
backend/api/v1/documents/permissions.py
Normal file
@ -0,0 +1,69 @@
|
||||
# backend/api/v1/documents/permissions.py
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
from copy import deepcopy
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
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
|
||||
|
||||
|
||||
def _has_any_model_perm(
|
||||
user, app_label: str, model_name: str, actions=("view", "change", "add", "delete")
|
||||
) -> bool:
|
||||
"""Проверяет наличие любого модельного права из набора."""
|
||||
for act in actions:
|
||||
code = f"{act}_{model_name}"
|
||||
if user.has_perm(f"{app_label}.{code}"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class DocumentReadOrModelPerms(DjangoModelPermissions):
|
||||
"""Чтение/скачивание/ознакомление: любой аутентифицированный,
|
||||
но доступ только к 'своим' документам (или если есть модельные права/админ).
|
||||
Создание/редактирование/удаление: только админы и обладатели модельных прав.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: если нет доступа к конкретному документу.
|
||||
"""
|
||||
|
||||
perms_map = deepcopy(DjangoModelPermissions.perms_map)
|
||||
perms_map["GET"] = []
|
||||
perms_map["HEAD"] = []
|
||||
perms_map["OPTIONS"] = []
|
||||
|
||||
def has_permission(self, request: Request, view: View) -> bool:
|
||||
# SAFE + acknowledge/download → достаточно быть аутентифицированным
|
||||
if request.method in SAFE_METHODS or getattr(view, "action", None) in (
|
||||
"acknowledge",
|
||||
"download",
|
||||
):
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
# небезопасные → staff ИЛИ стандартные модельные права (add/change/delete)
|
||||
if request.user and request.user.is_staff:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
def has_object_permission(self, request: Request, view: View, obj: Any) -> bool:
|
||||
user = request.user
|
||||
if not (user and user.is_authenticated):
|
||||
return False
|
||||
|
||||
# Полный доступ при наличии ЛЮБОГО модельного права или staff
|
||||
if user.is_staff or _has_any_model_perm(user, "documents", "document"):
|
||||
return True
|
||||
|
||||
# SAFE/ack/download без модельных прав → только получатели или sent_to_all
|
||||
if request.method in SAFE_METHODS or getattr(view, "action", None) in (
|
||||
"acknowledge",
|
||||
"download",
|
||||
):
|
||||
if getattr(obj, "sent_to_all", False):
|
||||
return True
|
||||
return obj.recipients.filter(pk=user.pk, is_active=True).exists()
|
||||
|
||||
# Небезопасные без прав — нельзя
|
||||
return False
|
||||
@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Sequence
|
||||
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
|
||||
|
||||
@ -11,6 +16,90 @@ 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):
|
||||
"""Сериализатор для чтения документа.
|
||||
|
||||
@ -37,7 +126,7 @@ class DocumentReadSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
def get_is_acknowledged(self, obj: Document) -> bool:
|
||||
"""Проверяет, отмечал ли текущий пользователь ознакомление.
|
||||
"""Возвращает флаг ознакомления текущего пользователя.
|
||||
|
||||
Args:
|
||||
obj (Document): Документ.
|
||||
@ -45,6 +134,10 @@ class DocumentReadSerializer(serializers.ModelSerializer):
|
||||
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
|
||||
@ -58,17 +151,14 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
|
||||
|
||||
Поддерживает:
|
||||
- загрузку файла (multipart)
|
||||
- `sent_to_all`
|
||||
- `recipient_ids` (список ID пользователей), если `sent_to_all == False`
|
||||
- sent_to_all
|
||||
- recipient_ids (repeat/JSON/CSV) при sent_to_all=false
|
||||
"""
|
||||
|
||||
# Принимаем список ID вместо M2M-структуры
|
||||
recipient_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(min_value=1),
|
||||
recipient_ids = RecipientIDsField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
help_text="Список ID сотрудников (используется, когда sent_to_all=false).",
|
||||
help_text="Список ID сотрудников (repeat/JSON/CSV) при sent_to_all=false.",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -81,19 +171,25 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
|
||||
"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): Данные.
|
||||
attrs (dict): Данные после field-level валидаций.
|
||||
|
||||
Returns:
|
||||
dict: Провалидированные данные.
|
||||
dict: Те же данные.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: При логических несоответствиях.
|
||||
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:
|
||||
@ -105,7 +201,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
def _set_recipients(self, doc: Document, recipient_ids: Sequence[int]) -> int:
|
||||
"""Привязывает получателей по списку ID.
|
||||
"""Привязывает получателей по списку ID (только активные).
|
||||
|
||||
Args:
|
||||
doc (Document): Документ.
|
||||
@ -117,30 +213,24 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
|
||||
if not recipient_ids:
|
||||
doc.recipients.clear()
|
||||
return 0
|
||||
users = User.objects.filter(is_active=True, id__in=list(set(recipient_ids)))
|
||||
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:
|
||||
"""Создание документа.
|
||||
|
||||
ВАЖНО: чтобы уведомления (signals) не улетели до установки M2M,
|
||||
мы временно отключим `post_save` сигнал и вызовем уведомление вручную уже после назначения получателей.
|
||||
"""Создание документа с корректной установкой получателей и уведомлением.
|
||||
|
||||
Args:
|
||||
validated_data (dict): Данные.
|
||||
|
||||
Returns:
|
||||
Document: Созданный документ.
|
||||
|
||||
Raises:
|
||||
serializers.ValidationError: Если входные данные недопустимы.
|
||||
"""
|
||||
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:
|
||||
@ -152,56 +242,69 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
|
||||
post_save.disconnect(on_document_saved, sender=Document)
|
||||
|
||||
try:
|
||||
doc = Document.objects.create(
|
||||
uploaded_by=uploader, # type: ignore[arg-type]
|
||||
**validated_data,
|
||||
)
|
||||
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
|
||||
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:
|
||||
"""Обновление документа.
|
||||
|
||||
Сценарии:
|
||||
- можно заменить файл/описание/заголовок
|
||||
- переключить `sent_to_all`
|
||||
- обновить получателей через `recipient_ids`
|
||||
"""Частичное обновление документа.
|
||||
|
||||
Args:
|
||||
instance (Document): Документ для обновления.
|
||||
validated_data (dict): Новые данные.
|
||||
instance (Document): Экземпляр.
|
||||
validated_data (dict): Данные.
|
||||
|
||||
Returns:
|
||||
Document: Обновлённый документ.
|
||||
"""
|
||||
recipient_ids = validated_data.pop("recipient_ids", None)
|
||||
|
||||
for field in ("title", "description", "sent_to_all", "file"):
|
||||
if field in validated_data:
|
||||
setattr(instance, field, validated_data[field])
|
||||
for f in ("title", "description", "sent_to_all", "file"):
|
||||
if f in validated_data:
|
||||
setattr(instance, f, validated_data[f])
|
||||
instance.save()
|
||||
|
||||
if recipient_ids is not None:
|
||||
# Если теперь "всем" — очистим M2M
|
||||
if validated_data.get("sent_to_all", instance.sent_to_all):
|
||||
instance.recipients.clear()
|
||||
else:
|
||||
self._set_recipients(instance, recipient_ids)
|
||||
# очищаем получателей при 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
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Count, Exists, OuterRef, QuerySet
|
||||
from documents.models import Document, DocumentAcknowledgement
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@ -15,6 +15,7 @@ from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from ..permissions import AdminOrActionOrModelPerms
|
||||
from .permissions import DocumentReadOrModelPerms
|
||||
from .serializers import DocumentReadSerializer, DocumentWriteSerializer
|
||||
|
||||
User = get_user_model()
|
||||
@ -31,39 +32,88 @@ class DocumentViewSet(ModelViewSet):
|
||||
.select_related("uploaded_by")
|
||||
.prefetch_related("recipients")
|
||||
)
|
||||
permission_classes = [AdminOrActionOrModelPerms]
|
||||
permission_classes = [DocumentReadOrModelPerms]
|
||||
parser_classes = (MultiPartParser, FormParser, JSONParser)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Выбор сериализатора: на чтение — Read, на запись — Write."""
|
||||
if self.action in ("list", "retrieve"):
|
||||
return DocumentReadSerializer
|
||||
return DocumentWriteSerializer
|
||||
"""Возвращает сериализатор в зависимости от действия.
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Response: 201 Created с данными документа (read-сериализатор).
|
||||
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})
|
||||
return Response(read.data, status=status.HTTP_201_CREATED)
|
||||
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)
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Полное обновление документа."""
|
||||
return super().update(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)
|
||||
|
||||
@transaction.atomic
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""Частичное обновление документа."""
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
"""Частичное обновление (PATCH) — аналогично update, но partial=True."""
|
||||
kwargs["partial"] = True
|
||||
return self.update(request, *args, **kwargs)
|
||||
|
||||
@action(methods=["post"], detail=True, permission_classes=[IsAuthenticated])
|
||||
@action(methods=["post"], detail=True)
|
||||
def acknowledge(self, request, pk=None):
|
||||
"""Отметить ознакомление текущего пользователя с документом.
|
||||
|
||||
@ -79,3 +129,4 @@ class DocumentViewSet(ModelViewSet):
|
||||
document=doc, user=request.user
|
||||
)
|
||||
return Response({"ok": True, "already": not created})
|
||||
|
||||
|
||||
@ -369,6 +369,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
class EmployeeBriefSerializer(serializers.ModelSerializer):
|
||||
display_name = serializers.SerializerMethodField()
|
||||
avatar = Base64ImageField(read_only=True)
|
||||
full_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
@ -380,6 +381,7 @@ class EmployeeBriefSerializer(serializers.ModelSerializer):
|
||||
"email",
|
||||
"phone_number",
|
||||
"display_name",
|
||||
"full_name",
|
||||
"avatar",
|
||||
)
|
||||
|
||||
@ -397,6 +399,15 @@ class EmployeeBriefSerializer(serializers.ModelSerializer):
|
||||
return obj.phone_number
|
||||
return f"Сотрудник #{obj.id}"
|
||||
|
||||
def get_full_name(self, obj: Employee) -> str:
|
||||
"""Полное имя для совместимости с тестами документов."""
|
||||
parts = [
|
||||
obj.last_name or "",
|
||||
obj.first_name or "",
|
||||
getattr(obj, "patronymic", "") or "",
|
||||
]
|
||||
return " ".join(p.strip() for p in parts if p)
|
||||
|
||||
|
||||
class DepartmentSerializer(serializers.ModelSerializer):
|
||||
head = EmployeeBriefSerializer(read_only=True)
|
||||
|
||||
@ -3,6 +3,7 @@ from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse, NoReverseMatch
|
||||
|
||||
|
||||
class AuthRequiredMiddleware:
|
||||
"""
|
||||
Разрешает доступ НЕавторизованным только к страницам логина/регистрации/верификации
|
||||
@ -10,11 +11,12 @@ class AuthRequiredMiddleware:
|
||||
"""
|
||||
|
||||
# Префиксы, которые всегда пропускаем (настройка через settings при желании)
|
||||
|
||||
EXEMPT_PREFIXES = (
|
||||
"/api/",
|
||||
"/auth/reset/",
|
||||
# "/static/", # статика
|
||||
# "/media/", # медиа
|
||||
"/static/", # статика
|
||||
"/media/", # медиа
|
||||
# "/__debug__/", # django-debug-toolbar, если используется
|
||||
)
|
||||
|
||||
@ -26,8 +28,7 @@ class AuthRequiredMiddleware:
|
||||
"auth_front:verify_email",
|
||||
"auth_front:resend_email",
|
||||
"auth_front:password_reset_done",
|
||||
|
||||
"auth_front"
|
||||
"auth_front",
|
||||
)
|
||||
|
||||
def __init__(self, get_response):
|
||||
|
||||
@ -9,7 +9,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key")
|
||||
DEBUG = os.getenv("DEBUG", "true").lower() == "true"
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = eusrr_backend.settings
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
python_files = tests.py test_*.py *_tests.py
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@ -4,25 +4,30 @@ from __future__ import annotations
|
||||
"""
|
||||
Тесты CRUD API документов (/api/v1/documents/ + экшен acknowledge) по чек-листу.
|
||||
|
||||
ВАЖНО: API ещё не реализовано — эти тесты задают ожидаемое поведение (TDD).
|
||||
Политика доступа (актуальная):
|
||||
- Создавать/редактировать/удалять — только админы и обладатели модельных прав.
|
||||
- Читать/скачивать/ознакомливаться — только аутентифицированные, и только документы,
|
||||
предназначенные им (или с sent_to_all=True). Получатели не имеют доступ к документам,
|
||||
которые им не предназначались.
|
||||
|
||||
Все функции и классы снабжены докстрингами (Google-style).
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import tempfile
|
||||
from typing import Iterable
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.conf import settings
|
||||
from documents.models import Document, DocumentAcknowledgement
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from documents.models import Document, DocumentAcknowledgement
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
User = get_user_model()
|
||||
@ -158,6 +163,12 @@ def api_urls():
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_media_files(settings):
|
||||
settings.MEDIA_ROOT = tempfile.mkdtemp()
|
||||
settings.DEBUG = True
|
||||
|
||||
|
||||
# -------------------- A. Роли и доступ --------------------
|
||||
|
||||
|
||||
@ -201,18 +212,22 @@ class TestAuthAndPermissions:
|
||||
# acknowledge action
|
||||
assert api_client.post(api_urls["ack"](d.pk), {}).status_code == 401
|
||||
|
||||
def test_regular_user_forbidden_except_ack(self, auth_client, make_user, api_urls):
|
||||
"""Обычный user без прав: CRUD → 403, ACK → 200.
|
||||
|
||||
По текущей спецификации экшен acknowledge пускает любого аутентифицированного.
|
||||
Этот тест фиксирует такое поведение.
|
||||
def test_regular_user_access_according_to_policy(
|
||||
self, auth_client, make_user, api_urls
|
||||
):
|
||||
"""Обычный user без модельных прав:
|
||||
- list → 200 (пустой список, если нет доступных документов);
|
||||
- CRUD → 403;
|
||||
- detail/ack чужого документа с sent_to_all=False → 403.
|
||||
"""
|
||||
u = make_user("u@example.com")
|
||||
client = auth_client(u)
|
||||
|
||||
list_url = api_urls["list"]
|
||||
assert client.get(list_url).status_code == 403
|
||||
# POST (multipart)
|
||||
# list — 200 (даже без прав), но результаты ограничены доступными документами
|
||||
assert client.get(list_url).status_code == 200
|
||||
|
||||
# POST (multipart) — запрещено
|
||||
resp = client.post(
|
||||
list_url,
|
||||
{"title": "T", "file": _file(), "sent_to_all": True},
|
||||
@ -220,11 +235,11 @@ class TestAuthAndPermissions:
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
# object
|
||||
# object not intended for user
|
||||
author = make_user("a@example.com")
|
||||
d = make_document(uploaded_by=author)
|
||||
d = make_document(uploaded_by=author, sent_to_all=False)
|
||||
|
||||
# detail & write ops
|
||||
# detail & write ops — запрещено
|
||||
assert client.get(api_urls["detail"](d.pk)).status_code == 403
|
||||
assert (
|
||||
client.put(
|
||||
@ -240,10 +255,9 @@ class TestAuthAndPermissions:
|
||||
)
|
||||
assert client.delete(api_urls["detail"](d.pk)).status_code == 403
|
||||
|
||||
# acknowledge → 200
|
||||
# acknowledge чужого документа (не sent_to_all) — запрещено
|
||||
r = client.post(api_urls["ack"](d.pk), {})
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("ok") is True
|
||||
assert r.status_code == 403
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"perm,method,expected",
|
||||
@ -333,6 +347,7 @@ class TestAuthAndPermissions:
|
||||
|
||||
# -------------------- B. Создание (POST) --------------------
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreate:
|
||||
"""B. Создание документа multipart/form-data."""
|
||||
@ -523,7 +538,18 @@ class TestRead:
|
||||
body = r.json()
|
||||
assert body["is_acknowledged"] is False
|
||||
assert body["file_url"]
|
||||
assert body["file_url"].startswith(settings.MEDIA_URL)
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed_url = urlparse(body["file_url"])
|
||||
assert bool(parsed_url.scheme)
|
||||
assert bool(parsed_url.netloc)
|
||||
assert parsed_url.path.startswith(settings.MEDIA_URL)
|
||||
expected_path = f"{settings.MEDIA_URL}{doc.file.name}"
|
||||
expected_url = f"http://testserver{expected_path}"
|
||||
assert (
|
||||
body["file_url"] == expected_url
|
||||
), f"Ожидался URL: {expected_url}, получен: {body['file_url']}"
|
||||
|
||||
|
||||
# -------------------- D. Обновление (PUT/PATCH) --------------------
|
||||
@ -556,7 +582,8 @@ class TestUpdate:
|
||||
editor = make_user("editor@example.com")
|
||||
grant_perms(editor, "change_document")
|
||||
client = auth_client(editor)
|
||||
d = make_document(uploaded_by=author)
|
||||
d = make_document(uploaded_by=author) # sent_to_all=True по умолчанию
|
||||
|
||||
# получим старый url через detail (этап API)
|
||||
r1 = client.get(api_urls["detail"](d.pk))
|
||||
old_url = r1.json()["file_url"]
|
||||
@ -645,7 +672,7 @@ class TestDelete:
|
||||
r = client.delete(api_urls["detail"](d.pk))
|
||||
assert r.status_code in (200, 204)
|
||||
|
||||
# detail теперь 404
|
||||
# detail теперь 404 (в некоторых реализациях 403 — зависит от политики; оставляем как есть)
|
||||
r2 = client.get(api_urls["detail"](d.pk))
|
||||
assert r2.status_code == 404
|
||||
|
||||
@ -660,14 +687,16 @@ class TestAcknowledge:
|
||||
"""F. Экшен POST /{id}/acknowledge/ — идемпотентность и политика доступа."""
|
||||
|
||||
def test_ack_first_and_repeat(self, auth_client, make_user, api_urls):
|
||||
"""Первый вызов создаёт запись, повторный — already=true (200 оба раза)."""
|
||||
"""Первый вызов создаёт запись, повторный — already=true (200 оба раза).
|
||||
Допустимо для документов с sent_to_all=True (общерассылка).
|
||||
"""
|
||||
author = make_user("author@example.com")
|
||||
any_user = make_user("u@example.com")
|
||||
client = auth_client(any_user)
|
||||
|
||||
d = make_document(
|
||||
uploaded_by=author, sent_to_all=False
|
||||
) # умышленно не в получателях
|
||||
uploaded_by=author, sent_to_all=True
|
||||
) # доступен всем аутентифицированным
|
||||
|
||||
r1 = client.post(api_urls["ack"](d.pk), {})
|
||||
assert r1.status_code == 200
|
||||
@ -701,10 +730,10 @@ class TestAcknowledge:
|
||||
DocumentAcknowledgement.objects.create(document=d, user=viewer)
|
||||
assert client.get(api_urls["detail"](d.pk)).json()["is_acknowledged"] is True
|
||||
|
||||
def test_policy_ack_even_if_not_recipient_current_behavior(
|
||||
def test_ack_forbidden_if_not_recipient_and_not_sent_to_all(
|
||||
self, auth_client, make_user, api_urls
|
||||
):
|
||||
"""Пользователь вне recipients и при sent_to_all=false тоже может ACK (текущая политика)."""
|
||||
"""Пользователь вне recipients при sent_to_all=false — не может ACK (403)."""
|
||||
author = make_user("author@example.com")
|
||||
u = make_user("u@example.com")
|
||||
client = auth_client(u)
|
||||
@ -712,8 +741,7 @@ class TestAcknowledge:
|
||||
r = client.post(
|
||||
api_urls["ack"](make_document(uploaded_by=author, sent_to_all=False).pk), {}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Если это поведение нежелательно — создать тикет на ужесточение.
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# -------------------- G. Сериализация и данные --------------------
|
||||
@ -755,11 +783,11 @@ class TestSerialization:
|
||||
|
||||
d = make_document(uploaded_by=author)
|
||||
file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"]
|
||||
print(file_url)
|
||||
|
||||
# обычный Django client (без APIClient) для скачивания
|
||||
resp = client.get(file_url)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content # не пусто
|
||||
|
||||
|
||||
# -------------------- H. Ошибки/краевые случаи --------------------
|
||||
@ -798,12 +826,15 @@ class TestErrorsAndEdgeCases:
|
||||
assert client.delete(api_urls["detail"](d.pk)).status_code == 403
|
||||
|
||||
def test_get_without_view_perm(self, auth_client, make_user, api_urls):
|
||||
"""GET list/detail без view_document → 403."""
|
||||
"""GET list/detail без view_document:
|
||||
- list → 200 (выдаёт только доступные документы, обычно пусто);
|
||||
- detail чужого документа при sent_to_all=false → 403.
|
||||
"""
|
||||
user = make_user("u@example.com")
|
||||
client = auth_client(user)
|
||||
author = make_user("a@example.com")
|
||||
d = make_document(uploaded_by=author)
|
||||
assert client.get(api_urls["list"]).status_code == 403
|
||||
d = make_document(uploaded_by=author, sent_to_all=False)
|
||||
assert client.get(api_urls["list"]).status_code == 200
|
||||
assert client.get(api_urls["detail"](d.pk)).status_code == 403
|
||||
|
||||
def test_big_file_over_limit_if_configured(
|
||||
@ -847,7 +878,7 @@ class TestPerformance:
|
||||
make_document(uploaded_by=author, title=f"doc{i:02d}")
|
||||
|
||||
# допуская кэш/хитрости, выставим безопасный порог
|
||||
with django_assert_num_queries(10):
|
||||
with django_assert_num_queries(10, exact=False):
|
||||
r = client.get(api_urls["list"])
|
||||
assert r.status_code == 200
|
||||
|
||||
@ -889,17 +920,21 @@ class TestSecurityAndPolicy:
|
||||
dj_settings.REST_FRAMEWORK.get("DEFAULT_PERMISSION_CLASSES", [])
|
||||
)
|
||||
|
||||
def test_policy_ack_without_perms_is_allowed_but_crud_is_not(
|
||||
def test_policy_without_perms_list_is_200_detail_and_ack_require_access(
|
||||
self, auth_client, make_user, api_urls
|
||||
):
|
||||
"""Авторизованный без perms не видит список/деталь (403), но может acknowledge (200) — подтвердить спецификацию."""
|
||||
"""Пользователь без perms:
|
||||
- list → 200 (покажет только доступные доки);
|
||||
- detail чужого документа (sent_to_all=false) → 403;
|
||||
- ack чужого документа (sent_to_all=false) → 403.
|
||||
"""
|
||||
u = make_user("u@example.com")
|
||||
client = auth_client(u)
|
||||
author = make_user("a@example.com")
|
||||
d = make_document(uploaded_by=author, sent_to_all=False)
|
||||
|
||||
assert client.get(api_urls["list"]).status_code == 403
|
||||
assert client.get(api_urls["list"]).status_code == 200
|
||||
assert client.get(api_urls["detail"](d.pk)).status_code == 403
|
||||
|
||||
r = client.post(api_urls["ack"](d.pk), {})
|
||||
assert r.status_code == 200
|
||||
assert r.status_code == 403
|
||||
|
||||
@ -175,23 +175,13 @@ def make_user(user_factory):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth(api_client: APIClient):
|
||||
"""Возвращает функцию для аутентификации клиента под заданным пользователем.
|
||||
|
||||
Args:
|
||||
api_client (APIClient): DRF APIClient.
|
||||
|
||||
Returns:
|
||||
Callable[[User], APIClient]: Аутентифицированный клиент.
|
||||
"""
|
||||
|
||||
def auth():
|
||||
def _as(u: User) -> APIClient:
|
||||
api_client.force_authenticate(user=u)
|
||||
return api_client
|
||||
|
||||
c = APIClient()
|
||||
c.force_authenticate(user=u)
|
||||
return c
|
||||
return _as
|
||||
|
||||
|
||||
# ------------------------ A. Аноним ------------------------
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user