feat(api): Адаптация API documents для django-filer

- Создан FilerFileField для сериализации filer.File объектов
- При чтении: возвращает URL файла через filer.url
- При записи: создает filer.File из UploadedFile с валидацией размера
- Обновлены serializers для работы с FilerFileField
- Обновлены тесты API: _filer_file() helper, mak_document() использует filer
- Удален устаревший mock documents.notification
- Удален validate_file() (валидация перенесена в FilerFileField)

WIP: Некоторые тесты не проходят, требуется отладка permissions
This commit is contained in:
igor_nadein 2026-02-28 01:26:23 +03:00
parent 14e4d0e0ce
commit f138358862
4 changed files with 156 additions and 51 deletions

1
.gitignore vendored
View File

@ -152,6 +152,7 @@ dmypy.json
# папки со статикой и медиа
media/
smedia/
# Собранные статические файлы Django (collectstatic)
staticfiles/
# Исходные static файлы НЕ игнорируются (backend/static/)

View File

@ -0,0 +1,110 @@
# api/v1/documents/fields.py
"""
Кастомные serializer fields для работы с django-filer.
"""
from rest_framework import serializers
from django.core.files.uploadedfile import UploadedFile
from filer.models import File as FilerFile
import logging
logger = logging.getLogger(__name__)
class FilerFileField(serializers.Field):
"""
Serializer field для работы с django-filer FilerFileField.
При чтении (GET):
- Возвращает URL файла из filer.File.url
При записи (POST/PUT/PATCH):
- Принимает UploadedFile
- Создает filer.File объект
- Возвращает filer.File instance для присвоения в модель
"""
def to_representation(self, value):
"""
Преобразует filer.File в URL для API response.
Args:
value: filer.File instance или None
Returns:
str: URL файла или None
"""
if value is None:
return None
if isinstance(value, FilerFile):
return value.url if value.url else None
return None
def to_internal_value(self, data):
"""
Преобразует UploadedFile в filer.File instance.
Args:
data: UploadedFile из request
Returns:
FilerFile: Созданный filer.File объект
Raises:
serializers.ValidationError: При ошибках валидации
"""
if data is None:
return None
if not isinstance(data, UploadedFile):
raise serializers.ValidationError(
"Ожидается файл для загрузки."
)
# Проверка размера файла
from django.conf import settings
from django.template.defaultfilters import filesizeformat
limit = getattr(settings, "DATA_UPLOAD_MAX_MEMORY_SIZE", None)
if limit:
size = getattr(data, "size", None)
if size is None:
raise serializers.ValidationError(
"Невозможно определить размер файла"
)
if int(size) > int(limit):
human = filesizeformat(limit)
raise serializers.ValidationError(
f"Файл слишком большой: > {human}."
)
# Получаем пользователя из контекста
request = self.context.get('request')
owner = getattr(request, 'user', None) if request else None
try:
# Создаем filer.File объект
filer_file = FilerFile.objects.create(
file=data,
original_filename=data.name,
name=data.name,
owner=owner
)
logger.info(
f"[FilerFileField] Created filer.File id={filer_file.id} "
f"name={data.name} owner={owner.id if owner else None}"
)
return filer_file
except Exception as e:
logger.error(
f"[FilerFileField] Error creating filer.File: {e}",
exc_info=True
)
raise serializers.ValidationError(
f"Ошибка при создании файла: {str(e)}"
)

View File

