✨(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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")),
|
||||
|
||||
@@ -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"
|
||||
@@ -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,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,5 +1,5 @@
|
||||
merge_accept,active_email,inactive_email,status
|
||||
true,[email protected],[email protected]|[email protected],pending
|
||||
true,[email protected],[email protected]|[email protected]|[email protected]|[email protected]|[email protected],pending
|
||||
true,[email protected],[email protected],pending
|
||||
true,[email protected],[email protected],pending
|
||||
merge_accept,active_email,inactive_email,status,id
|
||||
true,[email protected],[email protected]|[email protected],pending,10
|
||||
true,[email protected],[email protected]|[email protected]|[email protected]|[email protected]|[email protected],pending,11
|
||||
true,[email protected],[email protected],pending,12
|
||||
true,[email protected],[email protected],pending,13
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
)
|
||||
Binary file not shown.
@@ -0,0 +1,60 @@
|
||||

|
||||
|
||||
# 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 }}
|
||||
Reference in New Issue
Block a user