From 5cdd06d43281f4771f32e21e0070d1dfd1f235ce Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 1 Dec 2024 11:25:01 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20server-to-server=20A?= =?UTF-8?q?PI=20endpoint=20to=20create=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want trusted external applications to be able to create documents via the API on behalf of any user. The user may or may not pre-exist in our database and should be notified of the document creation by email. --- CHANGELOG.md | 5 + src/backend/core/api/serializers.py | 95 +++++ src/backend/core/api/viewsets.py | 37 +- src/backend/core/authentication/__init__.py | 52 +++ ..._creator_and_invitation_issuer_optional.py | 30 ++ src/backend/core/models.py | 92 +++-- .../core/services/converter_services.py | 76 ++++ .../test_api_document_accesses_create.py | 14 +- .../test_api_document_invitations.py | 23 +- .../test_api_documents_create_for_owner.py | 364 ++++++++++++++++++ .../core/tests/test_models_documents.py | 29 +- .../core/tests/test_models_invitations.py | 2 +- .../tests/test_services_converter_services.py | 145 +++++++ src/backend/impress/settings.py | 21 + .../locale/fr_FR/LC_MESSAGES/django.mo | Bin 1747 -> 1922 bytes .../locale/fr_FR/LC_MESSAGES/django.po | 295 +++++++------- src/mail/mjml/invitation.mjml | 10 +- 17 files changed, 1072 insertions(+), 218 deletions(-) create mode 100644 src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py create mode 100644 src/backend/core/services/converter_services.py create mode 100644 src/backend/core/tests/documents/test_api_documents_create_for_owner.py create mode 100644 src/backend/core/tests/test_services_converter_services.py diff --git a/CHANGELOG.md b/CHANGELOG.md index feedbc7a..5c3f35d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to ## [Unreleased] +## Added + +- ✨(backend) add server-to-server API endpoint to create documents #467 + + ## [1.9.0] - 2024-12-11 ## Added diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 9a81fc47..464e8542 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -4,6 +4,7 @@ import mimetypes from django.conf import settings from django.db.models import Q +from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _ import magic @@ -11,6 +12,10 @@ from rest_framework import exceptions, serializers from core import enums, models from core.services.ai_services import AI_ACTIONS +from core.services.converter_services import ( + ConversionError, + YdocConverter, +) class UserSerializer(serializers.ModelSerializer): @@ -227,6 +232,96 @@ class DocumentSerializer(ListDocumentSerializer): return value +class ServerCreateDocumentSerializer(serializers.Serializer): + """ + Serializer for creating a document from a server-to-server request. + + Expects 'content' as a markdown string, which is converted to our internal format + via a Node.js microservice. The conversion is handled automatically, so third parties + only need to provide markdown. + + Both "sub" and "email" are required because the external app calling doesn't know + if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the + submitted "email" field and use the email address set on the user account in our database + """ + + # Document + title = serializers.CharField(required=True) + content = serializers.CharField(required=True) + # User + sub = serializers.CharField( + required=True, validators=[models.User.sub_validator], max_length=255 + ) + email = serializers.EmailField(required=True) + language = serializers.ChoiceField( + required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)() + ) + # Invitation + message = serializers.CharField(required=False) + subject = serializers.CharField(required=False) + + def create(self, validated_data): + """Create the document and associate it with the user or send an invitation.""" + language = validated_data.get("language", settings.LANGUAGE_CODE) + + # Get the user based on the sub (unique identifier) + try: + user = models.User.objects.get(sub=validated_data["sub"]) + except (models.User.DoesNotExist, KeyError): + user = None + email = validated_data["email"] + else: + email = user.email + language = user.language or language + + try: + converter_response = YdocConverter().convert_markdown( + validated_data["content"] + ) + except ConversionError as err: + raise exceptions.APIException(detail="could not convert content") from err + + document = models.Document.objects.create( + title=validated_data["title"], + content=converter_response["content"], + creator=user, + ) + + if user: + # Associate the document with the pre-existing user + models.DocumentAccess.objects.create( + document=document, + role=models.RoleChoices.OWNER, + user=user, + ) + else: + # The user doesn't exist in our database: we need to invite him/her + models.Invitation.objects.create( + document=document, + email=email, + role=models.RoleChoices.OWNER, + ) + + # Notify the user about the newly created document + subject = validated_data.get("subject") or _( + "A new document was created on your behalf!" + ) + context = { + "message": validated_data.get("message") + or _("You have been granted ownership of a new document:"), + "title": subject, + } + document.send_email(subject, [email], context, language) + + return document + + def update(self, instance, validated_data): + """ + This serializer does not support updates. + """ + raise NotImplementedError("Update is not supported for this serializer.") + + class LinkDocumentSerializer(BaseResourceSerializer): """ Serialize link configuration for documents. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 4c71689c..b217194d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -25,10 +25,11 @@ from django.http import Http404 import rest_framework as drf from botocore.exceptions import ClientError from django_filters import rest_framework as drf_filters -from rest_framework import filters +from rest_framework import filters, status +from rest_framework import response as drf_response from rest_framework.permissions import AllowAny -from core import enums, models +from core import authentication, enums, models from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService @@ -430,6 +431,30 @@ class DocumentViewSet( role=models.RoleChoices.OWNER, ) + @drf.decorators.action( + authentication_classes=[authentication.ServerToServerAuthentication], + detail=False, + methods=["post"], + permission_classes=[], + url_path="create-for-owner", + ) + def create_for_owner(self, request): + """ + Create a document on behalf of a specified owner (pre-existing user or invited). + """ + # Deserialize and validate the data + serializer = serializers.ServerCreateDocumentSerializer(data=request.data) + if not serializer.is_valid(): + return drf_response.Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + document = serializer.save() + + return drf_response.Response( + {"id": str(document.id)}, status=status.HTTP_201_CREATED + ) + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ @@ -813,11 +838,11 @@ class DocumentAccessViewSet( access = serializer.save() language = self.request.headers.get("Content-Language", "en-us") - access.document.email_invitation( - language, + access.document.send_invitation_email( access.user.email, access.role, self.request.user, + language, ) def perform_update(self, serializer): @@ -1078,8 +1103,8 @@ class InvitationViewset( language = self.request.headers.get("Content-Language", "en-us") - invitation.document.email_invitation( - language, invitation.email, invitation.role, self.request.user + invitation.document.send_invitation_email( + invitation.email, invitation.role, self.request.user, language ) diff --git a/src/backend/core/authentication/__init__.py b/src/backend/core/authentication/__init__.py index e69de29b..c5fa0c71 100644 --- a/src/backend/core/authentication/__init__.py +++ b/src/backend/core/authentication/__init__.py @@ -0,0 +1,52 @@ +"""Custom authentication classes for the Impress core app""" + +from django.conf import settings + +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + + +class ServerToServerAuthentication(BaseAuthentication): + """ + Custom authentication class for server-to-server requests. + Validates the presence and correctness of the Authorization header. + """ + + AUTH_HEADER = "Authorization" + TOKEN_TYPE = "Bearer" # noqa S105 + + def authenticate(self, request): + """ + Authenticate the server-to-server request by validating the Authorization header. + + This method checks if the Authorization header is present in the request, ensures it + contains a valid token with the correct format, and verifies the token against the + list of allowed server-to-server tokens. If the header is missing, improperly formatted, + or contains an invalid token, an AuthenticationFailed exception is raised. + + Returns: + None: If authentication is successful + (no user is authenticated for server-to-server requests). + + Raises: + AuthenticationFailed: If the Authorization header is missing, malformed, + or contains an invalid token. + """ + auth_header = request.headers.get(self.AUTH_HEADER) + if not auth_header: + raise AuthenticationFailed("Authorization header is missing.") + + # Validate token format and existence + auth_parts = auth_header.split(" ") + if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE: + raise AuthenticationFailed("Invalid authorization header.") + + token = auth_parts[1] + if token not in settings.SERVER_TO_SERVER_API_TOKENS: + raise AuthenticationFailed("Invalid server-to-server token.") + + # Authentication is successful, but no user is authenticated + + def authenticate_header(self, request): + """Return the WWW-Authenticate header value.""" + return f"{self.TOKEN_TYPE} realm='Create document server to server'" diff --git a/src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py b/src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py new file mode 100644 index 00000000..b0902896 --- /dev/null +++ b/src/backend/core/migrations/0012_make_document_creator_and_invitation_issuer_optional.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.2 on 2024-11-30 22:23 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_populate_creator_field_and_make_it_required'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='creator', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='invitation', + name='issuer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='user', + name='language', + field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2f52b2fe..16f93808 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -26,8 +26,8 @@ from django.template.context import Context from django.template.loader import render_to_string from django.utils import html, 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 _ -from django.utils.translation import override import frontmatter import markdown @@ -239,6 +239,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): for invitation in valid_invitations ] ) + + # Set creator of documents if not yet set (e.g. documents created via server-to-server API) + document_ids = [invitation.document_id for invitation in valid_invitations] + Document.objects.filter(id__in=document_ids, creator__isnull=True).update( + creator=self + ) + valid_invitations.delete() def email_user(self, subject, message, from_email=None, **kwargs): @@ -342,7 +349,11 @@ class Document(BaseModel): max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER ) creator = models.ForeignKey( - User, on_delete=models.RESTRICT, related_name="documents_created" + User, + on_delete=models.RESTRICT, + related_name="documents_created", + blank=True, + null=True, ) _content = None @@ -534,44 +545,61 @@ class Document(BaseModel): "versions_retrieve": has_role, } - def email_invitation(self, language, email, role, sender): - """Send email invitation.""" - - sender_name = sender.full_name or sender.email + def send_email(self, subject, emails, context=None, language=None): + """Generate and send email from a template.""" + context = context or {} domain = Site.objects.get_current().domain + language = language or get_language() + context.update( + { + "domain": domain, + "link": f"{domain}/docs/{self.id}/", + "document": self, + } + ) - try: - with override(language): - title = _( - "%(sender_name)s shared a document with you: %(document)s" - ) % { - "sender_name": sender_name, - "document": self.title, - } - template_vars = { - "title": title, - "domain": domain, - "document": self, - "link": f"{domain}/docs/{self.id}/", - "sender_name": sender_name, - "sender_name_email": f"{sender.full_name} ({sender.email})" - if sender.full_name - else sender.email, - "role": RoleChoices(role).label.lower(), - } - msg_html = render_to_string("mail/html/invitation.html", template_vars) - msg_plain = render_to_string("mail/text/invitation.txt", template_vars) + with override(language): + msg_html = render_to_string("mail/html/invitation.html", context) + msg_plain = render_to_string("mail/text/invitation.txt", context) + subject = str(subject) # Force translation + + try: send_mail( - title, + subject.capitalize(), msg_plain, settings.EMAIL_FROM, - [email], + emails, html_message=msg_html, fail_silently=False, ) + except smtplib.SMTPException as exception: + logger.error("invitation to %s was not sent: %s", emails, exception) - except smtplib.SMTPException as exception: - logger.error("invitation to %s was not sent: %s", email, exception) + def send_invitation_email(self, email, role, sender, language=None): + """Method allowing a user to send an email invitation to another user for a document.""" + language = language or get_language() + role = RoleChoices(role).label + sender_name = sender.full_name or sender.email + sender_name_email = ( + f"{sender.full_name:s} ({sender.email})" + if sender.full_name + else sender.email + ) + + with override(language): + context = { + "title": _("{name} shared a document with you!").format( + name=sender_name + ), + "message": _( + "{name} invited you with the role ``{role}`` on the following document:" + ).format(name=sender_name_email, role=role.lower()), + } + subject = _("{name} shared a document with you: {title}").format( + name=sender_name, title=self.title + ) + + self.send_email(subject, [email], context, language) class LinkTrace(BaseModel): @@ -887,6 +915,8 @@ class Invitation(BaseModel): User, on_delete=models.CASCADE, related_name="invitations", + blank=True, + null=True, ) class Meta: diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py new file mode 100644 index 00000000..6ca01a3d --- /dev/null +++ b/src/backend/core/services/converter_services.py @@ -0,0 +1,76 @@ +"""Converter services.""" + +from django.conf import settings + +import requests + + +class ConversionError(Exception): + """Base exception for conversion-related errors.""" + + +class ValidationError(ConversionError): + """Raised when the input validation fails.""" + + +class ServiceUnavailableError(ConversionError): + """Raised when the conversion service is unavailable.""" + + +class InvalidResponseError(ConversionError): + """Raised when the conversion service returns an invalid response.""" + + +class MissingContentError(ConversionError): + """Raised when the response is missing required content.""" + + +class YdocConverter: + """Service class for conversion-related operations.""" + + @property + def auth_header(self): + """Build microservice authentication header.""" + return f"Bearer {settings.CONVERSION_API_KEY}" + + def convert_markdown(self, text): + """Convert a Markdown text into our internal format using an external microservice.""" + + if not text: + raise ValidationError("Input text cannot be empty") + + try: + response = requests.post( + settings.CONVERSION_API_URL, + json={ + "content": text, + }, + headers={ + "Authorization": self.auth_header, + "Content-Type": "application/json", + }, + timeout=settings.CONVERSION_API_TIMEOUT, + ) + response.raise_for_status() + conversion_response = response.json() + + except requests.RequestException as err: + raise ServiceUnavailableError( + "Failed to connect to conversion service", + ) from err + + except ValueError as err: + raise InvalidResponseError( + "Could not parse conversion service response" + ) from err + + try: + document_content = conversion_response[ + settings.CONVERSION_API_CONTENT_FIELD + ] + except KeyError as err: + raise MissingContentError( + f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}" + ) from err + + return document_content diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index bd96d04d..dc5cb0ee 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -171,10 +171,11 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user email = mail.outbox[0] assert email.to == [other_user["email"]] email_content = " ".join(email.body.split()) + assert f"{user.full_name} shared a document with you!" in email_content assert ( - f"{user.full_name} shared a document with you: {document.title}" - in email_content - ) + f"{user.full_name} ({user.email}) invited you with the role ``{role}`` " + f"on the following document: {document.title}" + ) in email_content assert "docs/" + str(document.id) + "/" in email_content @@ -228,8 +229,9 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): email = mail.outbox[0] assert email.to == [other_user["email"]] email_content = " ".join(email.body.split()) + assert f"{user.full_name} shared a document with you!" in email_content assert ( - f"{user.full_name} shared a document with you: {document.title}" - in email_content - ) + f"{user.full_name} ({user.email}) invited you with the role ``{role}`` " + f"on the following document: {document.title}" + ) in email_content assert "docs/" + str(document.id) + "/" in email_content diff --git a/src/backend/core/tests/documents/test_api_document_invitations.py b/src/backend/core/tests/documents/test_api_document_invitations.py index 1b9e6168..d6776720 100644 --- a/src/backend/core/tests/documents/test_api_document_invitations.py +++ b/src/backend/core/tests/documents/test_api_document_invitations.py @@ -402,10 +402,11 @@ def test_api_document_invitations_create_privileged_members( email = mail.outbox[0] assert email.to == ["guest@example.com"] email_content = " ".join(email.body.split()) + assert f"{user.full_name} shared a document with you!" in email_content assert ( - f"{user.full_name} shared a document with you: {document.title}" - in email_content - ) + f"{user.full_name} ({user.email}) invited you with the role ``{invited}`` " + f"on the following document: {document.title}" + ) in email_content else: assert models.Invitation.objects.exists() is False @@ -452,10 +453,7 @@ def test_api_document_invitations_create_email_from_content_language(): assert email.to == ["guest@example.com"] email_content = " ".join(email.body.split()) - assert ( - f"{user.full_name} a partagé un document avec vous: {document.title}" - in email_content - ) + assert f"{user.full_name} a partagé un document avec vous !" in email_content def test_api_document_invitations_create_email_from_content_language_not_supported(): @@ -494,10 +492,7 @@ def test_api_document_invitations_create_email_from_content_language_not_support assert email.to == ["guest@example.com"] email_content = " ".join(email.body.split()) - assert ( - f"{user.full_name} shared a document with you: {document.title}" - in email_content - ) + assert f"{user.full_name} shared a document with you!" in email_content def test_api_document_invitations_create_email_full_name_empty(): @@ -535,10 +530,10 @@ def test_api_document_invitations_create_email_full_name_empty(): assert email.to == ["guest@example.com"] email_content = " ".join(email.body.split()) - assert f"{user.email} shared a document with you: {document.title}" in email_content + assert f"{user.email} shared a document with you!" in email_content assert ( - f'{user.email} invited you with the role "reader" on the ' - f"following document : {document.title}" in email_content + f"{user.email.capitalize()} invited you with the role ``reader`` on the " + f"following document: {document.title}" in email_content ) diff --git a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py new file mode 100644 index 00000000..6d8909d5 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py @@ -0,0 +1,364 @@ +""" +Tests for Documents API endpoint in impress's core app: create +""" + +# pylint: disable=W0621 + +from unittest.mock import patch + +from django.core import mail +from django.test import override_settings + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.models import Document, Invitation, User +from core.services.converter_services import ConversionError, YdocConverter + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mock_convert_markdown(): + """Mock YdocConverter.convert_markdown to return a converted content.""" + with patch.object( + YdocConverter, + "convert_markdown", + return_value={"content": "Converted document content"}, + ) as mock: + yield mock + + +def test_api_documents_create_for_owner_missing_token(): + """Requests with no token should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", data, format="json" + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_invalid_token(): + """Requests with an invalid token should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "language": "fr", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer InvalidToken", + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +def test_api_documents_create_for_owner_authenticated_forbidden(): + """ + Authenticated users should not be allowed to call create documents on behalf of other users. + This API endpoint is reserved for server-to-server calls. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + } + + response = client.post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + ) + + assert response.status_code == 401 + assert not Document.objects.exists() + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_missing_sub(): + """Requests with no sub should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert not Document.objects.exists() + + assert response.json() == {"sub": ["This field is required."]} + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_missing_email(): + """Requests with no email should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert not Document.objects.exists() + + assert response.json() == {"email": ["This field is required."]} + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_invalid_sub(): + """Requests with an invalid sub should not be allowed to create documents for owner.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123!!", + "email": "john.doe@example.com", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 400 + assert not Document.objects.exists() + + assert response.json() == { + "sub": [ + "Enter a valid sub. This value may contain only letters, " + "numbers, and @/./+/-/_/: characters." + ] + } + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_existing(mock_convert_markdown): + """It should be possible to create a document on behalf of a pre-existing user.""" + user = factories.UserFactory(language="en-us") + + data = { + "title": "My Document", + "content": "Document content", + "sub": str(user.sub), + "email": "irrelevant@example.com", # Should be ignored since the user already exists + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_markdown.assert_called_once_with("Document content") + + document = Document.objects.get() + assert response.json() == {"id": str(document.id)} + + assert document.title == "My Document" + assert document.content == "Converted document content" + assert document.creator == user + assert document.accesses.filter(user=user, role="owner").exists() + + assert Invitation.objects.exists() is False + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == [user.email] + assert email.subject == "A new document was created on your behalf!" + email_content = " ".join(email.body.split()) + assert "A new document was created on your behalf!" in email_content + assert ( + "You have been granted ownership of a new document: My Document" + ) in email_content + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_new_user(mock_convert_markdown): + """ + It should be possible to create a document on behalf of new users by + passing only their email address. + """ + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", # Should be used to create a new user + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_markdown.assert_called_once_with("Document content") + + document = Document.objects.get() + assert response.json() == {"id": str(document.id)} + + assert document.title == "My Document" + assert document.content == "Converted document content" + assert document.creator is None + assert document.accesses.exists() is False + + invitation = Invitation.objects.get() + assert invitation.email == "john.doe@example.com" + assert invitation.role == "owner" + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["john.doe@example.com"] + assert email.subject == "A new document was created on your behalf!" + email_content = " ".join(email.body.split()) + assert "A new document was created on your behalf!" in email_content + assert ( + "You have been granted ownership of a new document: My Document" + ) in email_content + + # The creator field on the document should be set when the user is created + user = User.objects.create(email="john.doe@example.com", password="!") + document.refresh_from_db() + assert document.creator == user + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown): + """ + Test creating a document with a specific language. + Useful if the remote server knows the user's language. + """ + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "language": "fr-fr", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_markdown.assert_called_once_with("Document content") + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["john.doe@example.com"] + assert email.subject == "Un nouveau document a été créé pour vous !" + email_content = " ".join(email.body.split()) + assert "Un nouveau document a été créé pour vous !" in email_content + assert ( + "Vous avez été déclaré propriétaire d'un nouveau document : My Document" + ) in email_content + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_custom_subject_and_message( + mock_convert_markdown, +): + """It should be possible to customize the subject and message of the invitation email.""" + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "message": "mon message spécial", + "subject": "mon sujet spécial !", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + assert response.status_code == 201 + + mock_convert_markdown.assert_called_once_with("Document content") + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["john.doe@example.com"] + assert email.subject == "Mon sujet spécial !" + email_content = " ".join(email.body.split()) + assert "Mon sujet spécial !" in email_content + assert "Mon message spécial" in email_content + + +@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"]) +def test_api_documents_create_for_owner_with_converter_exception( + mock_convert_markdown, +): + """It should be possible to customize the subject and message of the invitation email.""" + + mock_convert_markdown.side_effect = ConversionError("Conversion failed") + + data = { + "title": "My Document", + "content": "Document content", + "sub": "123", + "email": "john.doe@example.com", + "message": "mon message spécial", + "subject": "mon sujet spécial !", + } + + response = APIClient().post( + "/api/v1.0/documents/create-for-owner/", + data, + format="json", + HTTP_AUTHORIZATION="Bearer DummyToken", + ) + + mock_convert_markdown.assert_called_once_with("Document content") + + assert response.status_code == 500 + assert response.json() == {"detail": "could not convert content"} diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 17cab6cd..5fe0d4fd 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -33,11 +33,8 @@ def test_models_documents_id_unique(): def test_models_documents_creator_required(): - """The "creator" field should be required.""" - with pytest.raises(ValidationError) as excinfo: - models.Document.objects.create() - - assert excinfo.value.message_dict["creator"] == ["This field cannot be null."] + """No field should be required on the Document model.""" + models.Document.objects.create() def test_models_documents_title_null(): @@ -430,8 +427,8 @@ def test_models_documents__email_invitation__success(): assert len(mail.outbox) == 0 sender = factories.UserFactory(full_name="Test Sender", email="sender@example.com") - document.email_invitation( - "en", "guest@example.com", models.RoleChoices.EDITOR, sender + document.send_invitation_email( + "guest@example.com", models.RoleChoices.EDITOR, sender, "en" ) # pylint: disable-next=no-member @@ -444,8 +441,8 @@ def test_models_documents__email_invitation__success(): email_content = " ".join(email.body.split()) assert ( - f'Test Sender (sender@example.com) invited you with the role "editor" ' - f"on the following document : {document.title}" in email_content + f"Test Sender (sender@example.com) invited you with the role ``editor`` " + f"on the following document: {document.title}" in email_content ) assert f"docs/{document.id}/" in email_content @@ -462,11 +459,11 @@ def test_models_documents__email_invitation__success_fr(): sender = factories.UserFactory( full_name="Test Sender2", email="sender2@example.com" ) - document.email_invitation( - "fr-fr", + document.send_invitation_email( "guest2@example.com", models.RoleChoices.OWNER, sender, + "fr-fr", ) # pylint: disable-next=no-member @@ -479,7 +476,7 @@ def test_models_documents__email_invitation__success_fr(): email_content = " ".join(email.body.split()) assert ( - f'Test Sender2 (sender2@example.com) vous a invité avec le rôle "propriétaire" ' + f"Test Sender2 (sender2@example.com) vous a invité avec le rôle ``propriétaire`` " f"sur le document suivant : {document.title}" in email_content ) assert f"docs/{document.id}/" in email_content @@ -498,11 +495,11 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail assert len(mail.outbox) == 0 sender = factories.UserFactory() - document.email_invitation( - "en", + document.send_invitation_email( "guest3@example.com", models.RoleChoices.ADMIN, sender, + "en", ) # No email has been sent @@ -514,9 +511,9 @@ def test_models_documents__email_invitation__failed(mock_logger, _mock_send_mail ( _, - email, + emails, exception, ) = mock_logger.call_args.args - assert email == "guest3@example.com" + assert emails == ["guest3@example.com"] assert isinstance(exception, smtplib.SMTPException) diff --git a/src/backend/core/tests/test_models_invitations.py b/src/backend/core/tests/test_models_invitations.py index 4ae4bfc7..4bd538a2 100644 --- a/src/backend/core/tests/test_models_invitations.py +++ b/src/backend/core/tests/test_models_invitations.py @@ -144,7 +144,7 @@ def test_models_invitationd_new_user_filter_expired_invitations(): ).exists() -@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 6), (20, 6)]) +@pytest.mark.parametrize("num_invitations, num_queries", [(0, 3), (1, 7), (20, 7)]) def test_models_invitationd_new_userd_user_creation_constant_num_queries( django_assert_num_queries, num_invitations, num_queries ): diff --git a/src/backend/core/tests/test_services_converter_services.py b/src/backend/core/tests/test_services_converter_services.py new file mode 100644 index 00000000..58500ee4 --- /dev/null +++ b/src/backend/core/tests/test_services_converter_services.py @@ -0,0 +1,145 @@ +"""Test converter services.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from core.services.converter_services import ( + InvalidResponseError, + MissingContentError, + ServiceUnavailableError, + ValidationError, + YdocConverter, +) + + +def test_auth_header(settings): + """Test authentication header generation.""" + settings.CONVERSION_API_KEY = "test-key" + converter = YdocConverter() + assert converter.auth_header == "Bearer test-key" + + +def test_convert_markdown_empty_text(): + """Should raise ValidationError when text is empty.""" + converter = YdocConverter() + with pytest.raises(ValidationError, match="Input text cannot be empty"): + converter.convert_markdown("") + + +@patch("requests.post") +def test_convert_markdown_service_unavailable(mock_post): + """Should raise ServiceUnavailableError when service is unavailable.""" + converter = YdocConverter() + + mock_post.side_effect = requests.RequestException("Connection error") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to conversion service", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_http_error(mock_post): + """Should raise ServiceUnavailableError when HTTP error occurs.""" + converter = YdocConverter() + + 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 conversion service", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_invalid_json_response(mock_post): + """Should raise InvalidResponseError when response is not valid JSON.""" + converter = YdocConverter() + + mock_response = MagicMock() + mock_response.json.side_effect = ValueError("Invalid JSON") + mock_post.return_value = mock_response + + with pytest.raises( + InvalidResponseError, + match="Could not parse conversion service response", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_missing_content_field(mock_post, settings): + """Should raise MissingContentError when response is missing required field.""" + + settings.CONVERSION_API_CONTENT_FIELD = "expected_field" + + converter = YdocConverter() + + mock_response = MagicMock() + mock_response.json.return_value = {"wrong_field": "content"} + mock_post.return_value = mock_response + + with pytest.raises( + MissingContentError, + match="Response missing required field: expected_field", + ): + converter.convert_markdown("test text") + + +@patch("requests.post") +def test_convert_markdown_full_integration(mock_post, settings): + """Test full integration with all settings.""" + + settings.CONVERSION_API_URL = "http://test.com" + settings.CONVERSION_API_KEY = "test-key" + settings.CONVERSION_API_TIMEOUT = 5 + settings.CONVERSION_API_CONTENT_FIELD = "content" + + converter = YdocConverter() + + expected_content = {"converted": "content"} + mock_response = MagicMock() + mock_response.json.return_value = {"content": expected_content} + mock_post.return_value = mock_response + + result = converter.convert_markdown("test markdown") + + assert result == expected_content + mock_post.assert_called_once_with( + "http://test.com", + json={"content": "test markdown"}, + headers={ + "Authorization": "Bearer test-key", + "Content-Type": "application/json", + }, + timeout=5, + ) + + +@patch("requests.post") +def test_convert_markdown_timeout(mock_post): + """Should raise ServiceUnavailableError when request times out.""" + converter = YdocConverter() + + mock_post.side_effect = requests.Timeout("Request timed out") + + with pytest.raises( + ServiceUnavailableError, + match="Failed to connect to conversion service", + ): + converter.convert_markdown("test text") + + +def test_convert_markdown_none_input(): + """Should raise ValidationError when input is None.""" + converter = YdocConverter() + + with pytest.raises(ValidationError, match="Input text cannot be empty"): + converter.convert_markdown(None) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 0aa608e5..c2cce347 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -65,6 +65,7 @@ class Base(Configuration): # Security ALLOWED_HOSTS = values.ListValue([]) SECRET_KEY = values.Value(None) + SERVER_TO_SERVER_API_TOKENS = values.ListValue([]) # Application definition ROOT_URLCONF = "impress.urls" @@ -502,6 +503,26 @@ class Base(Configuration): "day": 200, } + # Conversion microservice + CONVERSION_API_KEY = values.Value( + environ_name="CONVERSION_API_KEY", + environ_prefix=None, + ) + CONVERSION_API_URL = values.Value( + environ_name="CONVERSION_API_URL", + environ_prefix=None, + ) + CONVERSION_API_CONTENT_FIELD = values.Value( + default="content", + environ_name="CONVERSION_API_CONTENT_FIELD", + environ_prefix=None, + ) + CONVERSION_API_TIMEOUT = values.Value( + default=30, + environ_name="CONVERSION_API_TIMEOUT", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console. diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.mo b/src/backend/locale/fr_FR/LC_MESSAGES/django.mo index 1062972e84a35cba63a9e133fe1b5e23107c8ec4..1987dc160db829d2d856faaf4d3e6773160b2e9c 100644 GIT binary patch delta 847 zcmZwE&ubGw6u|M>wA-fD*dOAlRwjy6tCr$Hun0wj>cLA9Me&j`JKHR5XToMTK|?7D z;>BAmD2PWf=)qs$RmshRAYMGxyZ?bVzZ09K1&7Rj_Q!iO?@fM`9!zX*PY-Vi#zE#` z=11l%^Qy;%@fkz>g7bKyAVdkT?WpiL&lX2;3n%b74&xj2@g45NPw4V{Nc^Vw{_jTo z#Bo0S!n61rS8#Snh@%*wE8r2nqL}w+c)rc*3-}ce;2+$NMOK%12#a_Qr*IkhiB&Eo z_7^v~ag~KQjS2D-54nuuV;sXLxQs9G3U1>`oFixvSCP8 z(i(Z!W{JF`Ybp-QowtRj=Wp3e)>KpTQX9FJDASK@8J#q0QC-?lsy!<%iMCPedZY8r zn>k&nv=bZajY`EWp4PMsZ5-QHWY+Q$@`PvJOtC!3=3#6}w$msj{ln#<*IwtoZx*f_ zX>8WiD$DcPzt>HBUE1h%q3&8}+N>eVoiigZS8ljlI=HtJ7WBH+SP?7hiLECQ$tp^; s4Cb=`ES^_)!9Q|pS33)XW zLC`g-r&~V`(WNImb?DT+e<7kvbm~23VUJJ4&b)SX_B)@MyY}bY*CNYyxDup(#D>YF^crgV^ zXg)Z*@H}RiH}N@}Ucz(CzfoliPf^9kJ9rVlVjk-h$zl@)FXKVHiRO?I@~C?ZIrdjm zUPz)IV-cSrk9x|>aJWU+GE+yIXM@#n|*QhiF#TFfv2Th zsTylxSpt9lb2sq3VC?!gq!YA%(name)s" -msgstr "" - #: core/templates/mail/html/invitation.html:159 #: core/templates/mail/text/invitation.txt:3 msgid "La Suite Numérique" msgstr "" -#: core/templates/mail/html/invitation.html:189 -#: core/templates/mail/text/invitation.txt:6 -#, python-format -msgid " %(sender_name)s shared a document with you ! " -msgstr " %(sender_name)s a partagé un document avec vous ! " - -#: core/templates/mail/html/invitation.html:196 -#: core/templates/mail/text/invitation.txt:8 -#, python-format -msgid " %(sender_name_email)s invited you with the role \"%(role)s\" on the following document : " -msgstr " %(sender_name_email)s vous a invité avec le rôle \"%(role)s\" sur le document suivant : " - -#: core/templates/mail/html/invitation.html:205 +#: core/templates/mail/html/invitation.html:207 #: core/templates/mail/text/invitation.txt:10 msgid "Open" msgstr "Ouvrir" -#: core/templates/mail/html/invitation.html:222 +#: core/templates/mail/html/invitation.html:224 #: 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 " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. " +msgid "" +" Docs, your new essential tool for organizing, sharing and collaborating on " +"your documents as a team. " +msgstr "" +" Docs, votre nouvel outil incontournable pour organiser, partager et " +"collaborer sur vos documents en équipe. " -#: core/templates/mail/html/invitation.html:229 +#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/text/invitation.txt:16 msgid "Brought to you by La Suite Numérique" msgstr "Proposé par La Suite Numérique" -#: 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:177 +#: impress/settings.py:236 msgid "English" msgstr "" -#: impress/settings.py:178 +#: impress/settings.py:237 msgid "French" msgstr "" -#: impress/settings.py:176 +#: impress/settings.py:238 msgid "German" msgstr "" + +#, python-format +#~ msgid " %(sender_name)s shared a document with you! " +#~ msgstr "%(sender_name)s a partagé un document avec vous !" diff --git a/src/mail/mjml/invitation.mjml b/src/mail/mjml/invitation.mjml index f0db2e76..1a12ce44 100644 --- a/src/mail/mjml/invitation.mjml +++ b/src/mail/mjml/invitation.mjml @@ -22,17 +22,11 @@ -

- {% blocktrans %} - {{sender_name}} shared a document with you ! - {% endblocktrans %} -

+

{{title|capfirst}}

- {% blocktrans %} - {{sender_name_email}} invited you with the role "{{role}}" on the following document : - {% endblocktrans %} + {{message|capfirst}} {{document.title}}