diff --git a/docs/user_account_reconciliation.md b/docs/user_account_reconciliation.md index 59c6280f..dce16c0a 100644 --- a/docs/user_account_reconciliation.md +++ b/docs/user_account_reconciliation.md @@ -9,7 +9,7 @@ The CSV must contain the following mandatory columns: - `active_email`: the email of the user that will remain active after the process. - `inactive_email`: the email of the user(s) that will be merged into the active user. It is possible to indicate several emails, so the user only has to make one request even if they have more than two accounts. -- `status`: the value must be `pending`. Rows with other values will be ignored. +- `id`: a unique row id, so that entries already processed in a previous import are ignored. The following columns are optional: `active_email_checked` and `inactive_email_checked` (both must contain `0` (False) or `1` (True), and both default to False.) If present, it allows to indicate that the source form has a way to validate that the user making the request actually controls the email addresses, skipping the need to send confirmation emails (cf. below) diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index 4f1d1dff..40bf32ec 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -110,7 +110,7 @@ class UserAdmin(auth_admin.UserAdmin): class UserReconciliationCsvImportAdmin(admin.ModelAdmin): """Admin class for UserReconciliationCsvImport model.""" - list_display = ("id", "created_at", "status") + list_display = ("id", "__str__", "created_at", "status") def save_model(self, request, obj, form, change): """Override save_model to trigger the import task on creation.""" @@ -167,7 +167,7 @@ def process_reconciliation(_modeladmin, _request, queryset): class UserReconciliationAdmin(admin.ModelAdmin): """Admin class for UserReconciliation model.""" - list_display = ["id", "created_at", "status"] + list_display = ["id", "__str__", "created_at", "status"] actions = [process_reconciliation] diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 78934520..66b4021b 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -277,9 +277,9 @@ class ReconciliationConfirmView(APIView): ) lookup = ( - {"active_confirmation_id": uuid_obj} + {"active_email_confirmation_id": uuid_obj} if user_type == "active" - else {"inactive_confirmation_id": uuid_obj} + else {"inactive_email_confirmation_id": uuid_obj} ) try: diff --git a/src/backend/core/migrations/0028_remove_templateaccess_template_and_more.py b/src/backend/core/migrations/0028_remove_templateaccess_template_and_more.py new file mode 100644 index 00000000..5de5e370 --- /dev/null +++ b/src/backend/core/migrations/0028_remove_templateaccess_template_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.9 on 2026-01-09 14:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0027_auto_20251120_0956"), + ] + + operations = [ + migrations.RemoveField( + model_name="templateaccess", + name="template", + ), + migrations.RemoveField( + model_name="templateaccess", + name="user", + ), + migrations.DeleteModel( + name="Template", + ), + migrations.DeleteModel( + name="TemplateAccess", + ), + ] diff --git a/src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py b/src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py index fed96eac..21a3a286 100644 --- a/src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py +++ b/src/backend/core/migrations/0028_userreconciliationcsvimport_userreconciliation.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.9 on 2026-01-06 18:46 +# Generated by Django 5.2.10 on 2026-01-27 15:50 import uuid @@ -43,7 +43,10 @@ class Migration(migrations.Migration): verbose_name="updated on", ), ), - ("file", models.FileField(upload_to="imports/")), + ( + "file", + models.FileField(upload_to="imports/", verbose_name="CSV file"), + ), ( "status", models.CharField( @@ -109,17 +112,26 @@ class Migration(migrations.Migration): ("active_email_checked", models.BooleanField(default=False)), ("inactive_email_checked", models.BooleanField(default=False)), ( - "active_confirmation_id", + "active_email_confirmation_id", models.UUIDField( default=uuid.uuid4, editable=False, null=True, unique=True ), ), ( - "inactive_confirmation_id", + "inactive_email_confirmation_id", models.UUIDField( default=uuid.uuid4, editable=False, null=True, unique=True ), ), + ( + "source_unique_id", + models.CharField( + blank=True, + max_length=100, + null=True, + verbose_name="Unique ID in the source file", + ), + ), ( "status", models.CharField( diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 637c6d4f..e327a8c8 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -318,12 +318,18 @@ class UserReconciliation(BaseModel): blank=True, related_name="inactive_user", ) - active_confirmation_id = models.UUIDField( + active_email_confirmation_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, null=True ) - inactive_confirmation_id = models.UUIDField( + inactive_email_confirmation_id = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, null=True ) + source_unique_id = models.CharField( + max_length=100, + blank=True, + null=True, + verbose_name=_("Unique ID in the source file"), + ) status = models.CharField( max_length=20, @@ -356,11 +362,13 @@ class UserReconciliation(BaseModel): if self.active_user and self.inactive_user: if not self.active_email_checked: self.send_reconciliation_confirm_email( - self.active_user, "active", self.active_confirmation_id + self.active_user, "active", self.active_email_confirmation_id ) if not self.inactive_email_checked: self.send_reconciliation_confirm_email( - self.inactive_user, "inactive", self.inactive_confirmation_id + self.inactive_user, + "inactive", + self.inactive_email_confirmation_id, ) self.status = "ready" else: @@ -456,7 +464,7 @@ class UserReconciliationCsvImport(BaseModel): """Model to import reconciliations requests from an external source (eg, )""" - file = models.FileField(upload_to="imports/") + file = models.FileField(upload_to="imports/", verbose_name=_("CSV file")) status = models.CharField( max_length=20, choices=[ @@ -506,12 +514,14 @@ class UserReconciliationCsvImport(BaseModel): except smtplib.SMTPException as exception: logger.error("invitation to %s was not sent: %s", emails, exception) - def send_reconciliation_error_email(self, email_1, email_2, language=None): + def send_reconciliation_error_email( + self, recipient_email, other_email, language=None + ): """Method allowing to send email for reconciliation requests with errors.""" language = language or get_language() domain = Site.objects.get_current().domain - emails = [email_1, email_2] + emails = [recipient_email] with override(language): subject = _("Reconciliation of your Docs accounts not completed") @@ -521,14 +531,14 @@ class UserReconciliationCsvImport(BaseModel): "message": _( """Reconciliation failed for the following email addresses: - - {email1} - - {email2} + - {recipient_email} + - {other_email} Please check for typos. You can submit another request with the valid email addresses. """ - ).format(email1=email_1, email2=email_2), + ).format(recipient_email=recipient_email, other_email=other_email), "link": f"{domain}/", "link_label": str(_("Click here")), "button_label": str(_("Make a new request")), diff --git a/src/backend/core/services/mime_types.py b/src/backend/core/services/mime_types.py new file mode 100644 index 00000000..ab0535a9 --- /dev/null +++ b/src/backend/core/services/mime_types.py @@ -0,0 +1,8 @@ +"""MIME type constants for document conversion.""" + +BLOCKNOTE = "application/vnd.blocknote+json" +YJS = "application/vnd.yjs.doc" +MARKDOWN = "text/markdown" +JSON = "application/json" +DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" +HTML = "text/html" diff --git a/src/backend/core/tasks/user_reconciliation.py b/src/backend/core/tasks/user_reconciliation.py index 020ca542..7a3b5174 100644 --- a/src/backend/core/tasks/user_reconciliation.py +++ b/src/backend/core/tasks/user_reconciliation.py @@ -23,76 +23,103 @@ def user_reconciliation_csv_import_job(job_id): Does some sanity checks on the data: - active_email and inactive_email must be valid email addresses - active_email and inactive_email cannot be the same + + Rows with errors are logged in the job logs and skipped, but do not cause + the entire job to fail or prevent the next rows from being processed. """ # Imports the CSV file, breaks it into UserReconciliation items job = UserReconciliationCsvImport.objects.get(id=job_id) job.status = "running" job.save() + rec_entries_created = 0 + rows_with_errors = 0 + already_processed_source_ids = 0 + try: with job.file.open(mode="r") as f: reader = csv.DictReader(f) - rec_entries_created = 0 + + if not {"active_email", "inactive_email", "id"}.issubset(reader.fieldnames): + raise KeyError( + "CSV is missing mandatory columns: active_email, inactive_email, id" + ) + for row in reader: - status = row["status"] + source_unique_id = row["id"].strip() - if status == "pending": - active_email_checked = row.get("active_email_checked", "0") == "1" - inactive_email_checked = ( - row.get("inactive_email_checked", "0") == "1" + # Skip entries if they already exist with this source_unique_id + if UserReconciliation.objects.filter( + source_unique_id=source_unique_id + ).exists(): + already_processed_source_ids += 1 + continue + + active_email_checked = row.get("active_email_checked", "0") == "1" + inactive_email_checked = row.get("inactive_email_checked", "0") == "1" + + active_email = row["active_email"] + inactive_emails = row["inactive_email"].split("|") + try: + validate_email(active_email) + except ValidationError: + job.send_reconciliation_error_email( + recipient_email=inactive_emails[0], other_email=active_email ) + job.logs += ( + f"Invalid active email address on row {source_unique_id}." + ) + rows_with_errors += 1 + continue - active_email = row["active_email"] - inactive_emails = row["inactive_email"].split("|") + for inactive_email in inactive_emails: try: - validate_email(active_email) - except ValidationError as e: + validate_email(inactive_email) + except (ValidationError, ValueError): job.send_reconciliation_error_email( - active_email, inactive_emails[0] + recipient_email=active_email, other_email=inactive_email ) - job.status = "error" - job.logs = f"{e!s}\n{traceback.format_exc()}" - - for inactive_email in inactive_emails: - try: - validate_email(inactive_email) - except ValidationError as e: - job.send_reconciliation_error_email( - active_email, inactive_email - ) - job.status = "error" - job.logs = f"{e!s}\n{traceback.format_exc()}" - if inactive_email == active_email: - raise ValueError( - "Active and inactive emails cannot be the same." - ) - - rec_entry = UserReconciliation.objects.create( - active_email=active_email, - inactive_email=inactive_email, - active_email_checked=active_email_checked, - inactive_email_checked=inactive_email_checked, - active_confirmation_id=uuid.uuid4(), - inactive_confirmation_id=uuid.uuid4(), - status="pending", + job.logs += f"Invalid inactive email address on row {source_unique_id}.\n" + rows_with_errors += 1 + continue + if inactive_email == active_email: + job.logs += ( + f"Error on row {source_unique_id}: " + f"{active_email} set as both active and inactive email.\n" ) - rec_entry.save() - rec_entries_created += 1 + rows_with_errors += 1 + continue + + _rec_entry = UserReconciliation.objects.create( + active_email=active_email, + inactive_email=inactive_email, + active_email_checked=active_email_checked, + inactive_email_checked=inactive_email_checked, + active_email_confirmation_id=uuid.uuid4(), + inactive_email_confirmation_id=uuid.uuid4(), + source_unique_id=source_unique_id, + status="pending", + ) + rec_entries_created += 1 job.status = "done" - job.logs = f"""Import completed successfully. {reader.line_num} rows processed. - {rec_entries_created} reconciliation entries created.""" + job.logs += ( + f"Import completed successfully. {reader.line_num} rows processed." + f"{rec_entries_created} reconciliation entries created." + f" {already_processed_source_ids} rows were already processed." + f"{rows_with_errors} rows had errors." + ) except ( csv.Error, KeyError, - ValueError, ValidationError, + ValueError, IntegrityError, OSError, ClientError, ) as e: # Catch expected I/O/CSV/model errors and record traceback in logs for debugging job.status = "error" - job.logs = f"{e!s}\n{traceback.format_exc()}" + job.logs += f"{e!s}\n{traceback.format_exc()}" finally: job.save() diff --git a/src/backend/core/tests/data/example_reconciliation_basic.csv b/src/backend/core/tests/data/example_reconciliation_basic.csv index 2f3450d7..7e9f7fd7 100644 --- a/src/backend/core/tests/data/example_reconciliation_basic.csv +++ b/src/backend/core/tests/data/example_reconciliation_basic.csv @@ -1,6 +1,6 @@ -active_email,inactive_email,active_email_checked,inactive_email_checked,status -"user.test40@example.com","user.test41@example.com",0,0,pending -"user.test42@example.com","user.test43@example.com",0,1,pending -"user.test44@example.com","user.test45@example.com",1,0,pending -"user.test46@example.com","user.test47@example.com",1,1,pending -"user.test48@example.com","user.test49@example.com",1,1,pending \ No newline at end of file +active_email,inactive_email,active_email_checked,inactive_email_checked,status,id +"user.test40@example.com","user.test41@example.com",0,0,pending,1 +"user.test42@example.com","user.test43@example.com",0,1,pending,2 +"user.test44@example.com","user.test45@example.com",1,0,pending,3 +"user.test46@example.com","user.test47@example.com",1,1,pending,4 +"user.test48@example.com","user.test49@example.com",1,1,pending,5 \ No newline at end of file diff --git a/src/backend/core/tests/data/example_reconciliation_error.csv b/src/backend/core/tests/data/example_reconciliation_error.csv index 6f8d71ab..2ac79396 100644 --- a/src/backend/core/tests/data/example_reconciliation_error.csv +++ b/src/backend/core/tests/data/example_reconciliation_error.csv @@ -1,2 +1,2 @@ -active_email,inactive_email,active_email_checked,inactive_email_checked,status -"user.test40@example.com",,0,0,pending +active_email,inactive_email,active_email_checked,inactive_email_checked,status,id +"user.test40@example.com",,0,0,pending,40 diff --git a/src/backend/core/tests/data/example_reconciliation_grist_form.csv b/src/backend/core/tests/data/example_reconciliation_grist_form.csv index d2b76bcb..b92fe036 100644 --- a/src/backend/core/tests/data/example_reconciliation_grist_form.csv +++ b/src/backend/core/tests/data/example_reconciliation_grist_form.csv @@ -1,5 +1,5 @@ -merge_accept,active_email,inactive_email,status -true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending -true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending -true,user.test20@example.com,user.test21@example.com,pending -true,user.test22@example.com,user.test23@example.com,pending +merge_accept,active_email,inactive_email,status,id +true,user.test10@example.com,user.test11@example.com|user.test12@example.com,pending,10 +true,user.test30@example.com,user.test31@example.com|user.test32@example.com|user.test33@example.com|user.test34@example.com|user.test35@example.com,pending,11 +true,user.test20@example.com,user.test21@example.com,pending,12 +true,user.test22@example.com,user.test23@example.com,pending,13 \ No newline at end of file diff --git a/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv b/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv index 934816f4..86d92ca3 100644 --- a/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv +++ b/src/backend/core/tests/data/example_reconciliation_grist_form_error.csv @@ -1,2 +1,2 @@ -merge_accept,active_email,inactive_email,status -true,user.test20@example.com,user.test20@example.com,pending +merge_accept,active_email,inactive_email,status,id +true,user.test20@example.com,user.test20@example.com,pending,20 diff --git a/src/backend/core/tests/data/example_reconciliation_missing_column.csv b/src/backend/core/tests/data/example_reconciliation_missing_column.csv new file mode 100644 index 00000000..2f3450d7 --- /dev/null +++ b/src/backend/core/tests/data/example_reconciliation_missing_column.csv @@ -0,0 +1,6 @@ +active_email,inactive_email,active_email_checked,inactive_email_checked,status +"user.test40@example.com","user.test41@example.com",0,0,pending +"user.test42@example.com","user.test43@example.com",0,1,pending +"user.test44@example.com","user.test45@example.com",1,0,pending +"user.test46@example.com","user.test47@example.com",1,1,pending +"user.test48@example.com","user.test49@example.com",1,1,pending \ No newline at end of file diff --git a/src/backend/core/tests/documents/test_api_documents_create_with_file.py b/src/backend/core/tests/documents/test_api_documents_create_with_file.py new file mode 100644 index 00000000..3cd6dda2 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_create_with_file.py @@ -0,0 +1,413 @@ +""" +Tests for Documents API endpoint in impress's core app: create with file upload +""" + +from base64 import b64decode, binascii +from io import BytesIO +from unittest.mock import patch + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document +from core.services import mime_types +from core.services.converter_services import ( + ConversionError, + ServiceUnavailableError, +) + +pytestmark = pytest.mark.django_db + + +def test_api_documents_create_with_file_anonymous(): + """Anonymous users should not be allowed to create documents with file upload.""" + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "test_document.docx" + + response = APIClient().post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_docx_file_success(mock_convert): + """ + Authenticated users should be able to create documents 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) + + # 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" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "My Important Document.docx" + assert document.content == converted_yjs + assert document.accesses.filter(role="owner", user=user).exists() + + # 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_create_with_markdown_file_success(mock_convert): + """ + Authenticated users should be able to create documents by uploading a Markdown file. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a fake Markdown file + file_content = b"# Test Document\n\nThis is a test." + file = BytesIO(file_content) + file.name = "readme.md" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "readme.md" + assert document.content == converted_yjs + assert document.accesses.filter(role="owner", user=user).exists() + + # Verify the converter was called correctly + mock_convert.assert_called_once_with( + file_content, + content_type=mime_types.MARKDOWN, + accept=mime_types.YJS, + ) + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_and_explicit_title(mock_convert): + """ + When both file and title are provided, the filename should override the title. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # 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 = "Uploaded Document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + "title": "This should be overridden", + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + # The filename should take precedence + assert document.title == "Uploaded Document.docx" + + +def test_api_documents_create_with_empty_file(): + """ + Creating a document with an empty file should fail with a validation error. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create an empty file + file = BytesIO(b"") + file.name = "empty.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["The submitted file is empty."]} + assert not Document.objects.exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_conversion_error(mock_convert): + """ + When conversion fails, the API should return a 400 error with appropriate message. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion to raise an error + mock_convert.side_effect = ConversionError("Failed to convert document") + + # Create a fake DOCX file + file_content = b"fake invalid docx content" + file = BytesIO(file_content) + file.name = "corrupted.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["Could not convert file content"]} + assert not Document.objects.exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_service_unavailable(mock_convert): + """ + When the conversion service is unavailable, appropriate error should be returned. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion to raise ServiceUnavailableError + mock_convert.side_effect = ServiceUnavailableError( + "Failed to connect to conversion service" + ) + + # Create a fake DOCX file + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == {"file": ["Could not convert file content"]} + assert not Document.objects.exists() + + +def test_api_documents_create_without_file_still_works(): + """ + Creating a document without a file should still work as before (backward compatibility). + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/documents/", + { + "title": "Regular document without file", + }, + format="json", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "Regular document without file" + assert document.content is None + assert document.accesses.filter(role="owner", user=user).exists() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_null_value(mock_convert): + """ + Passing file=null should be treated as no file upload. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/documents/", + { + "title": "Document with null file", + "file": None, + }, + format="json", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "Document with null file" + # Converter should not have been called + mock_convert.assert_not_called() + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_preserves_content_format(mock_convert): + """ + Verify that the converted content is stored correctly in the document. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion with realistic base64-encoded YJS data + converted_yjs = "AQMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4fICA=" + mock_convert.return_value = converted_yjs + + # Create a fake DOCX file + file_content = b"fake docx with complex formatting" + file = BytesIO(file_content) + file.name = "complex_document.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + + # Verify the content is stored as returned by the converter + assert document.content == converted_yjs + + # Verify it's valid base64 (can be decoded) + try: + b64decode(converted_yjs) + except binascii.Error: + pytest.fail("Content should be valid base64-encoded data") + + +@patch("core.services.converter_services.Converter.convert") +def test_api_documents_create_with_file_unicode_filename(mock_convert): + """ + Test that Unicode characters in filenames are handled correctly. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Mock the conversion + converted_yjs = "base64encodedyjscontent" + mock_convert.return_value = converted_yjs + + # Create a file with Unicode characters in the name + file_content = b"fake docx content" + file = BytesIO(file_content) + file.name = "文档-télécharger-документ.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 201 + document = Document.objects.get() + assert document.title == "文档-télécharger-документ.docx" + + +def test_api_documents_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 + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = BytesIO(b"a" * (10)) + file.name = "test.docx" + + response = client.post( + "/api/v1.0/documents/", + { + "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_create_with_file_extension_not_allowed(settings): + """ + The uploaded file should not have an allowed extension. + """ + settings.CONVERSION_FILE_EXTENSIONS_ALLOWED = [".docx"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + file = BytesIO(b"fake docx content") + file.name = "test.md" + + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) + + assert response.status_code == 400 + assert response.json() == { + "file": [ + "File extension .md is not allowed. Allowed extensions are: ['.docx']." + ] + } diff --git a/src/backend/core/tests/test_models_user_reconciliation.py b/src/backend/core/tests/test_models_user_reconciliation.py index 3d74ee5a..1e8f65bc 100644 --- a/src/backend/core/tests/test_models_user_reconciliation.py +++ b/src/backend/core/tests/test_models_user_reconciliation.py @@ -76,6 +76,30 @@ def test_user_reconciliation_csv_import_entry_is_created_grist_form( def test_incorrect_csv_format_handling(): + """Test that an incorrectly formatted CSV file is handled gracefully.""" + example_csv_path = ( + Path(__file__).parent / "data/example_reconciliation_missing_column.csv" + ) + with open(example_csv_path, "rb") as f: + csv_file = ContentFile( + f.read(), name="example_reconciliation_missing_column.csv" + ) + csv_import = models.UserReconciliationCsvImport(file=csv_file) + csv_import.save() + + assert csv_import.status == "pending" + + user_reconciliation_csv_import_job(csv_import.id) + csv_import.refresh_from_db() + + assert ( + "CSV is missing mandatory columns: active_email, inactive_email, id" + in csv_import.logs + ) + assert csv_import.status == "error" + + +def test_incorrect_email_format_handling(): """Test that an incorrectly formatted CSV file is handled gracefully.""" example_csv_path = Path(__file__).parent / "data/example_reconciliation_error.csv" with open(example_csv_path, "rb") as f: @@ -88,8 +112,8 @@ def test_incorrect_csv_format_handling(): user_reconciliation_csv_import_job(csv_import.id) csv_import.refresh_from_db() - assert "This field cannot be blank" in csv_import.logs - assert csv_import.status == "error" + assert "Invalid inactive email address on row 40" in csv_import.logs + assert csv_import.status == "done" # pylint: disable-next=no-member assert len(mail.outbox) == 1 @@ -97,7 +121,7 @@ def test_incorrect_csv_format_handling(): # pylint: disable-next=no-member email = mail.outbox[0] - assert email.to == ["user.test40@example.com", ""] + assert email.to == ["user.test40@example.com"] email_content = " ".join(email.body.split()) assert "Reconciliation of your Docs accounts not completed" in email_content @@ -120,8 +144,11 @@ def test_incorrect_csv_data_handling_grist_form(): user_reconciliation_csv_import_job(csv_import.id) csv_import.refresh_from_db() - assert "Active and inactive emails cannot be the same." in csv_import.logs - assert csv_import.status == "error" + assert ( + "user.test20@example.com set as both active and inactive email" + in csv_import.logs + ) + assert csv_import.status == "done" def test_job_creates_reconciliation_entries(import_example_csv_basic): @@ -141,6 +168,37 @@ def test_job_creates_reconciliation_entries(import_example_csv_basic): assert reconciliations.count() == 5 +def test_job_does_not_create_duplicated_reconciliation_entries( + import_example_csv_basic, +): + """Test that the CSV import job doesn't create UserReconciliation entries + for source unique IDs that have already been processed.""" + + _already_created_entry = models.UserReconciliation.objects.create( + active_email="user.test40@example.com", + inactive_email="user.test41@example.com", + active_email_checked=0, + inactive_email_checked=0, + status="pending", + source_unique_id=1, + ) + + assert import_example_csv_basic.status == "pending" + user_reconciliation_csv_import_job(import_example_csv_basic.id) + + # Verify the job status changed + import_example_csv_basic.refresh_from_db() + assert import_example_csv_basic.status == "done" + assert "Import completed successfully." in import_example_csv_basic.logs + assert "6 rows processed." in import_example_csv_basic.logs + assert "4 reconciliation entries created." in import_example_csv_basic.logs + assert "1 rows were already processed." in import_example_csv_basic.logs + + # Verify the correct number of reconciliation entries were created + reconciliations = models.UserReconciliation.objects.all() + assert reconciliations.count() == 5 + + def test_job_creates_reconciliation_entries_grist_form(import_example_csv_grist_form): """Test that the CSV import job creates UserReconciliation entries.""" assert import_example_csv_grist_form.status == "pending" @@ -224,8 +282,8 @@ def test_user_reconciliation_is_created(user_reconciliation_users_and_docs): inactive_email=user_2.email, active_email_checked=False, inactive_email_checked=True, - active_confirmation_id=uuid.uuid4(), - inactive_confirmation_id=uuid.uuid4(), + active_email_confirmation_id=uuid.uuid4(), + inactive_email_confirmation_id=uuid.uuid4(), status="pending", ) @@ -243,8 +301,8 @@ def test_user_reconciliation_verification_emails_are_sent( inactive_email=user_2.email, active_email_checked=False, inactive_email_checked=False, - active_confirmation_id=uuid.uuid4(), - inactive_confirmation_id=uuid.uuid4(), + active_email_confirmation_id=uuid.uuid4(), + inactive_email_confirmation_id=uuid.uuid4(), status="pending", ) @@ -263,9 +321,12 @@ def test_user_reconciliation_verification_emails_are_sent( "You have requested a reconciliation of your user accounts on Docs." in email_1_content ) - active_confirmation_id = rec.active_confirmation_id - inactive_confirmation_id = rec.inactive_confirmation_id - assert f"user_reconciliations/active/{active_confirmation_id}/" in email_1_content + active_email_confirmation_id = rec.active_email_confirmation_id + inactive_email_confirmation_id = rec.inactive_email_confirmation_id + assert ( + f"user_reconciliations/active/{active_email_confirmation_id}/" + in email_1_content + ) # pylint: disable-next=no-member email_2 = mail.outbox[1] @@ -279,7 +340,8 @@ def test_user_reconciliation_verification_emails_are_sent( ) assert ( - f"user_reconciliations/inactive/{inactive_confirmation_id}/" in email_2_content + f"user_reconciliations/inactive/{inactive_email_confirmation_id}/" + in email_2_content ) diff --git a/src/backend/core/tests/test_services_converter_orchestration.py b/src/backend/core/tests/test_services_converter_orchestration.py new file mode 100644 index 00000000..90ac66d3 --- /dev/null +++ b/src/backend/core/tests/test_services_converter_orchestration.py @@ -0,0 +1,93 @@ +"""Test Converter orchestration services.""" + +from unittest.mock import MagicMock, patch + +from core.services import mime_types +from core.services.converter_services import Converter + + +@patch("core.services.converter_services.DocSpecConverter") +@patch("core.services.converter_services.YdocConverter") +def test_converter_docx_to_yjs_orchestration(mock_ydoc_class, mock_docspec_class): + """Test that DOCX to YJS conversion uses both DocSpec and Ydoc converters.""" + # Setup mocks + mock_docspec = MagicMock() + mock_ydoc = MagicMock() + mock_docspec_class.return_value = mock_docspec + mock_ydoc_class.return_value = mock_ydoc + + # Mock the conversion chain: DOCX -> BlockNote -> YJS + blocknote_data = b'[{"type": "paragraph", "content": "test"}]' + yjs_data = "base64encodedyjs" + + mock_docspec.convert.return_value = blocknote_data + mock_ydoc.convert.return_value = yjs_data + + # Execute conversion + converter = Converter() + docx_data = b"fake docx data" + result = converter.convert(docx_data, mime_types.DOCX, mime_types.YJS) + + # Verify the orchestration + mock_docspec.convert.assert_called_once_with( + docx_data, mime_types.DOCX, mime_types.BLOCKNOTE + ) + mock_ydoc.convert.assert_called_once_with( + blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS + ) + assert result == yjs_data + + +@patch("core.services.converter_services.YdocConverter") +def test_converter_markdown_to_yjs_delegation(mock_ydoc_class): + """Test that Markdown to YJS conversion is delegated to YdocConverter.""" + mock_ydoc = MagicMock() + mock_ydoc_class.return_value = mock_ydoc + + yjs_data = "base64encodedyjs" + mock_ydoc.convert.return_value = yjs_data + + converter = Converter() + markdown_data = "# Test Document" + result = converter.convert(markdown_data, mime_types.MARKDOWN, mime_types.YJS) + + mock_ydoc.convert.assert_called_once_with( + markdown_data, mime_types.MARKDOWN, mime_types.YJS + ) + assert result == yjs_data + + +@patch("core.services.converter_services.YdocConverter") +def test_converter_yjs_to_html_delegation(mock_ydoc_class): + """Test that YJS to HTML conversion is delegated to YdocConverter.""" + mock_ydoc = MagicMock() + mock_ydoc_class.return_value = mock_ydoc + + html_data = "

