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:
parent
14e4d0e0ce
commit
f138358862
1
.gitignore
vendored
1
.gitignore
vendored
@ -152,6 +152,7 @@ dmypy.json
|
||||
|
||||
# папки со статикой и медиа
|
||||
media/
|
||||
smedia/
|
||||
# Собранные статические файлы Django (collectstatic)
|
||||
staticfiles/
|
||||
# Исходные static файлы НЕ игнорируются (backend/static/)
|
||||
|
||||
110
backend/api/v1/documents/fields.py
Normal file
110
backend/api/v1/documents/fields.py
Normal 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)}"
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user