diff --git a/CHANGELOG.md b/CHANGELOG.md index feedbc7a6..5c3f35d23 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 9a81fc47e..464e85421 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -4,6 +4,7 @@ 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 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 @@ def validate_id(self, value): 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 4c71689ce..b217194d9 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -25,10 +25,11 @@ 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 @@ def perform_create(self, serializer): 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 @@ def perform_create(self, serializer): 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 @@ def perform_create(self, serializer): 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 e69de29bb..c5fa0c711 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 000000000..b0902896e --- /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 2f52b2fe0..16f93808d 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -26,8 +26,8 @@ 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 @@ def _convert_valid_invitations(self): 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 @@ def get_abilities(self, user): "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 000000000..6ca01a3d3 --- /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 bd96d04dd..dc5cb0ee3 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 1b9e61688..d6776720b 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 000000000..6d8909d52 --- /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 17cab6cd7..5fe0d4fda 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 4ae4bfc75..4bd538a20 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 000000000..58500ee4d --- /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 0aa608e5f..c2cce3474 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 1062972e8..1987dc160 100644 Binary files a/src/backend/locale/fr_FR/LC_MESSAGES/django.mo and b/src/backend/locale/fr_FR/LC_MESSAGES/django.mo differ diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po index 3ed800407..0e42c691c 100644 --- a/src/backend/locale/fr_FR/LC_MESSAGES/django.po +++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: lasuite-people\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-10-15 07:19+0000\n" +"POT-Creation-Date: 2024-12-01 08:22+0000\n" "PO-Revision-Date: 2024-10-15 07:23\n" "Last-Translator: \n" "Language-Team: French\n" @@ -29,15 +29,35 @@ msgstr "Permissions" msgid "Important dates" msgstr "Dates importantes" -#: core/api/serializers.py:253 +#: core/api/filters.py:16 +msgid "Creator is me" +msgstr "" + +#: core/api/filters.py:19 +msgid "Favorite" +msgstr "" + +#: core/api/filters.py:22 +msgid "Title" +msgstr "" + +#: core/api/serializers.py:284 +msgid "A new document was created on your behalf!" +msgstr "Un nouveau document a été créé pour vous !" + +#: core/api/serializers.py:287 +msgid "You have been granted ownership of a new document:" +msgstr "Vous avez été déclaré propriétaire d'un nouveau document :" + +#: core/api/serializers.py:390 msgid "Body" msgstr "" -#: core/api/serializers.py:256 +#: core/api/serializers.py:393 msgid "Body type" msgstr "" -#: core/api/serializers.py:262 +#: core/api/serializers.py:399 msgid "Format" msgstr "" @@ -49,6 +69,10 @@ msgstr "" msgid "User info contained no recognizable user identification" msgstr "" +#: core/authentication/backends.py:88 +msgid "User account is disabled" +msgstr "" + #: core/models.py:62 core/models.py:69 msgid "Reader" msgstr "Lecteur" @@ -65,294 +89,293 @@ msgstr "Administrateur" msgid "Owner" msgstr "Propriétaire" -#: core/models.py:80 +#: core/models.py:83 msgid "Restricted" msgstr "Restreint" -#: core/models.py:84 +#: core/models.py:87 msgid "Authenticated" msgstr "Authentifié" -#: core/models.py:86 +#: core/models.py:89 msgid "Public" msgstr "Public" -#: core/models.py:98 +#: core/models.py:101 msgid "id" msgstr "" -#: core/models.py:99 +#: core/models.py:102 msgid "primary key for the record as UUID" msgstr "" -#: core/models.py:105 +#: core/models.py:108 msgid "created on" msgstr "" -#: core/models.py:106 +#: core/models.py:109 msgid "date and time at which a record was created" msgstr "" -#: core/models.py:111 +#: core/models.py:114 msgid "updated on" msgstr "" -#: core/models.py:112 +#: core/models.py:115 msgid "date and time at which a record was last updated" msgstr "" -#: core/models.py:132 -msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters." +#: core/models.py:135 +msgid "" +"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" +"_/: characters." msgstr "" -#: core/models.py:138 +#: core/models.py:141 msgid "sub" msgstr "" -#: core/models.py:140 -msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only." +#: core/models.py:143 +msgid "" +"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: " +"characters only." msgstr "" -#: core/models.py:149 +#: core/models.py:152 msgid "full name" msgstr "" -#: core/models.py:150 +#: core/models.py:153 msgid "short name" msgstr "" -#: core/models.py:152 +#: core/models.py:155 msgid "identity email address" msgstr "" -#: core/models.py:157 +#: core/models.py:160 msgid "admin email address" msgstr "" -#: core/models.py:164 +#: core/models.py:167 msgid "language" msgstr "" -#: core/models.py:165 +#: core/models.py:168 msgid "The language in which the user wants to see the interface." msgstr "" -#: core/models.py:171 +#: core/models.py:174 msgid "The timezone in which the user wants to see times." msgstr "" -#: core/models.py:174 +#: core/models.py:177 msgid "device" msgstr "" -#: core/models.py:176 +#: core/models.py:179 msgid "Whether the user is a device or a real user." msgstr "" -#: core/models.py:179 +#: core/models.py:182 msgid "staff status" msgstr "" -#: core/models.py:181 +#: core/models.py:184 msgid "Whether the user can log into this admin site." msgstr "" -#: core/models.py:184 +#: core/models.py:187 msgid "active" msgstr "" -#: core/models.py:187 -msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts." +#: core/models.py:190 +msgid "" +"Whether this user should be treated as active. Unselect this instead of " +"deleting accounts." msgstr "" -#: core/models.py:199 +#: core/models.py:202 msgid "user" msgstr "" -#: core/models.py:200 +#: core/models.py:203 msgid "users" msgstr "" -#: core/models.py:332 core/models.py:638 +#: core/models.py:340 core/models.py:701 msgid "title" msgstr "" -#: core/models.py:347 +#: core/models.py:358 msgid "Document" msgstr "" -#: core/models.py:348 +#: core/models.py:359 msgid "Documents" msgstr "" -#: core/models.py:351 +#: core/models.py:362 msgid "Untitled Document" msgstr "" -#: core/models.py:530 -#, python-format -msgid "%(sender_name)s shared a document with you: %(document)s" -msgstr "%(sender_name)s a partagé un document avec vous: %(document)s" +#: core/models.py:578 +#, python-brace-format +msgid "{name} shared a document with you!" +msgstr "{name} a partagé un document avec vous !" + +#: core/models.py:580 +#, python-brace-format +msgid "{name} invited you with the role ``{role}`` on the following document:" +msgstr "{name} vous a invité avec le rôle ``{role}`` sur le document suivant :" + +#: core/models.py:583 +#, python-brace-format +msgid "{name} shared a document with you: {title}" +msgstr "{name} a partagé un document avec vous: {title}" -#: core/models.py:574 +#: core/models.py:606 msgid "Document/user link trace" msgstr "" -#: core/models.py:575 +#: core/models.py:607 msgid "Document/user link traces" msgstr "" -#: core/models.py:581 +#: core/models.py:613 msgid "A link trace already exists for this document/user." msgstr "" -#: core/models.py:602 +#: core/models.py:636 +msgid "Document favorite" +msgstr "" + +#: core/models.py:637 +msgid "Document favorites" +msgstr "" + +#: core/models.py:643 +msgid "" +"This document is already targeted by a favorite relation instance for the " +"same user." +msgstr "" + +#: core/models.py:665 msgid "Document/user relation" msgstr "" -#: core/models.py:603 +#: core/models.py:666 msgid "Document/user relations" msgstr "" -#: core/models.py:609 +#: core/models.py:672 msgid "This user is already in this document." msgstr "" -#: core/models.py:615 +#: core/models.py:678 msgid "This team is already in this document." msgstr "" -#: core/models.py:621 core/models.py:810 +#: core/models.py:684 core/models.py:873 msgid "Either user or team must be set, not both." msgstr "" -#: core/models.py:639 +#: core/models.py:702 msgid "description" msgstr "" -#: core/models.py:640 +#: core/models.py:703 msgid "code" msgstr "" -#: core/models.py:641 +#: core/models.py:704 msgid "css" msgstr "" -#: core/models.py:643 +#: core/models.py:706 msgid "public" msgstr "" -#: core/models.py:645 +#: core/models.py:708 msgid "Whether this template is public for anyone to use." msgstr "" -#: core/models.py:651 +#: core/models.py:714 msgid "Template" msgstr "" -#: core/models.py:652 +#: core/models.py:715 msgid "Templates" msgstr "" -#: core/models.py:791 +#: core/models.py:854 msgid "Template/user relation" msgstr "" -#: core/models.py:792 +#: core/models.py:855 msgid "Template/user relations" msgstr "" -#: core/models.py:798 +#: core/models.py:861 msgid "This user is already in this template." msgstr "" -#: core/models.py:804 +#: core/models.py:867 msgid "This team is already in this template." msgstr "" -#: core/models.py:827 +#: core/models.py:890 msgid "email address" msgstr "" -#: core/models.py:844 +#: core/models.py:908 msgid "Document invitation" msgstr "" -#: core/models.py:845 +#: core/models.py:909 msgid "Document invitations" msgstr "" -#: core/models.py:862 +#: core/models.py:926 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: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 f0db2e76b..1a12ce442 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}} - - - {% blocktranslate with href=site.url name=site.name trimmed %} - This mail has been sent to {{email}} by {{name}} - {% endblocktranslate %} - - -