Test Document

" + mock_ydoc.convert.return_value = html_data + + converter = Converter() + yjs_data = b"yjs binary data" + result = converter.convert(yjs_data, mime_types.YJS, mime_types.HTML) + + mock_ydoc.convert.assert_called_once_with(yjs_data, mime_types.YJS, mime_types.HTML) + assert result == html_data + + +@patch("core.services.converter_services.YdocConverter") +def test_converter_blocknote_to_yjs_delegation(mock_ydoc_class): + """Test that BlockNote to YJS conversion is delegated to YdocConverter.""" + mock_ydoc = MagicMock() + mock_ydoc_class.return_value = mock_ydoc + + yjs_data = "base64encodedyjs" + mock_ydoc.convert.return_value = yjs_data + + converter = Converter() + blocknote_data = b'[{"type": "paragraph"}]' + result = converter.convert(blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS) + + mock_ydoc.convert.assert_called_once_with( + blocknote_data, mime_types.BLOCKNOTE, mime_types.YJS + ) + assert result == yjs_data diff --git a/src/backend/core/tests/test_services_docspec_converter.py b/src/backend/core/tests/test_services_docspec_converter.py new file mode 100644 index 00000000..16f4a5f5 --- /dev/null +++ b/src/backend/core/tests/test_services_docspec_converter.py @@ -0,0 +1,117 @@ +"""Test DocSpec converter services.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from core.services import mime_types +from core.services.converter_services import ( + DocSpecConverter, + ServiceUnavailableError, + ValidationError, +) + + +def test_docspec_convert_empty_data(): + """Should raise ValidationError when data is empty.""" + converter = DocSpecConverter() + with pytest.raises(ValidationError, match="Input data cannot be empty"): + converter.convert("", mime_types.DOCX, mime_types.BLOCKNOTE) + + +def test_docspec_convert_none_input(): + """Should raise ValidationError when input is None.""" + converter = DocSpecConverter() + with pytest.raises(ValidationError, match="Input data cannot be empty"): + converter.convert(None, mime_types.DOCX, mime_types.BLOCKNOTE) + + +def test_docspec_convert_unsupported_content_type(): + """Should raise ValidationError when content type is not DOCX.""" + converter = DocSpecConverter() + with pytest.raises( + ValidationError, match="Conversion from text/plain to .* is not supported" + ): + converter.convert(b"test data", "text/plain", mime_types.BLOCKNOTE) + + +def test_docspec_convert_unsupported_accept(): + """Should raise ValidationError when accept type is not BLOCKNOTE.""" + converter = DocSpecConverter() + with pytest.raises( + ValidationError, + match=f"Conversion from {mime_types.DOCX} to {mime_types.YJS} is not supported", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.YJS) + + +@patch("requests.post") +def test_docspec_convert_service_unavailable(mock_post): + """Should raise ServiceUnavailableError when service is unavailable.""" + converter = DocSpecConverter() + mock_post.side_effect = requests.RequestException("Connection error") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to DocSpec conversion service", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE) + + +@patch("requests.post") +def test_docspec_convert_http_error(mock_post): + """Should raise ServiceUnavailableError when HTTP error occurs.""" + converter = DocSpecConverter() + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP Error") + mock_post.return_value = mock_response + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to DocSpec conversion service", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE) + + +@patch("requests.post") +def test_docspec_convert_timeout(mock_post): + """Should raise ServiceUnavailableError when request times out.""" + converter = DocSpecConverter() + mock_post.side_effect = requests.Timeout("Request timed out") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to DocSpec conversion service", + ): + converter.convert(b"test data", mime_types.DOCX, mime_types.BLOCKNOTE) + + +@patch("requests.post") +def test_docspec_convert_success(mock_post, settings): + """Test successful DOCX to BlockNote conversion.""" + settings.DOCSPEC_API_URL = "http://docspec.test/convert" + settings.CONVERSION_API_TIMEOUT = 5 + settings.CONVERSION_API_SECURE = False + + converter = DocSpecConverter() + + expected_content = b'[{"type": "paragraph", "content": "test"}]' + mock_response = MagicMock() + mock_response.content = expected_content + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + docx_data = b"fake docx binary data" + result = converter.convert(docx_data, mime_types.DOCX, mime_types.BLOCKNOTE) + + assert result == expected_content + + # Verify the request was made correctly + mock_post.assert_called_once_with( + "http://docspec.test/convert", + headers={"Accept": mime_types.BLOCKNOTE}, + files={"file": ("document.docx", docx_data, mime_types.DOCX)}, + timeout=5, + verify=False, + ) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.docx b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.docx new file mode 100644 index 00000000..8db66a06 Binary files /dev/null and b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.docx differ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.md b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.md new file mode 100644 index 00000000..461c52de --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/assets/test_import.md @@ -0,0 +1,60 @@ +![473389927-e4ff1794-69f3-460a-85f8-fec993cd74d6.png](http://localhost:3000/assets/logo-suite-numerique.png)![497094770-53e5f8e2-c93e-4a0b-a82f-cd184fd03f51.svg](http://localhost:3000/assets/icon-docs.svg) + +# Lorem Ipsum import Document + +## Introduction + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl. + +### Subsection 1.1 + +* **Bold text**: Lorem ipsum dolor sit amet. + +* *Italic text*: Consectetur adipiscing elit. + +* ~~Strikethrough text~~: Nullam auctor, nisl eget ultricies tincidunt. + +1. First item in an ordered list. + +2. Second item in an ordered list. + + * Indented bullet point. + + * Another indented bullet point. + +3. Third item in an ordered list. + +### Subsection 1.2 + +**Code block:** + +```js +const hello_world = () => { + console.log("Hello, world!"); +} +``` + +**Blockquote:** + +> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt. + +**Horizontal rule:** + +*** + +**Table:** + +| Syntax | Description | +| --------- | ----------- | +| Header | Title | +| Paragraph | Text | + +**Inline code:** + +Use the `printf()` function. + +**Link:** [Example](http://localhost:3000/) + +## Conclusion + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam auctor, nisl eget ultricies tincidunt, nisl nisl aliquam nisl, eget ultricies nisl nisl eget nisl. diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts new file mode 100644 index 00000000..f5269bce --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-import.spec.ts @@ -0,0 +1,181 @@ +import { readFileSync } from 'fs'; +import path from 'path'; + +import { Page, expect, test } from '@playwright/test'; + +import { getEditor } from './utils-editor'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Doc Import', () => { + test('it imports 2 docs with the import icon', async ({ page }) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByLabel('Open the upload dialog').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([ + path.join(__dirname, 'assets/test_import.docx'), + path.join(__dirname, 'assets/test_import.md'), + ]); + + await expect( + page.getByText( + 'The document "test_import.docx" has been successfully imported', + ), + ).toBeVisible(); + await expect( + page.getByText( + 'The document "test_import.md" has been successfully imported', + ), + ).toBeVisible(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible(); + await expect(docsGrid.getByText('test_import.md').first()).toBeVisible(); + + // Check content of imported md + await docsGrid.getByText('test_import.md').first().click(); + const editor = await getEditor({ page }); + + const contentCheck = async (isMDCheck = false) => { + await expect( + editor.getByRole('heading', { + name: 'Lorem Ipsum import Document', + level: 1, + }), + ).toBeVisible(); + await expect( + editor.getByRole('heading', { + name: 'Introduction', + level: 2, + }), + ).toBeVisible(); + await expect( + editor.getByRole('heading', { + name: 'Subsection 1.1', + level: 3, + }), + ).toBeVisible(); + await expect( + editor + .locator('div[data-content-type="bulletListItem"] strong') + .getByText('Bold text'), + ).toBeVisible(); + await expect( + editor + .locator('div[data-content-type="codeBlock"]') + .getByText('hello_world'), + ).toBeVisible(); + await expect( + editor + .locator('div[data-content-type="table"] td') + .getByText('Paragraph'), + ).toBeVisible(); + await expect( + editor.locator('a[href="http://localhost:3000/"]').getByText('Example'), + ).toBeVisible(); + + /* eslint-disable playwright/no-conditional-expect */ + if (isMDCheck) { + await expect( + editor.locator( + 'img[src="http://localhost:3000/assets/logo-suite-numerique.png"]', + ), + ).toBeVisible(); + await expect( + editor.locator( + 'img[src="http://localhost:3000/assets/icon-docs.svg"]', + ), + ).toBeVisible(); + } else { + await expect(editor.locator('img')).toHaveCount(2); + } + /* eslint-enable playwright/no-conditional-expect */ + + /** + * Divider are not supported in docx import in DocSpec 2.4.4 + */ + /* eslint-disable playwright/no-conditional-expect */ + if (isMDCheck) { + await expect( + editor.locator('div[data-content-type="divider"] hr'), + ).toBeVisible(); + } + /* eslint-enable playwright/no-conditional-expect */ + }; + + await contentCheck(true); + + // Check content of imported docx + await page.getByLabel('Back to homepage').first().click(); + await docsGrid.getByText('test_import.docx').first().click(); + + await contentCheck(); + }); + + test('it imports 2 docs with the drag and drop area', async ({ page }) => { + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + + await dragAndDropFiles(page, "[data-testid='docs-grid']", [ + { + filePath: path.join(__dirname, 'assets/test_import.docx'), + fileName: 'test_import.docx', + fileType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }, + { + filePath: path.join(__dirname, 'assets/test_import.md'), + fileName: 'test_import.md', + fileType: 'text/markdown', + }, + ]); + + // Wait for success messages + await expect( + page.getByText( + 'The document "test_import.docx" has been successfully imported', + ), + ).toBeVisible(); + await expect( + page.getByText( + 'The document "test_import.md" has been successfully imported', + ), + ).toBeVisible(); + + await expect(docsGrid.getByText('test_import.docx').first()).toBeVisible(); + await expect(docsGrid.getByText('test_import.md').first()).toBeVisible(); + }); +}); + +const dragAndDropFiles = async ( + page: Page, + selector: string, + files: Array<{ filePath: string; fileName: string; fileType?: string }>, +) => { + const filesData = files.map((file) => ({ + bufferData: `data:application/octet-stream;base64,${readFileSync(file.filePath).toString('base64')}`, + fileName: file.fileName, + fileType: file.fileType || '', + })); + + const dataTransfer = await page.evaluateHandle(async (filesInfo) => { + const dt = new DataTransfer(); + + for (const fileInfo of filesInfo) { + const blobData = await fetch(fileInfo.bufferData).then((res) => + res.blob(), + ); + const file = new File([blobData], fileInfo.fileName, { + type: fileInfo.fileType, + }); + dt.items.add(file); + } + + return dt; + }, filesData); + + await page.dispatchEvent(selector, 'drop', { dataTransfer }); +}; diff --git a/src/frontend/apps/impress/src/assets/icons/doc-all.svg b/src/frontend/apps/impress/src/assets/icons/doc-all.svg new file mode 100644 index 00000000..a4e61a5a --- /dev/null +++ b/src/frontend/apps/impress/src/assets/icons/doc-all.svg @@ -0,0 +1,20 @@ + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocsFavorite.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocsFavorite.tsx new file mode 100644 index 00000000..3baa4510 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocsFavorite.tsx @@ -0,0 +1,72 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { + APIError, + APIList, + InfiniteQueryConfig, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} from '@/api'; + +import { Doc } from '../types'; + +export type DocsFavoriteParams = { + page: number; +}; + +export type DocsFavoriteResponse = APIList; +export const getDocsFavorite = async ( + params: DocsFavoriteParams, +): Promise => { + const searchParams = new URLSearchParams(); + + if (params.page) { + searchParams.set('page', params.page.toString()); + } + + const response = await fetchAPI( + `documents/favorite_list/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the favorite docs', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_FAVORITE_DOC = 'docs_favorite_list'; + +type UseDocsOptions = UseQueryOptions< + DocsFavoriteResponse, + APIError, + DocsFavoriteResponse +>; +type UseInfiniteDocsOptions = InfiniteQueryConfig; + +export function useDocsFavorite( + params: DocsFavoriteParams, + queryConfig?: UseDocsOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_FAVORITE_DOC, params], + queryFn: () => getDocsFavorite(params), + ...queryConfig, + }); +} + +export const useInfiniteDocsFavorite = ( + params: DocsFavoriteParams, + queryConfig?: UseInfiniteDocsOptions, +) => { + return useAPIInfiniteQuery( + KEY_LIST_FAVORITE_DOC, + getDocsFavorite, + params, + queryConfig, + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx new file mode 100644 index 00000000..d4b9d1e9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/api/useImportDoc.tsx @@ -0,0 +1,125 @@ +import { + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/cunningham-react'; +import { + UseMutationOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { + APIError, + UseInfiniteQueryResultAPI, + errorCauses, + fetchAPI, +} from '@/api'; +import { Doc, DocsResponse, KEY_LIST_DOC } from '@/docs/doc-management'; + +export enum ContentTypes { + Docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + Markdown = 'text/markdown', + OctetStream = 'application/octet-stream', +} + +export const importDoc = async ([file, mimeType]: [ + File, + string, +]): Promise => { + const form = new FormData(); + + form.append( + 'file', + new File([file], file.name, { + type: mimeType, + lastModified: file.lastModified, + }), + ); + + const response = await fetchAPI(`documents/`, { + method: 'POST', + body: form, + withoutContentType: true, + }); + + if (!response.ok) { + throw new APIError('Failed to import the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +type UseImportDocOptions = UseMutationOptions; + +export function useImportDoc(props?: UseImportDocOptions) { + const { toast } = useToastProvider(); + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: importDoc, + ...props, + onSuccess: (...successProps) => { + const importedDoc = successProps[0]; + + const updateDocsListCache = (isCreatorMe: boolean | undefined) => { + queryClient.setQueriesData>( + { + queryKey: [ + KEY_LIST_DOC, + { + page: 1, + ordering: undefined, + is_creator_me: isCreatorMe, + title: undefined, + is_favorite: undefined, + }, + ], + }, + (oldData) => { + if (!oldData || oldData?.pages.length === 0) { + return oldData; + } + + return { + ...oldData, + pages: oldData.pages.map((page, index) => { + // Add the new doc to the first page only + if (index === 0) { + return { + ...page, + results: [importedDoc, ...page.results], + }; + } + return page; + }), + }; + }, + ); + }; + + updateDocsListCache(undefined); + updateDocsListCache(true); + + toast( + t('The document "{{documentName}}" has been successfully imported', { + documentName: importedDoc.title || '', + }), + VariantType.SUCCESS, + ); + + props?.onSuccess?.(...successProps); + }, + onError: (...errorProps) => { + toast( + t(`The document "{{documentName}}" import has failed`, { + documentName: errorProps?.[1][0].name || '', + }), + VariantType.ERROR, + ); + + props?.onError?.(...errorProps); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx new file mode 100644 index 00000000..9721f267 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useImport.tsx @@ -0,0 +1,116 @@ +import { + VariantType, + useToastProvider, +} from '@gouvfr-lasuite/cunningham-react'; +import { t } from 'i18next'; +import { useMemo } from 'react'; +import { useDropzone } from 'react-dropzone'; + +import { useConfig } from '@/core'; + +import { ContentTypes, useImportDoc } from '../api/useImportDoc'; + +interface UseImportProps { + onDragOver: (isDragOver: boolean) => void; +} + +export const useImport = ({ onDragOver }: UseImportProps) => { + const { toast } = useToastProvider(); + const { data: config } = useConfig(); + + const MAX_FILE_SIZE = useMemo(() => { + const maxSizeInBytes = config?.CONVERSION_FILE_MAX_SIZE ?? 10 * 1024 * 1024; // Default to 10MB + + const units = ['bytes', 'KB', 'MB', 'GB']; + let size = maxSizeInBytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + return { + bytes: maxSizeInBytes, + text: `${Math.round(size * 10) / 10}${units[unitIndex]}`, + }; + }, [config?.CONVERSION_FILE_MAX_SIZE]); + + const ACCEPT = useMemo(() => { + const extensions = config?.CONVERSION_FILE_EXTENSIONS_ALLOWED; + const accept: { [key: string]: string[] } = {}; + + if (extensions && extensions.length > 0) { + extensions.forEach((ext) => { + switch (ext.toLowerCase()) { + case '.docx': + accept[ContentTypes.Docx] = ['.docx']; + break; + case '.md': + case '.markdown': + accept[ContentTypes.Markdown] = ['.md']; + break; + default: + break; + } + }); + } else { + // Default to docx and md if no configuration is provided + accept[ContentTypes.Docx] = ['.docx']; + accept[ContentTypes.Markdown] = ['.md']; + } + + return accept; + }, [config?.CONVERSION_FILE_EXTENSIONS_ALLOWED]); + + const { getRootProps, getInputProps, open } = useDropzone({ + accept: ACCEPT, + maxSize: MAX_FILE_SIZE.bytes, + onDrop(acceptedFiles) { + onDragOver(false); + for (const file of acceptedFiles) { + importDoc([file, file.type]); + } + }, + onDragEnter: () => { + onDragOver(true); + }, + onDragLeave: () => { + onDragOver(false); + }, + onDropRejected(fileRejections) { + fileRejections.forEach((rejection) => { + const isFileTooLarge = rejection.errors.some( + (error) => error.code === 'file-too-large', + ); + + if (isFileTooLarge) { + toast( + t( + 'The document "{{documentName}}" is too large. Maximum file size is {{maxFileSize}}.', + { + documentName: rejection.file.name, + maxFileSize: MAX_FILE_SIZE.text, + }, + ), + VariantType.ERROR, + ); + } else { + toast( + t( + `The document "{{documentName}}" import has failed (only .docx and .md files are allowed)`, + { + documentName: rejection.file.name, + }, + ), + VariantType.ERROR, + ); + } + }); + }, + noClick: true, + }); + const { mutate: importDoc } = useImportDoc(); + + return { getRootProps, getInputProps, open }; +}; diff --git a/src/helm/impress/templates/docspec_deployment.yaml b/src/helm/impress/templates/docspec_deployment.yaml new file mode 100644 index 00000000..984b98d1 --- /dev/null +++ b/src/helm/impress/templates/docspec_deployment.yaml @@ -0,0 +1,108 @@ +{{- if .Values.docSpec.enabled -}} +{{- $envVars := include "impress.common.env" (list . .Values.docSpec) -}} +{{- $fullName := include "impress.docSpec.fullname" . -}} +{{- $component := "docspec" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + replicas: {{ .Values.docSpec.replicas }} + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} + template: + metadata: + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + containers: + - name: {{ .Chart.Name }}-docspec + image: "{{ .Values.docSpec.image.repository }}:{{ .Values.docSpec.image.tag }}" + imagePullPolicy: {{ .Values.docSpec.image.pullPolicy }} + {{- with .Values.docSpec.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.docSpec.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if $envVars }} + env: + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.docSpec.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.docSpec.service.targetPort }} + protocol: TCP + {{- if .Values.docSpec.probes.liveness }} + livenessProbe: + {{- include "impress.probes.abstract" (merge .Values.docSpec.probes.liveness (dict "targetPort" .Values.docSpec.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.docSpec.probes.readiness }} + readinessProbe: + {{- include "impress.probes.abstract" (merge .Values.docSpec.probes.readiness (dict "targetPort" .Values.docSpec.service.targetPort )) | nindent 12 }} + {{- end }} + {{- with .Values.docSpec.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.docSpec.extraVolumeMounts }} + volumeMounts: + {{- range .Values.docSpec.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + {{- if .subPath }} + subPath: {{ .subPath }} + {{- end }} + {{- if .readOnly }} + readOnly: {{ .readOnly }} + {{- end }} + {{- end }} + {{- end }} + {{- with .Values.docSpec.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.docSpec.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.docSpec.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- if .Values.docSpec.extraVolumes }} + volumes: + {{- range .Values.docSpec.extraVolumes }} + - name: {{ .name }} + {{- if .persistentVolumeClaim }} + persistentVolumeClaim: + {{- toYaml .persistentVolumeClaim | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .secret }} + secret: + {{- toYaml .secret | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/src/helm/impress/templates/docspec_svc.yaml b/src/helm/impress/templates/docspec_svc.yaml new file mode 100644 index 00000000..f393446c --- /dev/null +++ b/src/helm/impress/templates/docspec_svc.yaml @@ -0,0 +1,20 @@ +{{- if .Values.docSpec.enabled -}} +{{- $fullName := include "impress.docSpec.fullname" . -}} +{{- $component := "docspec" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + type: {{ .Values.docSpec.service.type }} + ports: + - port: {{ .Values.docSpec.service.port }} + targetPort: {{ .Values.docSpec.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} +{{- end }}