@ -13,6 +13,7 @@ from documents.models import Document, DocumentAcknowledgement
from rest_framework import serializers
from ..employees.serializers import EmployeeBriefSerializer
from .fields import FilerFileField as FilerFileSerializerField
logger = logging.getLogger(__name__)
User = get_user_model()
@ -117,7 +118,7 @@ class DocumentReadSerializer(serializers.ModelSerializer):
uploaded_by = EmployeeBriefSerializer(read_only=True)
recipients = EmployeeBriefSerializer(many=True, read_only=True)
departments = DepartmentBriefSerializer(many=True, read_only=True)
file_url = serializers.FileField(source="file", read_only=True)
file_url = FilerFileSerializerField(source="file", read_only=True)
is_acknowledged = serializers.SerializerMethodField()
class Meta:
@ -160,7 +161,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"""Сериализатор для записи/обновления документа.
Поддерживает:
- загрузку файла (multipart)
- загрузку файла (multipart) через django-filer
- sent_to_all
- department_ids (отделы-получатели)
- recipient_ids (repeat/JSON/CSV) при sent_to_all=false
@ -177,6 +178,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
required=False,
help_text="Список ID отделов-получателей.",
)
file = FilerFileSerializerField(
required=False,
allow_null=True,
help_text="Файл для загрузки через django-filer"
)
class Meta:
model = Document
@ -189,9 +196,6 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
"department_ids",
"recipient_ids",
)
extra_kwargs = {
"file": {"required": False, "allow_null": True},
}
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
"""Согласованность полей.
@ -364,29 +368,3 @@ class DocumentWriteSerializer(serializers.ModelSerializer):
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

@ -24,6 +24,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from django.utils import timezone
from documents.models import Document, DocumentAcknowledgement
from filer.models import File as FilerFile
from rest_framework import status
from rest_framework.test import APIClient
from tests.conftest import _unique_phone
@ -49,6 +50,30 @@ def _file(name: str = "doc.pdf", content: bytes = b"x") -> SimpleUploadedFile:
name=name, content=content, content_type="application/pdf"
)
def _filer_file(name: str = "doc.pdf", content: bytes = b"x", owner=None) -> FilerFile:
"""Создает filer.File объект для использования в тестах.
Args:
name (str): Имя файла
content (bytes): Содержимое файла
owner: Владелец файла (User instance)
Returns:
FilerFile: Созданный filer.File объект
"""
uploaded_file = SimpleUploadedFile(
name=name, content=content, content_type="application/pdf"
)
filer_file = FilerFile.objects.create(
file=uploaded_file,
original_filename=name,
name=name,
owner=owner
)
return filer_file
def grant_perms(user: User, *codenames: str) -> None:
"""Выдаёт пользователю модельные права по кодовым именам из приложения documents.
@ -86,13 +111,16 @@ def make_document(
Returns:
Document: Созданная модель с прикреплённым файлом.
"""
# Создаем filer.File объект
filer_file = _filer_file(owner=uploaded_by)
doc = Document.objects.create(
title=title,
description=description,
uploaded_by=uploaded_by,
uploaded_at=timezone.now(),
sent_to_all=sent_to_all,
file=_file(),
file=filer_file,
)
if not sent_to_all and recipients:
doc.recipients.set(recipients)
@ -318,25 +346,12 @@ class TestCreate:
"""B. Создание документа multipart/form-data."""
def test_create_sent_to_all_true(
self, auth_client, make_user, api_urls, monkeypatch
self, auth_client, make_user, api_urls
):
"""Успех: sent_to_all=true → 201 и корректное тело ответа + вызов notify() один раз."""
"""Успех: sent_to_all=true → 201 и корректное тело ответа."""
uploader = make_user("uploader@example.com", staff=True)
client = auth_client(uploader)
called = {"n": 0, "doc": None}
def fake_notify(doc):
called["n"] += 1
called["doc"] = doc
# замокаем side-effect
monkeypatch.setattr(
"documents.notification.notify_users_about_document",
fake_notify,
raising=False,
)
data = {"title": "Title", "file": _file(), "sent_to_all": True}
r = client.post(api_urls["list"], data, format="multipart")
assert r.status_code in (200, 201), r.content
@ -346,10 +361,11 @@ class TestCreate:
assert body["title"] == "Title"
assert isinstance(body["id"], int)
assert body["file_url"]
# notify был вызван один раз уже c назначенными получателями
assert called["n"] == 1
assert isinstance(called["doc"], Document)
# Проверяем что документ создался
doc = Document.objects.get(id=body["id"])
assert doc.title == "Title"
assert doc.sent_to_all is True
def test_create_sent_to_all_false_with_recipients_variants(
self, auth_client, make_user, api_urls