diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml index 896f07ef..385aa633 100644 --- a/.github/workflows/impress.yml +++ b/.github/workflows/impress.yml @@ -206,10 +206,11 @@ jobs: - name: Install development dependencies run: pip install --user .[dev] - - name: Install gettext (required to compile messages) + - name: Install gettext (required to compile messages) and MIME support run: | sudo apt-get update - sudo apt-get install -y gettext pandoc + sudo apt-get install -y gettext pandoc shared-mime-info + sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types - name: Generate a MO file from strings extracted from the project run: python manage.py compilemessages diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b6ccc3..5719eb92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,15 @@ and this project adheres to ## Added -- github actions to managed Crowdin workflow +- github actions to manage Crowdin workflow +- πŸ“ˆIntegrate Posthog #540 +- 🏷️(backend) add content-type to uploaded files #552 +- ✨(frontend) export pdf docx front side #537 ## Changed - πŸ’„(frontend) add abilities on doc row #581 - +- πŸ’„(frontend) improve DocsGridItem responsive padding #582 ## [2.0.1] - 2025-01-17 @@ -35,6 +38,8 @@ and this project adheres to - πŸ’„(frontend) add filtering to left panel #475 - ✨(frontend) new share modal ui #489 - ✨(frontend) add favorite feature #515 +- πŸ“(documentation) Documentation about self-hosted installation #530 +- ✨(helm) helm versioning #530 ## Changed @@ -46,7 +51,7 @@ and this project adheres to - πŸ’„(frontend) update DocHeader ui #448 - πŸ’„(frontend) update doc versioning ui #463 - πŸ’„(frontend) update doc summary ui #473 -- πŸ“(doc) update readme.md to match V2 changes #558 +- πŸ“(doc) update readme.md to match V2 changes #558 & #572 ## Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c73f028..20c4e9e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,14 @@ Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. πŸš€πŸš€πŸš€ -To get started with the project, please refer to the [README.md](https://github.com/numerique-gouv/impress/blob/main/README.md) for detailed instructions. +To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions. + +Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices. + +## Help us with translations + +You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs). +Your language is not there? Request it on our Crowdin page 😊. ## Creating an Issue diff --git a/Dockerfile b/Dockerfile index 941f0747..60f464ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,10 +72,11 @@ RUN apk add \ gettext \ gdk-pixbuf \ libffi-dev \ - pandoc \ pango \ shared-mime-info +RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types + # Copy entrypoint COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint diff --git a/README.md b/README.md index 27b1af11..27b0e59f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- Docs + Docs

@@ -27,7 +27,7 @@ Docs is a collaborative text editor designed to address common challenges in kno * 😌 Simple collaborative editing without the formatting complexity of markdown * πŸ”Œ Offline? No problem, keep writing, your edits will get synced when back online * πŸ’… Create clean documents with limited but beautiful formatting options and focus on content -* 🧱 Built for productivity (markdown support, many block types, slash commands, markdown support, keyboard shortcuts) (page in french sorry πŸ˜…). +* 🧱 Built for productivity (markdown support, many block types, slash commands, keyboard shortcuts). * ✨ Save time thanks to our AI actions (generate, sum up, correct, translate) ### Collaborate @@ -91,7 +91,7 @@ password: impress $ make run-with-frontend ``` -⚠️ For the frontend developper, it is often better to run the frontend in development mode locally. +⚠️ For the frontend developer, it is often better to run the frontend in development mode locally. To do so, install the frontend dependencies with the following command: @@ -144,12 +144,14 @@ Want to know where the project is headed? [πŸ—ΊοΈ Checkout our roadmap](https:/ ## Licence πŸ“ This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)). -While Docs is public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project. +While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project. ## Contributing πŸ™Œ -This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design decisions. +This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions. -If you intend to make pull requests see CONTRIBUTING for guidelines. +You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs). + +If you intend to make pull requests see [CONTRIBUTING](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) for guidelines. Directory structure: @@ -167,7 +169,15 @@ docs ## Credits ❀️ ### Stack -Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/) and [BlocNote.js](https://www.blocknotejs.org/) +Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/) -### States ❀️ open source -Docs is the result of a joint effort lead by the French πŸ‡«πŸ‡·πŸ₯– ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German πŸ‡©πŸ‡ͺπŸ₯¨ government ([ZenDiS](https://zendis.de/)). We are always looking for new public partners feel free to reach out if you are interested in using or contributing to docs. \ No newline at end of file +### Gov ❀️ open source +Docs is the result of a joint effort lead by the French πŸ‡«πŸ‡·πŸ₯– ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German πŸ‡©πŸ‡ͺπŸ₯¨ governments ([ZenDiS](https://zendis.de/)). + +We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/). + +We are always looking for new public partners (we are currently onboarding the Netherlands πŸ‡³πŸ‡±πŸ§€), feel free to [reach out](https://matrix.to/#/#docs-official:matrix.org) if you are interested in using or contributing to Docs. + +

+ +

