(backend) reconciliation requests: use a source unique id

This changes the way the reconciliation requests CSV imports are
processed and requires the CSV to provide a unique id for each
request.

Rows with errors are also now handled better so that they don't
fail the whole import.
This commit is contained in:
Sylvain Boissel
2026-01-28 11:01:40 +01:00
parent af523e13ee
commit c121441073
26 changed files with 1565 additions and 89 deletions
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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]
+2 -2
View File
@@ -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:
@@ -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",
),
]
@@ -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(
+20 -10
View File
@@ -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")),
+8
View File
@@ -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"
+69 -42
View File
@@ -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()
@@ -1,6 +1,6 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"[email protected]","[email protected]",0,0,pending
"[email protected]","[email protected]",0,1,pending
"[email protected]","[email protected]",1,0,pending
"[email protected]","[email protected]",1,1,pending
"[email protected]","[email protected]",1,1,pending
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"[email protected]","[email protected]",0,0,pending,1
"[email protected]","[email protected]",0,1,pending,2
"[email protected]","[email protected]",1,0,pending,3
"[email protected]","[email protected]",1,1,pending,4
"[email protected]","[email protected]",1,1,pending,5
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 [email protected] [email protected] 0 0 pending 1
3 [email protected] [email protected] 0 1 pending 2
4 [email protected] [email protected] 1 0 pending 3
5 [email protected] [email protected] 1 1 pending 4
6 [email protected] [email protected] 1 1 pending 5
@@ -1,2 +1,2 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"[email protected]",,0,0,pending
active_email,inactive_email,active_email_checked,inactive_email_checked,status,id
"[email protected]",,0,0,pending,40
1 active_email inactive_email active_email_checked inactive_email_checked status id
2 [email protected] 0 0 pending 40
@@ -1,2 +1,2 @@
merge_accept,active_email,inactive_email,status
true,[email protected],[email protected],pending
merge_accept,active_email,inactive_email,status,id
true,[email protected],[email protected],pending,20
1 merge_accept active_email inactive_email status id
2 true [email protected] [email protected] pending 20
@@ -0,0 +1,6 @@
active_email,inactive_email,active_email_checked,inactive_email_checked,status
"[email protected]","[email protected]",0,0,pending
"[email protected]","[email protected]",0,1,pending
"[email protected]","[email protected]",1,0,pending
"[email protected]","[email protected]",1,1,pending
"[email protected]","[email protected]",1,1,pending
1 active_email inactive_email active_email_checked inactive_email_checked status
2 [email protected] [email protected] 0 0 pending
3 [email protected] [email protected] 0 1 pending
4 [email protected] [email protected] 1 0 pending
5 [email protected] [email protected] 1 1 pending
6 [email protected] [email protected] 1 1 pending
@@ -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']."
]
}
@@ -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 == ["[email protected]", ""]
assert email.to == ["[email protected]"]
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 (
"[email protected] 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="[email protected]",
inactive_email="[email protected]",
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
)
@@ -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 = "<p>Test Document</p>"
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
@@ -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,
)
@@ -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.
@@ -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 });
};
@@ -0,0 +1,20 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.12757 9.8486C5.98657 9.6993 5.91709 9.5143 5.91709 9.30858C5.91709 9.10284 5.98679 8.91775 6.13233 8.77221C6.28262 8.62192 6.47291 8.54842 6.68579 8.54842H13.1697C13.3775 8.54842 13.5623 8.62245 13.7061 8.77215C13.8559 8.91601 13.9299 9.10081 13.9299 9.30858C13.9299 9.51737 13.8553 9.70306 13.7085 9.8511C13.5643 10.0024 13.3787 10.0773 13.1697 10.0773H6.68579C6.47291 10.0773 6.28262 10.0038 6.13233 9.85349L6.13076 9.85192L6.12757 9.8486Z"
fill="currentColor"
/>
<path
d="M6.12757 12.83C5.98657 12.6807 5.91709 12.4957 5.91709 12.29C5.91709 12.0843 5.98679 11.8992 6.13233 11.7536C6.28262 11.6033 6.47291 11.5298 6.68579 11.5298H13.1697C13.3775 11.5298 13.5623 11.6039 13.7061 11.7536C13.8559 11.8974 13.9299 12.0822 13.9299 12.29C13.9299 12.4988 13.8553 12.6845 13.7085 12.8325C13.5643 12.9838 13.3787 13.0587 13.1697 13.0587H6.68579C6.47291 13.0587 6.28262 12.9852 6.13233 12.8349L6.13076 12.8333L6.12757 12.83Z"
fill="currentColor"
/>
<path
d="M5.91709 15.2885C5.91709 15.4912 5.98839 15.6726 6.12757 15.82L6.134 15.8266L6.13723 15.8296C6.28833 15.9723 6.47704 16.0401 6.68579 16.0401H9.75263C9.96123 16.0401 10.1502 15.9722 10.2975 15.8249C10.444 15.6784 10.5213 15.4956 10.5213 15.2885C10.5213 15.0768 10.4486 14.8874 10.2999 14.7374C10.1539 14.5842 9.96433 14.5113 9.75263 14.5113H6.68579C6.47293 14.5113 6.28257 14.5847 6.13226 14.735L6.12757 14.7399C5.98486 14.891 5.91709 15.0797 5.91709 15.2885Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.37975 1.24597C7.88425 0.735004 8.61944 0.5 9.54031 0.5H18.6127C19.533 0.5 20.2661 0.734736 20.7653 1.24652C21.2686 1.75666 21.5 2.49628 21.5 3.42147V16.3808C21.5 17.3112 21.2688 18.0521 20.7638 18.5572C20.2645 19.0624 19.532 19.2937 18.6127 19.2937H17.347V20.5338C17.347 21.4641 17.1158 22.2051 16.6108 22.7102C16.1115 23.2153 15.3789 23.4467 14.4597 23.4467H5.3873C4.46721 23.4467 3.73242 23.2149 3.22781 22.7103C2.72908 22.2051 2.5 21.4635 2.5 20.5338V7.57442C2.5 6.64962 2.72942 5.90915 3.22673 5.39893C3.73123 4.88796 4.46643 4.65295 5.3873 4.65295H6.65302V3.42147C6.65302 2.49666 6.88244 1.7562 7.37975 1.24597ZM8.42319 4.65295H14.4597C15.38 4.65295 16.1131 4.88769 16.6122 5.39947C17.1156 5.90962 17.347 6.64923 17.347 7.57442V17.5236H18.5444C18.9636 17.5236 19.2496 17.4163 19.4324 17.2289L19.4337 17.2275C19.6238 17.0374 19.7298 16.7549 19.7298 16.3552V3.4471C19.7298 3.04734 19.6238 2.76485 19.4337 2.57481L19.431 2.57206C19.248 2.37972 18.9625 2.27017 18.5444 2.27017H9.60866C9.19081 2.27017 8.90126 2.37956 8.71212 2.57341C8.52701 2.76329 8.42319 3.04633 8.42319 3.4471V4.65295ZM5.45564 21.6765C5.03728 21.6765 4.74743 21.5697 4.55844 21.3811C4.37372 21.1913 4.27017 20.9084 4.27017 20.5081V7.60005C4.27017 7.19928 4.37399 6.91625 4.55911 6.72636C4.74825 6.53252 5.03779 6.42313 5.45564 6.42313H14.3913C14.8095 6.42313 15.095 6.53268 15.278 6.72501L15.2807 6.72776C15.4708 6.9178 15.5768 7.20029 15.5768 7.60005V20.5081C15.5768 20.9079 15.4708 21.1904 15.2807 21.3804L15.2793 21.3818C15.0966 21.5693 14.8105 21.6765 14.3913 21.6765H5.45564Z"
fill="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -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<Doc>;
export const getDocsFavorite = async (
params: DocsFavoriteParams,
): Promise<DocsFavoriteResponse> => {
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<DocsFavoriteResponse>;
};
export const KEY_LIST_FAVORITE_DOC = 'docs_favorite_list';
type UseDocsOptions = UseQueryOptions<
DocsFavoriteResponse,
APIError,
DocsFavoriteResponse
>;
type UseInfiniteDocsOptions = InfiniteQueryConfig<DocsFavoriteResponse>;
export function useDocsFavorite(
params: DocsFavoriteParams,
queryConfig?: UseDocsOptions,
) {
return useQuery<DocsFavoriteResponse, APIError, DocsFavoriteResponse>({
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,
);
};
@@ -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<Doc> => {
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<Doc>;
};
type UseImportDocOptions = UseMutationOptions<Doc, APIError, [File, string]>;
export function useImportDoc(props?: UseImportDocOptions) {
const { toast } = useToastProvider();
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<Doc, APIError, [File, string]>({
mutationFn: importDoc,
...props,
onSuccess: (...successProps) => {
const importedDoc = successProps[0];
const updateDocsListCache = (isCreatorMe: boolean | undefined) => {
queryClient.setQueriesData<UseInfiniteQueryResultAPI<DocsResponse>>(
{
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);
},
});
}
@@ -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 };
};
@@ -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 }}
@@ -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 }}