From f1383588622bc0954d773a3e9e63fb90ee4bae64 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Sat, 28 Feb 2026 01:26:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D0=90=D0=B4=D0=B0=D0=BF=D1=82?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20API=20documents=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20django-filer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан 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 --- .gitignore | 1 + backend/api/v1/documents/fields.py | 110 ++++++++++++++++++ backend/api/v1/documents/serializers.py | 40 ++----- .../api/v1/documents/test_documents_api.py | 56 +++++---- 4 files changed, 156 insertions(+), 51 deletions(-) create mode 100644 backend/api/v1/documents/fields.py diff --git a/.gitignore b/.gitignore index fa449d61..b9d15612 100644 --- a/.gitignore +++ b/.gitignore @@ -152,6 +152,7 @@ dmypy.json # папки со статикой и медиа media/ +smedia/ # Собранные статические файлы Django (collectstatic) staticfiles/ # Исходные static файлы НЕ игнорируются (backend/static/) diff --git a/backend/api/v1/documents/fields.py b/backend/api/v1/documents/fields.py new file mode 100644 index 00000000..c667c796 --- /dev/null +++ b/backend/api/v1/documents/fields.py @@ -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)}" + ) diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index e3920762..738cf90f 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -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 diff --git a/backend/tests/api/v1/documents/test_documents_api.py b/backend/tests/api/v1/documents/test_documents_api.py index 6c2da03d..c2f97f70 100644 --- a/backend/tests/api/v1/documents/test_documents_api.py +++ b/backend/tests/api/v1/documents/test_documents_api.py @@ -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