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 %}
-
-
-