\ No newline at end of file diff --git a/docs/assets/docs-logo.png b/docs/assets/docs-logo.png new file mode 100644 index 00000000..fe590844 Binary files /dev/null and b/docs/assets/docs-logo.png differ diff --git a/docs/assets/europe<3opensource.png b/docs/assets/europe<3opensource.png new file mode 100644 index 00000000..00606b5e Binary files /dev/null and b/docs/assets/europe<3opensource.png differ diff --git a/docs/assets/logo-docs.png b/docs/assets/logo-docs.png deleted file mode 100644 index 54d3aa4d..00000000 Binary files a/docs/assets/logo-docs.png and /dev/null differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 00000000..949969ff Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/examples/impress.values.yaml b/docs/examples/impress.values.yaml index e21e7ec3..bc090450 100644 --- a/docs/examples/impress.values.yaml +++ b/docs/examples/impress.values.yaml @@ -40,6 +40,7 @@ backend: LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io + POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}" DB_HOST: postgresql DB_NAME: impress DB_USER: dinum @@ -121,6 +122,12 @@ yProvider: COLLABORATION_SERVER_SECRET: my-secret Y_PROVIDER_API_KEY: my-secret +posthog: + ingress: + enabled: false + ingressAssets: + enabled: false + ingress: enabled: true host: impress.127.0.0.1.nip.io diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d5b0d4c7..e2369f49 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -388,6 +388,7 @@ class FileUploadSerializer(serializers.Serializer): raise serializers.ValidationError("Could not determine file extension.") self.context["expected_extension"] = extension + self.context["content_type"] = magic_mime_type return file @@ -395,6 +396,7 @@ class FileUploadSerializer(serializers.Serializer): """Override validate to add the computed extension to validated_data.""" attrs["expected_extension"] = self.context["expected_extension"] attrs["is_unsafe"] = self.context["is_unsafe"] + attrs["content_type"] = self.context["content_type"] return attrs diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 0f83e1e5..a98ad4f5 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -20,6 +20,7 @@ from django.db.models import ( Subquery, Value, ) +from django.db.models.expressions import RawSQL from django.http import Http404 import rest_framework as drf @@ -150,29 +151,35 @@ class UserViewSet( """ queryset = self.queryset - if self.action == "list": - # Exclude all users already in the given document - if document_id := self.request.GET.get("document_id", ""): - queryset = queryset.exclude(documentaccess__document_id=document_id) + if self.action != "list": + return queryset - # Filter users by email similarity - if query := self.request.GET.get("q", ""): - # For performance reasons we filter first by similarity, which relies on an index, - # then only calculate precise similarity scores for sorting purposes - queryset = queryset.filter(email__trigram_word_similar=query) + # Exclude all users already in the given document + if document_id := self.request.GET.get("document_id", ""): + queryset = queryset.exclude(documentaccess__document_id=document_id) - queryset = queryset.annotate( - similarity=TrigramSimilarity("email", query) + if not (query := self.request.GET.get("q", "")): + return queryset + + # For emails, match emails by Levenstein distance to prevent typing errors + if "@" in query: + return ( + queryset.annotate( + distance=RawSQL("levenshtein(email::text, %s::text)", (query,)) ) - # When the query only is on the name part, we should try to make many proposals - # But when the query looks like an email we should only propose serious matches - threshold = 0.6 if "@" in query else 0.1 + .filter(distance__lte=3) + .order_by("distance", "email") + ) - queryset = queryset.filter(similarity__gt=threshold).order_by( - "-similarity", "email" - ) - - return queryset + # Use trigram similarity for non-email-like queries + # For performance reasons we filter first by similarity, which relies on an + # index, then only calculate precise similarity scores for sorting purposes + return ( + queryset.filter(email__trigram_word_similar=query) + .annotate(similarity=TrigramSimilarity("email", query)) + .filter(similarity__gt=0.2) + .order_by("-similarity", "email") + ) @drf.decorators.action( detail=False, @@ -605,7 +612,10 @@ class DocumentViewSet( key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}" # Prepare metadata for storage - extra_args = {"Metadata": {"owner": str(request.user.id)}} + extra_args = { + "Metadata": {"owner": str(request.user.id)}, + "ContentType": serializer.validated_data["content_type"], + } if serializer.validated_data["is_unsafe"]: extra_args["Metadata"]["is_unsafe"] = "true" @@ -936,40 +946,6 @@ class TemplateViewSet( role=models.RoleChoices.OWNER, ) - @drf.decorators.action( - detail=True, - methods=["post"], - url_path="generate-document", - permission_classes=[permissions.AccessPermission], - ) - # pylint: disable=unused-argument - def generate_document(self, request, pk=None): - """ - Generate and return a document for this template around the - body passed as argument. - - 2 types of body are accepted: - - HTML: body_type = "html" - - Markdown: body_type = "markdown" - - 2 types of documents can be generated: - - PDF: format = "pdf" - - Docx: format = "docx" - """ - serializer = serializers.DocumentGenerationSerializer(data=request.data) - - if not serializer.is_valid(): - return drf.response.Response( - serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST - ) - - body = serializer.validated_data["body"] - body_type = serializer.validated_data["body_type"] - export_format = serializer.validated_data["format"] - - template = self.get_object() - return template.generate_document(body, body_type, export_format) - class TemplateAccessViewSet( ResourceAccessViewsetMixin, @@ -1124,6 +1100,7 @@ class ConfigView(drf.views.APIView): "ENVIRONMENT", "FRONTEND_THEME", "MEDIA_BASE_URL", + "POSTHOG_KEY", "LANGUAGES", "LANGUAGE_CODE", "SENTRY_DSN", diff --git a/src/backend/core/management/__init__.py b/src/backend/core/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/management/commands/__init__.py b/src/backend/core/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/management/commands/update_files_content_type_metadata.py b/src/backend/core/management/commands/update_files_content_type_metadata.py new file mode 100644 index 00000000..bb2e5253 --- /dev/null +++ b/src/backend/core/management/commands/update_files_content_type_metadata.py @@ -0,0 +1,95 @@ +"""Management command updating the metadata for all the files in the MinIO bucket.""" + +from django.core.files.storage import default_storage +from django.core.management.base import BaseCommand + +import magic + +from core.models import Document + +# pylint: disable=too-many-locals, broad-exception-caught + + +class Command(BaseCommand): + """Update the metadata for all the files in the MinIO bucket.""" + + help = __doc__ + + def handle(self, *args, **options): + """Execute management command.""" + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + + mime_detector = magic.Magic(mime=True) + + documents = Document.objects.all() + self.stdout.write( + f"[INFO] Found {documents.count()} documents. Starting ContentType fix..." + ) + + for doc in documents: + doc_id_str = str(doc.id) + prefix = f"{doc_id_str}/attachments/" + self.stdout.write( + f"[INFO] Processing attachments under prefix '{prefix}' ..." + ) + + continuation_token = None + total_updated = 0 + + while True: + list_kwargs = {"Bucket": bucket_name, "Prefix": prefix} + if continuation_token: + list_kwargs["ContinuationToken"] = continuation_token + + response = s3_client.list_objects_v2(**list_kwargs) + + # If no objects found under this prefix, break out of the loop + if "Contents" not in response: + break + + for obj in response["Contents"]: + key = obj["Key"] + + # Skip if it's a folder + if key.endswith("/"): + continue + + try: + # Get existing metadata + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + + # Read first ~1KB for MIME detection + partial_obj = s3_client.get_object( + Bucket=bucket_name, Key=key, Range="bytes=0-1023" + ) + partial_data = partial_obj["Body"].read() + + # Detect MIME type + magic_mime_type = mime_detector.from_buffer(partial_data) + + # Update ContentType + s3_client.copy_object( + Bucket=bucket_name, + CopySource={"Bucket": bucket_name, "Key": key}, + Key=key, + ContentType=magic_mime_type, + Metadata=head_resp.get("Metadata", {}), + MetadataDirective="REPLACE", + ) + total_updated += 1 + + except Exception as exc: # noqa + self.stderr.write( + f"[ERROR] Could not update ContentType for {key}: {exc}" + ) + + if response.get("IsTruncated"): + continuation_token = response.get("NextContinuationToken") + else: + break + + if total_updated > 0: + self.stdout.write( + f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}." + ) diff --git a/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py b/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py new file mode 100644 index 00000000..db7fbb3a --- /dev/null +++ b/src/backend/core/migrations/0013_activate_fuzzystrmatch_extension.py @@ -0,0 +1,16 @@ +# Generated by Django 5.1.4 on 2025-01-25 08:38 + +from django.db import migrations + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0012_make_document_creator_and_invitation_issuer_optional'), + ] + + operations = [ + migrations.RunSQL( + "CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;", + reverse_sql="DROP EXTENSION IF EXISTS fuzzystrmatch;", + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 52e66aab..1dc85739 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -5,11 +5,8 @@ Declare and configure the models for the impress core application import hashlib import smtplib -import tempfile -import textwrap import uuid from datetime import timedelta -from io import BytesIO from logging import getLogger from django.conf import settings @@ -21,19 +18,12 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.core.mail import send_mail from django.db import models -from django.http import FileResponse -from django.template.base import Template as DjangoTemplate -from django.template.context import Context from django.template.loader import render_to_string -from django.utils import html, timezone +from django.utils import timezone from django.utils.functional import cached_property, lazy from django.utils.translation import get_language, override from django.utils.translation import gettext_lazy as _ -import frontmatter -import markdown -import pypandoc -import weasyprint from botocore.exceptions import ClientError from timezone_field import TimeZoneField @@ -794,107 +784,6 @@ class Template(BaseModel): "retrieve": can_get, } - def generate_pdf(self, body_html, metadata): - """ - Generate and return a pdf document wrapped around the current template - """ - document_html = weasyprint.HTML( - string=DjangoTemplate(self.code).render( - Context({"body": html.format_html(body_html), **metadata}) - ) - ) - css = weasyprint.CSS( - string=self.css, - font_config=weasyprint.text.fonts.FontConfiguration(), - ) - - pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1) - response = FileResponse(BytesIO(pdf_content), content_type="application/pdf") - response["Content-Disposition"] = f"attachment; filename={self.title}.pdf" - - return response - - def generate_word(self, body_html, metadata): - """ - Generate and return a docx document wrapped around the current template - """ - template_string = DjangoTemplate(self.code).render( - Context({"body": html.format_html(body_html), **metadata}) - ) - - html_string = f""" - - - - - - - {template_string} - - - """ - - reference_docx = "core/static/reference.docx" - output = BytesIO() - - # Convert the HTML to a temporary docx file - with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file: - output_path = tmp_file.name - - pypandoc.convert_text( - html_string, - "docx", - format="html", - outputfile=output_path, - extra_args=["--reference-doc", reference_docx], - ) - - # Create a BytesIO object to store the output of the temporary docx file - with open(output_path, "rb") as f: - output = BytesIO(f.read()) - - # Ensure the pointer is at the beginning - output.seek(0) - - response = FileResponse( - output, - content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ) - response["Content-Disposition"] = f"attachment; filename={self.title}.docx" - - return response - - def generate_document(self, body, body_type, export_format): - """ - Generate and return a document for this template around the - body passed as argument. - - 2 types of body are accepted: - - HTML: body_type = "html" - - Markdown: body_type = "markdown" - - 2 types of documents can be generated: - - PDF: export_format = "pdf" - - Docx: export_format = "docx" - """ - document = frontmatter.loads(body) - metadata = document.metadata - strip_body = document.content.strip() - - if body_type == "html": - body_html = strip_body - else: - body_html = ( - markdown.markdown(textwrap.dedent(strip_body)) if strip_body else "" - ) - - if export_format == "pdf": - return self.generate_pdf(body_html, metadata) - - return self.generate_word(body_html, metadata) - class TemplateAccess(BaseAccess): """Relation model to give access to a template for a user or a team with a role.""" diff --git a/src/backend/core/static/reference.docx b/src/backend/core/static/reference.docx deleted file mode 100644 index 2192455d..00000000 Binary files a/src/backend/core/static/reference.docx and /dev/null differ diff --git a/src/backend/core/tests/commands/test_update_files_content_type_metadata.py b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py new file mode 100644 index 00000000..3ef78314 --- /dev/null +++ b/src/backend/core/tests/commands/test_update_files_content_type_metadata.py @@ -0,0 +1,50 @@ +""" +Unit test for `update_files_content_type_metadata` command. +""" + +import uuid + +from django.core.files.storage import default_storage +from django.core.management import call_command + +import pytest + +from core import factories + + +@pytest.mark.django_db +def test_update_files_content_type_metadata(): + """ + Test that the command `update_files_content_type_metadata` + fixes the ContentType of attachment in the storage. + """ + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + + # Create files with a wrong ContentType + keys = [] + for _ in range(10): + doc_id = uuid.uuid4() + factories.DocumentFactory(id=doc_id) + key = f"{doc_id}/attachments/testfile.png" + keys.append(key) + fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..." + s3_client.put_object( + Bucket=bucket_name, + Key=key, + Body=fake_png, + ContentType="text/plain", + Metadata={"owner": "None"}, + ) + + # Call the command that fixes the ContentType + call_command("update_files_content_type_metadata") + + for key in keys: + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + assert head_resp["ContentType"] == "image/png", ( + f"ContentType not fixed, got {head_resp['ContentType']!r}" + ) + + # Check that original metadata was preserved + assert head_resp["Metadata"].get("owner") == "None" diff --git a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py index 1288f8ca..4a6564d6 100644 --- a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py +++ b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py @@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success(): assert response.status_code == 201 pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png") - match = pattern.search(response.json()["file"]) + file_path = response.json()["file"] + match = pattern.search(file_path) file_id = match.group(1) # Validate that file_id is a valid UUID uuid.UUID(file_id) + # Now, check the metadata of the uploaded file + key = file_path.replace("/media", "") + file_head = default_storage.connection.meta.client.head_object( + Bucket=default_storage.bucket_name, Key=key + ) + + assert file_head["Metadata"] == {"owner": "None"} + assert file_head["ContentType"] == "image/png" + @pytest.mark.parametrize( "reach, role", @@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams): Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id)} + assert file_head["ContentType"] == "image/png" def test_api_documents_attachment_upload_invalid(client): @@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings): @pytest.mark.parametrize( - "name,content,extension", + "name,content,extension,content_type", [ - ("test.exe", b"text", "exe"), - ("test", b"text", "txt"), - ("test.aaaaaa", b"test", "txt"), - ("test.txt", PIXEL, "txt"), - ("test.py", b"#!/usr/bin/python", "py"), + ("test.exe", b"text", "exe", "text/plain"), + ("test", b"text", "txt", "text/plain"), + ("test.aaaaaa", b"test", "txt", "text/plain"), + ("test.txt", PIXEL, "txt", "image/png"), + ("test.py", b"#!/usr/bin/python", "py", "text/plain"), ], ) -def test_api_documents_attachment_upload_fix_extension(name, content, extension): +def test_api_documents_attachment_upload_fix_extension( + name, content, extension, content_type +): """ A file with no extension or a wrong extension is accepted and the extension is corrected in storage. @@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension) Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["ContentType"] == content_type def test_api_documents_attachment_upload_empty_file(): @@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe(): Bucket=default_storage.bucket_name, Key=key ) assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["ContentType"] == "application/octet-stream" diff --git a/src/backend/core/tests/templates/test_api_templates_generate_document.py b/src/backend/core/tests/templates/test_api_templates_generate_document.py deleted file mode 100644 index 0ed8f4d3..00000000 --- a/src/backend/core/tests/templates/test_api_templates_generate_document.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Test users API endpoints in the impress core app. -""" - -import pytest -from rest_framework.test import APIClient - -from core import factories -from core.tests.conftest import TEAM, USER, VIA - -pytestmark = pytest.mark.django_db - - -def test_api_templates_generate_document_anonymous_public(): - """Anonymous users can generate pdf document with public templates.""" - template = factories.TemplateFactory(is_public=True) - data = { - "body": "# Test markdown body", - } - - response = APIClient().post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_anonymous_not_public(): - """ - Anonymous users should not be allowed to generate pdf document with templates - that are not marked as public. - """ - template = factories.TemplateFactory(is_public=False) - data = { - "body": "# Test markdown body", - } - - response = APIClient().post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 401 - assert response.json() == { - "detail": "Authentication credentials were not provided." - } - - -def test_api_templates_generate_document_authenticated_public(): - """Authenticated users can generate pdf document with public templates.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "# Test markdown body"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_authenticated_not_public(): - """ - Authenticated users should not be allowed to generate pdf document with templates - that are not marked as public. - """ - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=False) - data = {"body": "# Test markdown body"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 403 - assert response.json() == { - "detail": "You do not have permission to perform this action." - } - - -@pytest.mark.parametrize("via", VIA) -def test_api_templates_generate_document_related(via, mock_user_teams): - """Users related to a template can generate pdf document.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - access = None - if via == USER: - access = factories.UserTemplateAccessFactory(user=user) - elif via == TEAM: - mock_user_teams.return_value = ["lasuite", "unknown"] - access = factories.TeamTemplateAccessFactory(team="lasuite") - - data = {"body": "# Test markdown body"} - - response = client.post( - f"/api/v1.0/templates/{access.template_id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_type_html(): - """Generate pdf document with the body type html.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "

