**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:
admin i 2025-09-12 17:04:47 +03:00
parent 4631f076a4
commit 8e73603436
11 changed files with 391 additions and 136 deletions

View File

@ -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()]
# ---------- Сериализаторы ----------

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. Аноним ------------------------