diff --git a/CHANGELOG.md b/CHANGELOG.md index 886666dea..7f38f41b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to ### Added +- ✨(backend) support creating subdoc from file #1987 - ⚡️(frontend) add skeleton on content loading #2254 - ⚡️(frontend) close websocket connection when user change tab #2264 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 62b4f92ae..f12bd8bd3 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -676,10 +676,13 @@ class DocumentViewSet( return drf.response.Response(serializer.data) - def perform_create(self, serializer): - """Set the current user as creator and owner of the newly created object.""" - # Remove file from validated_data as it's not a model field - # Process it if present + def _apply_uploaded_file_conversion(self, serializer): + """ + Check if a file has been uploaded with a doc or a children is created. + If a file is present and the conversion upload enabled, the file is converted + using the converter service and the validated_data in the serializer are filled + with the converted file and the file name. + """ uploaded_file = serializer.validated_data.pop("file", None) if uploaded_file and not settings.CONVERSION_UPLOAD_ENABLED: @@ -707,6 +710,11 @@ class DocumentViewSet( {"file": ["Could not convert file content"]} ) from err + def perform_create(self, serializer): + """Set the current user as creator and owner of the newly created object.""" + + self._apply_uploaded_file_conversion(serializer) + obj = create_tree_node_with_retry( lambda: models.Document.add_root( creator=self.request.user, @@ -1016,6 +1024,8 @@ class DocumentViewSet( ) serializer.is_valid(raise_exception=True) + self._apply_uploaded_file_conversion(serializer) + child_document = create_tree_node_with_retry( lambda: document.add_child( creator=request.user, diff --git a/src/backend/core/tests/documents/test_api_documents_children_create.py b/src/backend/core/tests/documents/test_api_documents_children_create.py index b39653d5f..3119055d0 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_create.py +++ b/src/backend/core/tests/documents/test_api_documents_children_create.py @@ -3,6 +3,8 @@ Tests for Documents API endpoint in impress's core app: children create """ from concurrent.futures import ThreadPoolExecutor +from io import BytesIO +from unittest.mock import patch from uuid import uuid4 import pytest @@ -10,6 +12,7 @@ from rest_framework.test import APIClient from core import factories from core.models import Document, LinkReachChoices, LinkRoleChoices +from core.services import mime_types pytestmark = pytest.mark.django_db @@ -292,3 +295,142 @@ def test_api_documents_create_document_children_race_condition(): document.refresh_from_db() assert document.numchild == 2 + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_children_create_with_docx_file_success(mock_convert, settings): + """ + Authenticated users should be able to create children document by uploading a DOCX file. + The file should be converted to YJS format and the title should be set from filename. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + settings.CONVERSION_UPLOAD_ENABLED = True + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "My Important Document.docx" + + parent = factories.DocumentFactory(creator=user, users=[(user, "owner")]) + + response = client.post( + f"/api/v1.0/documents/{parent.id}/children/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + assert Document.objects.count() == 2 + children = Document.objects.get(pk=response.json()["id"]) + assert children.title == "My Important Document.docx" + assert children.content == converted_yjs + + # Verify the converter was called correctly + mock_convert.assert_called_once_with( + file_content, + content_type=mime_types.DOCX, + accept=mime_types.YJS, + ) + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_children_create_with_docx_file_disabled(mock_convert, settings): + """ + When conversion is not enabled, uploading a file should have no effect + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + settings.CONVERSION_UPLOAD_ENABLED = False + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "My Important Document.docx" + + parent = factories.DocumentFactory(creator=user, users=[(user, "owner")]) + + response = client.post( + f"/api/v1.0/documents/{parent.id}/children/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["file upload is not allowed"]} + + # Verify the converter was not called + mock_convert.assert_not_called() + + +def test_api_documents_children_create_with_file_max_size_exceeded(settings): + """ + The uploaded file should not exceed the maximum size in settings. + """ + settings.CONVERSION_FILE_MAX_SIZE = 1 # 1 byte for test + settings.CONVERSION_UPLOAD_ENABLED = True + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = BytesIO(b"a" * (10)) + file.name = "test.docx" + + parent = factories.DocumentFactory(creator=user, users=[(user, "owner")]) + + response = client.post( + f"/api/v1.0/documents/{parent.id}/children/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + + assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]} + + +def test_api_documents_children_create_with_file_extension_not_allowed(settings): + """ + The uploaded file should not have an allowed extension. + """ + settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"] + settings.CONVERSION_UPLOAD_ENABLED = True + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = BytesIO(b"fake docx content") + file.name = "test.md" + + parent = factories.DocumentFactory(creator=user, users=[(user, "owner")]) + + response = client.post( + f"/api/v1.0/documents/{parent.id}/children/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == { + "file": [ + "File extension .md is not allowed. Allowed extensions are: ['.docx']." + ] + }