Test body

", "body_type": "html"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_type_markdown(): - """Generate pdf document with the body type markdown.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "# Test markdown body", "body_type": "markdown"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert response.headers["content-type"] == "application/pdf" - - -def test_api_templates_generate_document_type_unknown(): - """Generate pdf document with the body type unknown.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "# Test markdown body", "body_type": "unknown"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 400 - assert response.json() == { - "body_type": [ - '"unknown" is not a valid choice.', - ] - } - - -def test_api_templates_generate_document_export_docx(): - """Generate pdf document with the body type html.""" - user = factories.UserFactory() - - client = APIClient() - client.force_login(user) - - template = factories.TemplateFactory(is_public=True) - data = {"body": "

Test body

", "body_type": "html", "format": "docx"} - - response = client.post( - f"/api/v1.0/templates/{template.id!s}/generate-document/", - data, - format="json", - ) - - assert response.status_code == 200 - assert ( - response.headers["content-type"] - == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ) diff --git a/src/backend/core/tests/templates/test_api_templates_list.py b/src/backend/core/tests/templates/test_api_templates_list.py index 44582b8f..11df4fa9 100644 --- a/src/backend/core/tests/templates/test_api_templates_list.py +++ b/src/backend/core/tests/templates/test_api_templates_list.py @@ -187,9 +187,9 @@ def test_api_templates_list_order_default(): response_template_ids = [template["id"] for template in response_data["results"]] template_ids.reverse() - assert ( - response_template_ids == template_ids - ), "created_at values are not sorted from newest to oldest" + assert response_template_ids == template_ids, ( + "created_at values are not sorted from newest to oldest" + ) def test_api_templates_list_order_param(): @@ -215,6 +215,6 @@ def test_api_templates_list_order_param(): response_template_ids = [template["id"] for template in response_data["results"]] - assert ( - response_template_ids == templates_ids - ), "created_at values are not sorted from oldest to newest" + assert response_template_ids == templates_ids, ( + "created_at values are not sorted from oldest to newest" + ) diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index a5eb151f..a625260e 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db CRISP_WEBSITE_ID="123", FRONTEND_THEME="test-theme", MEDIA_BASE_URL="http://testserver/", + POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"}, SENTRY_DSN="https://sentry.test/123", ) @pytest.mark.parametrize("is_authenticated", [False, True]) @@ -41,5 +42,6 @@ def test_api_config(is_authenticated): "LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]], "LANGUAGE_CODE": "en-us", "MEDIA_BASE_URL": "http://testserver/", + "POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"}, "SENTRY_DSN": "https://sentry.test/123", } diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index e739d4d1..cb3022d3 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -42,8 +42,9 @@ def test_api_users_list_authenticated(): def test_api_users_list_query_email(): """ - Authenticated users should be able to list users - and filter by email. + Authenticated users should be able to list users and filter by email. + Only results with a Levenstein distance less than 3 with the query should be returned. + We want to match by Levenstein distance because we want to prevent typing errors. """ user = factories.UserFactory() @@ -51,9 +52,7 @@ def test_api_users_list_query_email(): client.force_login(user) dave = factories.UserFactory(email="david.bowman@work.com") - nicole = factories.UserFactory(email="nicole_foole@work.com") - frank = factories.UserFactory(email="frank_poole@work.com") - factories.UserFactory(email="heywood_floyd@work.com") + factories.UserFactory(email="nicole.bowman@work.com") response = client.get( "/api/v1.0/users/?q=david.bowman@work.com", @@ -62,59 +61,53 @@ def test_api_users_list_query_email(): user_ids = [user["id"] for user in response.json()["results"]] assert user_ids == [str(dave.id)] - response = client.get("/api/v1.0/users/?q=oole") - + response = client.get( + "/api/v1.0/users/?q=davig.bovman@worm.com", + ) assert response.status_code == 200 user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(nicole.id), str(frank.id)] + assert user_ids == [str(dave.id)] + + response = client.get( + "/api/v1.0/users/?q=davig.bovman@worm.cop", + ) + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()["results"]] + assert user_ids == [] def test_api_users_list_query_email_matching(): - """While filtering by email, results should be filtered and sorted by similarity""" + """While filtering by email, results should be filtered and sorted by Levenstein distance.""" user = factories.UserFactory() client = APIClient() client.force_login(user) - alice = factories.UserFactory(email="alice.johnson@example.gouv.fr") - factories.UserFactory(email="jane.smith@example.gouv.fr") - michael_wilson = factories.UserFactory(email="michael.wilson@example.gouv.fr") - factories.UserFactory(email="david.jones@example.gouv.fr") - michael_brown = factories.UserFactory(email="michael.brown@example.gouv.fr") - factories.UserFactory(email="sophia.taylor@example.gouv.fr") + user1 = factories.UserFactory(email="alice.johnson@example.gouv.fr") + user2 = factories.UserFactory(email="alice.johnnson@example.gouv.fr") + user3 = factories.UserFactory(email="alice.kohlson@example.gouv.fr") + user4 = factories.UserFactory(email="alicia.johnnson@example.gouv.fr") + user5 = factories.UserFactory(email="alicia.johnnson@example.gov.uk") + factories.UserFactory(email="alice.thomson@example.gouv.fr") response = client.get( - "/api/v1.0/users/?q=michael.johnson@example.gouv.f", + "/api/v1.0/users/?q=alice.johnson@example.gouv.fr", ) assert response.status_code == 200 user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(michael_wilson.id)] + assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)] - response = client.get("/api/v1.0/users/?q=michael.johnson@example.gouv.fr") + response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr") assert response.status_code == 200 user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(michael_wilson.id), str(alice.id), str(michael_brown.id)] - - response = client.get( - "/api/v1.0/users/?q=ajohnson@example.gouv.f", - ) - assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(alice.id)] - - response = client.get( - "/api/v1.0/users/?q=michael.wilson@example.gouv.f", - ) - assert response.status_code == 200 - user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(michael_wilson.id)] + assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)] def test_api_users_list_query_email_exclude_doc_user(): """ - Authenticated users should be able to list users - and filter by email and exclude users who have access to a document. + Authenticated users should be able to list users while filtering by email + and excluding users who have access to a document. """ user = factories.UserFactory() document = factories.DocumentFactory() @@ -122,17 +115,19 @@ def test_api_users_list_query_email_exclude_doc_user(): client = APIClient() client.force_login(user) - nicole = factories.UserFactory(email="nicole_foole@work.com") - frank = factories.UserFactory(email="frank_poole@work.com") + nicole_fool = factories.UserFactory(email="nicole_fool@work.com") + nicole_pool = factories.UserFactory(email="nicole_pool@work.com") factories.UserFactory(email="heywood_floyd@work.com") - factories.UserDocumentAccessFactory(document=document, user=frank) + factories.UserDocumentAccessFactory(document=document, user=nicole_pool) - response = client.get("/api/v1.0/users/?q=oole&document_id=" + str(document.id)) + response = client.get( + "/api/v1.0/users/?q=nicole_fool@work.com&document_id=" + str(document.id) + ) assert response.status_code == 200 user_ids = [user["id"] for user in response.json()["results"]] - assert user_ids == [str(nicole.id)] + assert user_ids == [str(nicole_fool.id)] def test_api_users_retrieve_me_anonymous(): diff --git a/src/backend/core/tests/test_models_templates.py b/src/backend/core/tests/test_models_templates.py index 6e7cba2c..95f8fbde 100644 --- a/src/backend/core/tests/test_models_templates.py +++ b/src/backend/core/tests/test_models_templates.py @@ -2,10 +2,6 @@ Unit tests for the Template model """ -import os -import time -from unittest import mock - from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ValidationError @@ -189,31 +185,3 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries): "partial_update": False, "generate_document": True, } - - -def test_models_templates__generate_word(): - """Generate word document and assert no tmp files are left in /tmp folder.""" - template = factories.TemplateFactory() - response = template.generate_word("

Test body

", {}) - - assert response.status_code == 200 - assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0 - - -@mock.patch( - "pypandoc.convert_text", - side_effect=RuntimeError("Conversion failed"), -) -def test_models_templates__generate_word__raise_error(_mock_pypandoc): - """ - Generate word document and assert no tmp files are left in /tmp folder - even when the conversion fails. - """ - template = factories.TemplateFactory() - - try: - template.generate_word("

Test body

", {}) - except RuntimeError as e: - assert str(e) == "Conversion failed" - time.sleep(0.5) - assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0 diff --git a/src/backend/core/tests/test_services_collaboration_services.py b/src/backend/core/tests/test_services_collaboration_services.py index 7d02e252..5a915170 100644 --- a/src/backend/core/tests/test_services_collaboration_services.py +++ b/src/backend/core/tests/test_services_collaboration_services.py @@ -55,9 +55,9 @@ def mock_reset_connections(settings): ) yield - assert ( - len(rsps.calls) == 1 - ), "Expected one call to reset-connections endpoint" + assert len(rsps.calls) == 1, ( + "Expected one call to reset-connections endpoint" + ) request = rsps.calls[0].request assert request.url == endpoint_url, f"Unexpected URL called: {request.url}" assert ( @@ -66,9 +66,9 @@ def mock_reset_connections(settings): ), "Incorrect Authorization header" if user_id: - assert ( - request.headers.get("X-User-Id") == user_id - ), "Incorrect X-User-Id header" + assert request.headers.get("X-User-Id") == user_id, ( + "Incorrect X-User-Id header" + ) return _mock_reset_connections diff --git a/src/backend/demo/data/template/code.txt b/src/backend/demo/data/template/code.txt index 0ab83f60..56f6736b 100644 --- a/src/backend/demo/data/template/code.txt +++ b/src/backend/demo/data/template/code.txt @@ -1,10 +1,2 @@ - -
- -
-
-
{{ body }}
-
-
+ +
\ No newline at end of file diff --git a/src/backend/demo/data/template/css.txt b/src/backend/demo/data/template/css.txt index 79a440ab..e69de29b 100644 --- a/src/backend/demo/data/template/css.txt +++ b/src/backend/demo/data/template/css.txt @@ -1,20 +0,0 @@ -body { - background: white; - font-family: arial; -} -.header img { - width: 5cm; - margin-left: -0.4cm; -} -.body{ - margin-top: 1.5rem; -} -img { - max-width: 100%; -} -[custom-style="center"] { - text-align: center; -} -[custom-style="right"] { - text-align: right; -} diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 495ec4bc..df1cd39b 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -390,6 +390,11 @@ class Base(Configuration): None, environ_name="FRONTEND_THEME", environ_prefix=None ) + # Posthog + POSTHOG_KEY = values.DictValue( + None, environ_name="POSTHOG_KEY", environ_prefix=None + ) + # Crisp CRISP_WEBSITE_ID = values.Value( None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None diff --git a/src/backend/locale/de_DE/LC_MESSAGES/django.po b/src/backend/locale/de_DE/LC_MESSAGES/django.po index a9db616c..31bc9062 100644 --- a/src/backend/locale/de_DE/LC_MESSAGES/django.po +++ b/src/backend/locale/de_DE/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-01-15 21:00+0000\n" -"PO-Revision-Date: 2025-01-16 19:53\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: German\n" "Language: de_DE\n" diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.po b/src/backend/locale/en_US/LC_MESSAGES/django.po index 1b2f03c3..34382019 100644 --- a/src/backend/locale/en_US/LC_MESSAGES/django.po +++ b/src/backend/locale/en_US/LC_MESSAGES/django.po @@ -1,9 +1,9 @@ msgid "" msgstr "" -"Project-Id-Version: lasuite-people\n" +"Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-12-17 15:50+0000\n" -"PO-Revision-Date: 2024-12-17 15:53\n" +"POT-Creation-Date: 2025-01-15 21:00+0000\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: English\n" "Language: en_US\n" @@ -11,384 +11,342 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Crowdin-Project: lasuite-people\n" -"X-Crowdin-Project-ID: 637934\n" +"X-Crowdin-Project: lasuite-docs\n" +"X-Crowdin-Project-ID: 754523\n" "X-Crowdin-Language: en\n" "X-Crowdin-File: backend-impress.pot\n" -"X-Crowdin-File-ID: 8\n" +"X-Crowdin-File-ID: 18\n" -#: core/admin.py:33 +#: build/lib/core/admin.py:33 core/admin.py:33 msgid "Personal info" msgstr "" -#: core/admin.py:46 +#: build/lib/core/admin.py:46 core/admin.py:46 msgid "Permissions" msgstr "" -#: core/admin.py:58 +#: build/lib/core/admin.py:58 core/admin.py:58 msgid "Important dates" msgstr "" -#: core/api/filters.py:16 +#: build/lib/core/api/filters.py:16 core/api/filters.py:16 msgid "Creator is me" msgstr "" -#: core/api/filters.py:19 +#: build/lib/core/api/filters.py:19 core/api/filters.py:19 msgid "Favorite" msgstr "" -#: core/api/filters.py:22 +#: build/lib/core/api/filters.py:22 core/api/filters.py:22 msgid "Title" msgstr "" -#: core/api/serializers.py:307 +#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317 msgid "A new document was created on your behalf!" msgstr "" -#: core/api/serializers.py:311 +#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321 msgid "You have been granted ownership of a new document:" msgstr "" -#: core/api/serializers.py:414 +#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422 msgid "Body" msgstr "" -#: core/api/serializers.py:417 +#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425 msgid "Body type" msgstr "" -#: core/api/serializers.py:423 +#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431 msgid "Format" msgstr "" -#: core/authentication/backends.py:57 +#: build/lib/core/authentication/backends.py:61 +#: core/authentication/backends.py:61 msgid "Invalid response format or token verification failed" msgstr "" -#: core/authentication/backends.py:81 -msgid "User info contained no recognizable user identification" -msgstr "" - -#: core/authentication/backends.py:88 +#: build/lib/core/authentication/backends.py:108 +#: core/authentication/backends.py:108 msgid "User account is disabled" msgstr "" -#: core/models.py:62 core/models.py:69 +#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63 +#: core/models.py:70 msgid "Reader" msgstr "" -#: core/models.py:63 core/models.py:70 +#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64 +#: core/models.py:71 msgid "Editor" msgstr "" -#: core/models.py:71 +#: build/lib/core/models.py:72 core/models.py:72 msgid "Administrator" msgstr "" -#: core/models.py:72 +#: build/lib/core/models.py:73 core/models.py:73 msgid "Owner" msgstr "" -#: core/models.py:83 +#: build/lib/core/models.py:84 core/models.py:84 msgid "Restricted" msgstr "" -#: core/models.py:87 +#: build/lib/core/models.py:88 core/models.py:88 msgid "Authenticated" msgstr "" -#: core/models.py:89 +#: build/lib/core/models.py:90 core/models.py:90 msgid "Public" msgstr "" -#: core/models.py:101 +#: build/lib/core/models.py:112 core/models.py:112 msgid "id" msgstr "" -#: core/models.py:102 +#: build/lib/core/models.py:113 core/models.py:113 msgid "primary key for the record as UUID" msgstr "" -#: core/models.py:108 +#: build/lib/core/models.py:119 core/models.py:119 msgid "created on" msgstr "" -#: core/models.py:109 +#: build/lib/core/models.py:120 core/models.py:120 msgid "date and time at which a record was created" msgstr "" -#: core/models.py:114 +#: build/lib/core/models.py:125 core/models.py:125 msgid "updated on" msgstr "" -#: core/models.py:115 +#: build/lib/core/models.py:126 core/models.py:126 msgid "date and time at which a record was last updated" msgstr "" -#: core/models.py:135 +#: build/lib/core/models.py:162 core/models.py:162 +msgid "We couldn't find a user with this sub but the email is already associated with a registered user." +msgstr "" + +#: build/lib/core/models.py:175 core/models.py:175 msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters." msgstr "" -#: core/models.py:141 +#: build/lib/core/models.py:181 core/models.py:181 msgid "sub" msgstr "" -#: core/models.py:143 +#: build/lib/core/models.py:183 core/models.py:183 msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only." msgstr "" -#: core/models.py:152 +#: build/lib/core/models.py:192 core/models.py:192 msgid "full name" msgstr "" -#: core/models.py:153 +#: build/lib/core/models.py:193 core/models.py:193 msgid "short name" msgstr "" -#: core/models.py:155 +#: build/lib/core/models.py:195 core/models.py:195 msgid "identity email address" msgstr "" -#: core/models.py:160 +#: build/lib/core/models.py:200 core/models.py:200 msgid "admin email address" msgstr "" -#: core/models.py:167 +#: build/lib/core/models.py:207 core/models.py:207 msgid "language" msgstr "" -#: core/models.py:168 +#: build/lib/core/models.py:208 core/models.py:208 msgid "The language in which the user wants to see the interface." msgstr "" -#: core/models.py:174 +#: build/lib/core/models.py:214 core/models.py:214 msgid "The timezone in which the user wants to see times." msgstr "" -#: core/models.py:177 +#: build/lib/core/models.py:217 core/models.py:217 msgid "device" msgstr "" -#: core/models.py:179 +#: build/lib/core/models.py:219 core/models.py:219 msgid "Whether the user is a device or a real user." msgstr "" -#: core/models.py:182 +#: build/lib/core/models.py:222 core/models.py:222 msgid "staff status" msgstr "" -#: core/models.py:184 +#: build/lib/core/models.py:224 core/models.py:224 msgid "Whether the user can log into this admin site." msgstr "" -#: core/models.py:187 +#: build/lib/core/models.py:227 core/models.py:227 msgid "active" msgstr "" -#: core/models.py:190 +#: build/lib/core/models.py:230 core/models.py:230 msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." msgstr "" -#: core/models.py:202 +#: build/lib/core/models.py:242 core/models.py:242 msgid "user" msgstr "" -#: core/models.py:203 +#: build/lib/core/models.py:243 core/models.py:243 msgid "users" msgstr "" -#: core/models.py:342 core/models.py:718 +#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382 +#: core/models.py:758 msgid "title" msgstr "" -#: core/models.py:364 +#: build/lib/core/models.py:404 core/models.py:404 msgid "Document" msgstr "" -#: core/models.py:365 +#: build/lib/core/models.py:405 core/models.py:405 msgid "Documents" msgstr "" -#: core/models.py:368 +#: build/lib/core/models.py:408 core/models.py:408 msgid "Untitled Document" msgstr "" -#: core/models.py:593 +#: build/lib/core/models.py:633 core/models.py:633 #, python-brace-format msgid "{name} shared a document with you!" msgstr "" -#: core/models.py:597 +#: build/lib/core/models.py:637 core/models.py:637 #, python-brace-format msgid "{name} invited you with the role \"{role}\" on the following document:" msgstr "" -#: core/models.py:600 +#: build/lib/core/models.py:640 core/models.py:640 #, python-brace-format msgid "{name} shared a document with you: {title}" msgstr "" -#: core/models.py:623 +#: build/lib/core/models.py:663 core/models.py:663 msgid "Document/user link trace" msgstr "" -#: core/models.py:624 +#: build/lib/core/models.py:664 core/models.py:664 msgid "Document/user link traces" msgstr "" -#: core/models.py:630 +#: build/lib/core/models.py:670 core/models.py:670 msgid "A link trace already exists for this document/user." msgstr "" -#: core/models.py:653 +#: build/lib/core/models.py:693 core/models.py:693 msgid "Document favorite" msgstr "" -#: core/models.py:654 +#: build/lib/core/models.py:694 core/models.py:694 msgid "Document favorites" msgstr "" -#: core/models.py:660 +#: build/lib/core/models.py:700 core/models.py:700 msgid "This document is already targeted by a favorite relation instance for the same user." msgstr "" -#: core/models.py:682 +#: build/lib/core/models.py:722 core/models.py:722 msgid "Document/user relation" msgstr "" -#: core/models.py:683 +#: build/lib/core/models.py:723 core/models.py:723 msgid "Document/user relations" msgstr "" -#: core/models.py:689 +#: build/lib/core/models.py:729 core/models.py:729 msgid "This user is already in this document." msgstr "" -#: core/models.py:695 +#: build/lib/core/models.py:735 core/models.py:735 msgid "This team is already in this document." msgstr "" -#: core/models.py:701 core/models.py:890 +#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741 +#: core/models.py:930 msgid "Either user or team must be set, not both." msgstr "" -#: core/models.py:719 +#: build/lib/core/models.py:759 core/models.py:759 msgid "description" msgstr "" -#: core/models.py:720 +#: build/lib/core/models.py:760 core/models.py:760 msgid "code" msgstr "" -#: core/models.py:721 +#: build/lib/core/models.py:761 core/models.py:761 msgid "css" msgstr "" -#: core/models.py:723 +#: build/lib/core/models.py:763 core/models.py:763 msgid "public" msgstr "" -#: core/models.py:725 +#: build/lib/core/models.py:765 core/models.py:765 msgid "Whether this template is public for anyone to use." msgstr "" -#: core/models.py:731 +#: build/lib/core/models.py:771 core/models.py:771 msgid "Template" msgstr "" -#: core/models.py:732 +#: build/lib/core/models.py:772 core/models.py:772 msgid "Templates" msgstr "" -#: core/models.py:871 +#: build/lib/core/models.py:911 core/models.py:911 msgid "Template/user relation" msgstr "" -#: core/models.py:872 +#: build/lib/core/models.py:912 core/models.py:912 msgid "Template/user relations" msgstr "" -#: core/models.py:878 +#: build/lib/core/models.py:918 core/models.py:918 msgid "This user is already in this template." msgstr "" -#: core/models.py:884 +#: build/lib/core/models.py:924 core/models.py:924 msgid "This team is already in this template." msgstr "" -#: core/models.py:907 +#: build/lib/core/models.py:947 core/models.py:947 msgid "email address" msgstr "" -#: core/models.py:926 +#: build/lib/core/models.py:966 core/models.py:966 msgid "Document invitation" msgstr "" -#: core/models.py:927 +#: build/lib/core/models.py:967 core/models.py:967 msgid "Document invitations" msgstr "" -#: core/models.py:944 +#: build/lib/core/models.py:987 core/models.py:987 msgid "This email is already associated to a registered user." msgstr "" -#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3 -msgid "Company logo" -msgstr "" - -#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5 -#, python-format -msgid "Hello %(name)s" -msgstr "" - -#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5 -msgid "Hello" -msgstr "" - -#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6 -msgid "Thank you very much for your visit!" -msgstr "" - -#: core/templates/mail/html/hello.html:221 -#, python-format -msgid "This mail has been sent to %(email)s by %(name)s" -msgstr "" - -#: core/templates/mail/html/invitation.html:162 -#: core/templates/mail/text/invitation.txt:3 -msgid "Logo email" -msgstr "" - -#: core/templates/mail/html/invitation.html:209 -#: core/templates/mail/text/invitation.txt:10 -msgid "Open" -msgstr "" - -#: core/templates/mail/html/invitation.html:226 -#: core/templates/mail/text/invitation.txt:14 -msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. " -msgstr "" - -#: core/templates/mail/html/invitation.html:233 -#: core/templates/mail/text/invitation.txt:16 -#, python-format -msgid " Brought to you by %(brandname)s " -msgstr "" - -#: core/templates/mail/text/hello.txt:8 -#, python-format -msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]" -msgstr "" - -#: impress/settings.py:236 +#: build/lib/impress/settings.py:236 impress/settings.py:236 msgid "English" msgstr "" -#: impress/settings.py:237 +#: build/lib/impress/settings.py:237 impress/settings.py:237 msgid "French" msgstr "" -#: impress/settings.py:238 +#: build/lib/impress/settings.py:238 impress/settings.py:238 msgid "German" msgstr "" diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po index 27272fd9..a93a965a 100644 --- a/src/backend/locale/fr_FR/LC_MESSAGES/django.po +++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-01-15 21:00+0000\n" -"PO-Revision-Date: 2025-01-16 19:53\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: French\n" "Language: fr_FR\n" diff --git a/src/backend/locale/nl_NL/LC_MESSAGES/django.po b/src/backend/locale/nl_NL/LC_MESSAGES/django.po index f979f7bb..a0c45428 100644 --- a/src/backend/locale/nl_NL/LC_MESSAGES/django.po +++ b/src/backend/locale/nl_NL/LC_MESSAGES/django.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: lasuite-docs\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-01-15 21:00+0000\n" -"PO-Revision-Date: 2025-01-16 19:53\n" +"PO-Revision-Date: 2025-01-27 09:27\n" "Last-Translator: \n" "Language-Team: Dutch\n" "Language: nl_NL\n" diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 4ce29fa6..f470b9ae 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -25,7 +25,7 @@ license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.12" dependencies = [ - "boto3==1.35.90", + "boto3==1.36.7", "Brotli==1.1.0", "celery[redis]==5.4.0", "django-configurations==2.5.1", @@ -47,16 +47,13 @@ dependencies = [ "jsonschema==4.23.0", "markdown==3.7", "nested-multipart-parser==1.5.0", - "openai==1.58.1", - "psycopg[binary]==3.2.3", + "openai==1.60.2", + "psycopg[binary]==3.2.4", "PyJWT==2.10.1", - "pypandoc==1.14", - "python-frontmatter==1.1.0", "python-magic==0.4.27", "requests==2.32.3", - "sentry-sdk==2.19.2", + "sentry-sdk==2.20.0", "url-normalize==1.4.3", - "WeasyPrint>=60.2", "whitenoise==6.8.2", "mozilla-django-oidc==4.0.1", ] @@ -74,16 +71,16 @@ dev = [ "freezegun==1.5.1", "ipdb==0.13.13", "ipython==8.31.0", - "pyfakefs==5.7.3", + "pyfakefs==5.7.4", "pylint-django==2.6.1", - "pylint==3.3.3", + "pylint==3.3.4", "pytest-cov==6.0.0", "pytest-django==4.9.0", "pytest==8.3.4", "pytest-icdiff==0.9", "pytest-xdist==3.6.1", - "responses==0.25.3", - "ruff==0.8.4", + "responses==0.25.6", + "ruff==0.9.3", "types-requests==2.32.0.20241016", ] diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index cbc9a025..042c6287 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -16,6 +16,7 @@ const config = { ['de-de', 'German'], ], LANGUAGE_CODE: 'en-us', + POSTHOG_KEY: {}, SENTRY_DSN: null, }; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 8dba8dab..02048c7f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -14,6 +14,29 @@ test.beforeEach(async ({ page }) => { }); test.describe('Doc Editor', () => { + test('it saves the doc when we quit pages', async ({ page, browserName }) => { + // eslint-disable-next-line playwright/no-skipped-test + test.skip(browserName === 'webkit', 'This test is very flaky with webkit'); + + // Check the first doc + const doc = await goToGridDoc(page); + + await verifyDocName(page, doc); + + const editor = page.locator('.ProseMirror'); + await editor.click(); + await editor.fill('Hello World Doc persisted 2'); + await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); + + await page.goto('/'); + + await goToGridDoc(page, { + title: doc, + }); + + await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); + }); + test('it check translations of the slash menu when changing language', async ({ page, browserName, @@ -241,29 +264,6 @@ test.describe('Doc Editor', () => { await expect(editor.getByText('Hello World Doc persisted 1')).toBeVisible(); }); - test('it saves the doc when we quit pages', async ({ page, browserName }) => { - // eslint-disable-next-line playwright/no-skipped-test - test.skip(browserName === 'webkit', 'This test is very flaky with webkit'); - - // Check the first doc - const doc = await goToGridDoc(page); - - await verifyDocName(page, doc); - - const editor = page.locator('.ProseMirror'); - await editor.click(); - await editor.fill('Hello World Doc persisted 2'); - await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); - - await page.goto('/'); - - await goToGridDoc(page, { - title: doc, - }); - - await expect(editor.getByText('Hello World Doc persisted 2')).toBeVisible(); - }); - test('it cannot edit if viewer', async ({ page }) => { await mockedDocument(page, { abilities: { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 6e62d3ad..eff9592a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -1,6 +1,7 @@ +import path from 'path'; + import { expect, test } from '@playwright/test'; import cs from 'convert-stream'; -import jsdom from 'jsdom'; import pdf from 'pdf-parse'; import { createDoc, verifyDocName } from './common'; @@ -41,10 +42,8 @@ test.describe('Doc Export', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'Download' })).toBeVisible(); }); - test('it converts the doc to pdf with a template integrated', async ({ - page, - browserName, - }) => { + + test('it exports the doc to pdf', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -77,10 +76,7 @@ test.describe('Doc Export', () => { expect(pdfText).toContain('Hello World'); // This is the doc text }); - test('it converts the doc to docx with a template integrated', async ({ - page, - browserName, - }) => { + test('it exports the doc to docx', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); const downloadPromise = page.waitForEvent('download', (download) => { @@ -111,152 +107,75 @@ test.describe('Doc Export', () => { expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`); }); - test('it converts the blocknote json in correct html for the export', async ({ - page, - browserName, - }) => { - test.setTimeout(60000); - + /** + * This test tell us that the export to pdf is working with images + * but it does not tell us if the images are beeing displayed correctly + * in the pdf. + * + * TODO: Check if the images are displayed correctly in the pdf + */ + test('it exports the docs with images', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1); - let body = ''; - await page.route('**/templates/*/generate-document/', async (route) => { - const request = route.request(); - body = request.postDataJSON().body; - - await route.continue(); + const fileChooserPromise = page.waitForEvent('filechooser'); + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${randomDoc}.pdf`); }); await verifyDocName(page, randomDoc); - await page.locator('.bn-block-outer').last().fill('Hello World'); - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('Break'); - await expect(page.getByText('Break')).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page.locator('.ProseMirror.bn-editor').fill('Hello World'); - // Center the text - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="alignTextCenter"]').click(); - - // Change the background color - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="background-color-brown"]').click(); - - // Change the text color - await page.getByText('Break').dblclick(); - await page.locator('button[data-test="colors"]').click(); - await page.locator('button[data-test="text-color-orange"]').click(); - - // Add a list - await page.locator('.bn-block-outer').last().click(); await page.keyboard.press('Enter'); await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Bullet List').click(); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="bulletListItem"] p') - .last() - .fill('Test List 3'); + await page.getByText('Resizable image with caption').click(); + await page.getByText('Upload image').click(); - await page.keyboard.press('Enter'); - await page.keyboard.press('Backspace'); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, 'assets/logo-suite-numerique.png'), + ); - // Add a number list - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page.getByText('Numbered List').click(); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 1'); - // eslint-disable-next-line playwright/no-wait-for-timeout - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 2'); - await page.keyboard.press('Enter'); - await page - .locator('.bn-block-content[data-content-type="numberedListItem"] p') - .last() - .fill('Test Number 3'); + const image = page.getByRole('img', { name: 'logo-suite-numerique.png' }); - // Add img - await page.locator('.bn-block-outer').last().click(); - await page.keyboard.press('Enter'); - await page.locator('.bn-block-outer').last().fill('/'); - await page - .getByRole('option', { - name: 'Image', - }) - .click(); - await page - .getByRole('tab', { - name: 'Embed', - }) - .click(); - await page - .getByPlaceholder('Enter URL') - .fill('https://example.com/image.jpg'); - await page - .getByRole('button', { - name: 'Embed image', - }) - .click(); + await expect(image).toBeVisible(); - // Download await page .getByRole('button', { name: 'download', }) .click(); + await page + .getByRole('combobox', { + name: 'Template', + }) + .click(); + + await page + .getByRole('option', { + name: 'Demo Template', + }) + .click({ + delay: 100, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await page .getByRole('button', { name: 'Download', }) .click(); - // Empty paragraph should be replaced by a
- expect(body.match(/
/g)?.length).toBeGreaterThanOrEqual(2); - expect(body).toContain('style="color: orange;"'); - expect(body).toContain('custom-style="center"'); - expect(body).toContain('style="background-color: brown;"'); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`); - const { JSDOM } = jsdom; - const DOMParser = new JSDOM().window.DOMParser; - const parser = new DOMParser(); - const html = parser.parseFromString(body, 'text/html'); + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfExport = await pdf(pdfBuffer); + const pdfText = pdfExport.text; - const ulLis = html.querySelectorAll('ul li'); - expect(ulLis.length).toBe(3); - expect(ulLis[0].textContent).toBe('Test List 1'); - expect(ulLis[1].textContent).toBe('Test List 2'); - expect(ulLis[2].textContent).toBe('Test List 3'); - - const olLis = html.querySelectorAll('ol li'); - expect(olLis.length).toBe(3); - expect(olLis[0].textContent).toBe('Test Number 1'); - expect(olLis[1].textContent).toBe('Test Number 2'); - expect(olLis[2].textContent).toBe('Test Number 3'); - - const img = html.querySelectorAll('img'); - expect(img.length).toBe(1); - expect(img[0].src).toBe('https://example.com/image.jpg'); + expect(pdfText).toContain('Hello World'); }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index 8414b0c7..5e5a9dc2 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -67,6 +67,53 @@ test.describe('Doc Visibility', () => { test.describe('Doc Visibility: Restricted', () => { test.use({ storageState: { cookies: [], origins: [] } }); + test('A doc is accessible when member.', async ({ page, browserName }) => { + test.slow(); + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1); + + await verifyDocName(page, docTitle); + + await page.getByRole('button', { name: 'Share' }).click(); + + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + + const otherBrowser = browsersName.find((b) => b !== browserName); + const username = `user@${otherBrowser}.e2e`; + await inputSearch.fill(username); + await page.getByRole('option', { name: username }).first().click(); + + // Choose a role + const container = page.getByTestId('doc-share-add-member-list'); + await container.getByLabel('doc-role-dropdown').click(); + await page.getByRole('button', { name: 'Administrator' }).click(); + + await page.getByRole('button', { name: 'Invite' }).click(); + + await page.locator('.c__modal__backdrop').click({ + position: { x: 0, y: 0 }, + }); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await keyCloakSignIn(page, otherBrowser!); + + await page.goto(urlDoc); + + await verifyDocName(page, docTitle); + await expect(page.getByLabel('Share button')).toBeVisible(); + }); + test('A doc is not accessible when not authentified.', async ({ page, browserName, @@ -127,53 +174,6 @@ test.describe('Doc Visibility: Restricted', () => { page.getByText('You do not have permission to perform this action.'), ).toBeVisible(); }); - - test('A doc is accessible when member.', async ({ page, browserName }) => { - test.slow(); - await page.goto('/'); - await keyCloakSignIn(page, browserName); - - const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1); - - await verifyDocName(page, docTitle); - - await page.getByRole('button', { name: 'Share' }).click(); - - const inputSearch = page.getByRole('combobox', { - name: 'Quick search input', - }); - - const otherBrowser = browsersName.find((b) => b !== browserName); - const username = `user@${otherBrowser}.e2e`; - await inputSearch.fill(username); - await page.getByRole('option', { name: username }).click(); - - // Choose a role - const container = page.getByTestId('doc-share-add-member-list'); - await container.getByLabel('doc-role-dropdown').click(); - await page.getByRole('button', { name: 'Administrator' }).click(); - - await page.getByRole('button', { name: 'Invite' }).click(); - - await page.locator('.c__modal__backdrop').click({ - position: { x: 0, y: 0 }, - }); - - const urlDoc = page.url(); - - await page - .getByRole('button', { - name: 'Logout', - }) - .click(); - - await keyCloakSignIn(page, otherBrowser!); - - await page.goto(urlDoc); - - await verifyDocName(page, docTitle); - await expect(page.getByLabel('Share button')).toBeVisible(); - }); }); test.describe('Doc Visibility: Public', () => { diff --git a/src/frontend/apps/e2e/package.json b/src/frontend/apps/e2e/package.json index b46958ca..b9dcad2d 100644 --- a/src/frontend/apps/e2e/package.json +++ b/src/frontend/apps/e2e/package.json @@ -22,7 +22,6 @@ }, "dependencies": { "convert-stream": "1.0.2", - "jsdom": "25.0.1", "pdf-parse": "1.1.1" } } diff --git a/src/frontend/apps/impress/.env b/src/frontend/apps/impress/.env index 3cf0e897..e69de29b 100644 --- a/src/frontend/apps/impress/.env +++ b/src/frontend/apps/impress/.env @@ -1,2 +0,0 @@ -NEXT_PUBLIC_API_ORIGIN= -NEXT_PUBLIC_SW_DEACTIVATED= diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 658588e5..a4735a57 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -18,19 +18,24 @@ "@blocknote/core": "0.21.0", "@blocknote/mantine": "0.21.0", "@blocknote/react": "0.21.0", + "@blocknote/xl-docx-exporter": "0.21.0", + "@blocknote/xl-pdf-exporter": "0.21.0", "@gouvfr-lasuite/integration": "1.0.2", "@hocuspocus/provider": "2.15.0", "@openfun/cunningham-react": "2.9.4", + "@react-pdf/renderer": "4.1.6", "@sentry/nextjs": "8.47.0", "@tanstack/react-query": "5.62.11", "cmdk": "1.0.4", "crisp-sdk-web": "1.0.25", + "docx": "9.1.0", "i18next": "24.2.0", "i18next-browser-languagedetector": "8.0.2", "idb": "8.0.1", "lodash": "4.17.21", "luxon": "3.5.0", "next": "15.1.3", + "posthog-js": "1.204.0", "react": "*", "react-aria-components": "1.5.0", "react-dom": "*", diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 57033110..8d021b14 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -3,7 +3,7 @@ import { PropsWithChildren, useEffect } from 'react'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { configureCrispSession } from '@/services'; +import { PostHogProvider, configureCrispSession } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; import { useConfig } from './api/useConfig'; @@ -45,5 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { ); } - return children; + return {children}; }; diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 5cb6d3a9..7f26e9a8 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; +import { PostHogConf } from '@/services'; interface ConfigResponse { LANGUAGES: [string, string][]; @@ -11,6 +12,7 @@ interface ConfigResponse { CRISP_WEBSITE_ID?: string; FRONTEND_THEME?: Theme; MEDIA_BASE_URL?: string; + POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index cdfdacaf..30786e3e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -26,7 +26,7 @@ import { } from '@/features/docs/doc-versioning'; import { useResponsiveStore } from '@/stores'; -import { ModalPDF } from './ModalExport'; +import { ModalExport } from './ModalExport'; interface DocToolBoxProps { doc: Doc; @@ -43,7 +43,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const colors = colorsTokens(); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); - const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); + const [isModalExportOpen, setIsModalExportOpen] = useState(false); const selectHistoryModal = useModal(); const modalShare = useModal(); @@ -63,7 +63,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { label: t('Export'), icon: 'download', callback: () => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }, }, ] @@ -198,7 +198,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { } onClick={() => { - setIsModalPDFOpen(true); + setIsModalExportOpen(true); }} size={isSmallMobile ? 'small' : 'medium'} /> @@ -228,8 +228,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { {modalShare.isOpen && ( modalShare.close()} doc={doc} /> )} - {isModalPDFOpen && ( - setIsModalPDFOpen(false)} doc={doc} /> + {isModalExportOpen && ( + setIsModalExportOpen(false)} doc={doc} /> )} {isModalRemoveOpen && ( setIsModalRemoveOpen(false)} doc={doc} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx index e08ac21d..4e72a6eb 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/ModalExport.tsx @@ -1,3 +1,11 @@ +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from '@blocknote/xl-docx-exporter'; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from '@blocknote/xl-pdf-exporter'; import { Button, Loader, @@ -7,91 +15,116 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; -import { useEffect, useMemo, useState } from 'react'; +import { pdf } from '@react-pdf/renderer'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useEditorStore } from '@/features/docs/doc-editor'; import { Doc } from '@/features/docs/doc-management'; -import { useExport } from '../api/useExport'; import { TemplatesOrdering, useTemplates } from '../api/useTemplates'; -import { adaptBlockNoteHTML, downloadFile } from '../utils'; +import { downloadFile, exportResolveFileUrl } from '../utils'; -export enum DocDownloadFormat { +enum DocDownloadFormat { PDF = 'pdf', DOCX = 'docx', } -interface ModalPDFProps { +interface ModalExportProps { onClose: () => void; doc: Doc; } -export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { +export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { t } = useTranslation(); const { data: templates } = useTemplates({ ordering: TemplatesOrdering.BY_CREATED_ON_DESC, }); const { toast } = useToastProvider(); const { editor } = useEditorStore(); - const { - mutate: createExport, - data: documentGenerated, - isSuccess, - isPending, - error, - } = useExport(); - const [templateIdSelected, setTemplateIdSelected] = useState(); + const [templateSelected, setTemplateSelected] = useState(''); + const [isExporting, setIsExporting] = useState(false); const [format, setFormat] = useState( DocDownloadFormat.PDF, ); const templateOptions = useMemo(() => { - if (!templates?.pages) { - return []; - } - - const templateOptions = templates.pages + const templateOptions = (templates?.pages || []) .map((page) => page.results.map((template) => ({ label: template.title, - value: template.id, + value: template.code, })), ) .flat(); - if (templateOptions.length) { - setTemplateIdSelected(templateOptions[0].value); - } + templateOptions.unshift({ + label: t('Empty template'), + value: '', + }); return templateOptions; - }, [templates?.pages]); + }, [t, templates?.pages]); - useEffect(() => { - if (!error) { + async function onSubmit() { + if (!editor) { + toast(t('The export failed'), VariantType.ERROR); return; } - toast(error.message, VariantType.ERROR); + setIsExporting(true); - onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error, t]); - - useEffect(() => { - if (!documentGenerated || !isSuccess) { - return; - } - - // normalize title const title = doc.title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/\s/g, '-'); - downloadFile(documentGenerated, `${title}.${format}`); + const html = templateSelected; + let exportDocument = editor.document; + if (html) { + const blockTemplate = await editor.tryParseHTMLToBlocks(html); + exportDocument = [...blockTemplate, ...editor.document]; + } + + let blobExport: Blob; + if (format === DocDownloadFormat.PDF) { + const defaultExporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + ); + + const exporter = new PDFExporter( + editor.schema, + pdfDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + const pdfDocument = await exporter.toReactPDFDocument(exportDocument); + blobExport = await pdf(pdfDocument).toBlob(); + } else { + const defaultExporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + ); + + const exporter = new DOCXExporter( + editor.schema, + docxDefaultSchemaMappings, + { + resolveFileUrl: async (url) => + exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl), + }, + ); + + blobExport = await exporter.toBlob(exportDocument); + } + + downloadFile(blobExport, `${title}.${format}`); toast( t('Your {{format}} was downloaded succesfully', { @@ -100,29 +133,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { VariantType.SUCCESS, ); + setIsExporting(false); + onClose(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [documentGenerated, isSuccess, t]); - - async function onSubmit() { - if (!templateIdSelected || !format) { - return; - } - - if (!editor) { - toast(t('No editor found'), VariantType.ERROR); - return; - } - - let body = await editor.blocksToFullHTML(editor.document); - body = adaptBlockNoteHTML(body); - - createExport({ - templateId: templateIdSelected, - body, - body_type: 'html', - format, - }); } return ( @@ -138,6 +151,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="secondary" fullWidth onClick={() => onClose()} + disabled={isExporting} > {t('Cancel')} @@ -146,7 +160,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { color="primary" fullWidth onClick={() => void onSubmit()} - disabled={isPending || !templateIdSelected} + disabled={isExporting} > {t('Download')} @@ -173,9 +187,9 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => { clearable={false} label={t('Template')} options={templateOptions} - value={templateIdSelected} + value={templateSelected} onChange={(options) => - setTemplateIdSelected(options.target.value as string) + setTemplateSelected(options.target.value as string) } />