(backend) add server-to-server API endpoint to create documents

We want trusted external applications to be able to create documents
via the API on behalf of any user. The user may or may not pre-exist
in our database and should be notified of the document creation by
email.
This commit is contained in:
Samuel Paccoud - DINUM
2024-12-01 11:25:01 +01:00
committed by aleb_the_flash
parent 47e23bff90
commit 5cdd06d432
17 changed files with 1072 additions and 218 deletions

View File

@@ -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

View File

@@ -4,6 +4,7 @@ import mimetypes
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import magic
@@ -11,6 +12,10 @@ from rest_framework import exceptions, serializers
from core import enums, models
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
YdocConverter,
)
class UserSerializer(serializers.ModelSerializer):
@@ -227,6 +232,96 @@ class DocumentSerializer(ListDocumentSerializer):
return value
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
Serializer for creating a document from a server-to-server request.
Expects 'content' as a markdown string, which is converted to our internal format
via a Node.js microservice. The conversion is handled automatically, so third parties
only need to provide markdown.
Both "sub" and "email" are required because the external app calling doesn't know
if the user will pre-exist in Docs database. If the user pre-exist, we will ignore the
submitted "email" field and use the email address set on the user account in our database
"""
# Document
title = serializers.CharField(required=True)
content = serializers.CharField(required=True)
# User
sub = serializers.CharField(
required=True, validators=[models.User.sub_validator], max_length=255
)
email = serializers.EmailField(required=True)
language = serializers.ChoiceField(
required=False, choices=lazy(lambda: settings.LANGUAGES, tuple)()
)
# Invitation
message = serializers.CharField(required=False)
subject = serializers.CharField(required=False)
def create(self, validated_data):
"""Create the document and associate it with the user or send an invitation."""
language = validated_data.get("language", settings.LANGUAGE_CODE)
# Get the user based on the sub (unique identifier)
try:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
email = user.email
language = user.language or language
try:
converter_response = YdocConverter().convert_markdown(
validated_data["content"]
)
except ConversionError as err:
raise exceptions.APIException(detail="could not convert content") from err
document = models.Document.objects.create(
title=validated_data["title"],
content=converter_response["content"],
creator=user,
)
if user:
# Associate the document with the pre-existing user
models.DocumentAccess.objects.create(
document=document,
role=models.RoleChoices.OWNER,
user=user,
)
else:
# The user doesn't exist in our database: we need to invite him/her
models.Invitation.objects.create(
document=document,
email=email,
role=models.RoleChoices.OWNER,
)
# Notify the user about the newly created document
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
context = {
"message": validated_data.get("message")
or _("You have been granted ownership of a new document:"),
"title": subject,
}
document.send_email(subject, [email], context, language)
return document
def update(self, instance, validated_data):
"""
This serializer does not support updates.
"""
raise NotImplementedError("Update is not supported for this serializer.")
class LinkDocumentSerializer(BaseResourceSerializer):
"""
Serialize link configuration for documents.

View File

@@ -25,10 +25,11 @@ from django.http import Http404
import rest_framework as drf
from botocore.exceptions import ClientError
from django_filters import rest_framework as drf_filters
from rest_framework import filters
from rest_framework import filters, status
from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from core import enums, models
from core import authentication, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
@@ -430,6 +431,30 @@ class DocumentViewSet(
role=models.RoleChoices.OWNER,
)
@drf.decorators.action(
authentication_classes=[authentication.ServerToServerAuthentication],
detail=False,
methods=["post"],
permission_classes=[],
url_path="create-for-owner",
)
def create_for_owner(self, request):
"""
Create a document on behalf of a specified owner (pre-existing user or invited).
"""
# Deserialize and validate the data
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
document = serializer.save()
return drf_response.Response(
{"id": str(document.id)}, status=status.HTTP_201_CREATED
)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
@@ -813,11 +838,11 @@ class DocumentAccessViewSet(
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.email_invitation(
language,
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
)
def perform_update(self, serializer):
@@ -1078,8 +1103,8 @@ class InvitationViewset(
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.email_invitation(
language, invitation.email, invitation.role, self.request.user
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
)

View File

@@ -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'"

View File

@@ -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'),
),
]

View File

@@ -26,8 +26,8 @@ from django.template.context import Context
from django.template.loader import render_to_string
from django.utils import html, timezone
from django.utils.functional import cached_property, lazy
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
from django.utils.translation import override
import frontmatter
import markdown
@@ -239,6 +239,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
for invitation in valid_invitations
]
)
# Set creator of documents if not yet set (e.g. documents created via server-to-server API)
document_ids = [invitation.document_id for invitation in valid_invitations]
Document.objects.filter(id__in=document_ids, creator__isnull=True).update(
creator=self
)
valid_invitations.delete()
def email_user(self, subject, message, from_email=None, **kwargs):
@@ -342,7 +349,11 @@ class Document(BaseModel):
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
creator = models.ForeignKey(
User, on_delete=models.RESTRICT, related_name="documents_created"
User,
on_delete=models.RESTRICT,
related_name="documents_created",
blank=True,
null=True,
)
_content = None
@@ -534,44 +545,61 @@ class Document(BaseModel):
"versions_retrieve": has_role,
}
def email_invitation(self, language, email, role, sender):
"""Send email invitation."""
sender_name = sender.full_name or sender.email
def send_email(self, subject, emails, context=None, language=None):
"""Generate and send email from a template."""
context = context or {}
domain = Site.objects.get_current().domain
language = language or get_language()
context.update(
{
"domain": domain,
"link": f"{domain}/docs/{self.id}/",
"document": self,
}
)
try:
with override(language):
title = _(
"%(sender_name)s shared a document with you: %(document)s"
) % {
"sender_name": sender_name,
"document": self.title,
}
template_vars = {
"title": title,
"domain": domain,
"document": self,
"link": f"{domain}/docs/{self.id}/",
"sender_name": sender_name,
"sender_name_email": f"{sender.full_name} ({sender.email})"
if sender.full_name
else sender.email,
"role": RoleChoices(role).label.lower(),
}
msg_html = render_to_string("mail/html/invitation.html", template_vars)
msg_plain = render_to_string("mail/text/invitation.txt", template_vars)
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(subject) # Force translation
try:
send_mail(
title,
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[email],
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", emails, exception)
except smtplib.SMTPException as exception:
logger.error("invitation to %s was not sent: %s", email, exception)
def send_invitation_email(self, email, role, sender, language=None):
"""Method allowing a user to send an email invitation to another user for a document."""
language = language or get_language()
role = RoleChoices(role).label
sender_name = sender.full_name or sender.email
sender_name_email = (
f"{sender.full_name:s} ({sender.email})"
if sender.full_name
else sender.email
)
with override(language):
context = {
"title": _("{name} shared a document with you!").format(
name=sender_name
),
"message": _(
"{name} invited you with the role ``{role}`` on the following document:"
).format(name=sender_name_email, role=role.lower()),
}
subject = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
self.send_email(subject, [email], context, language)
class LinkTrace(BaseModel):
@@ -887,6 +915,8 @@ class Invitation(BaseModel):
User,
on_delete=models.CASCADE,
related_name="invitations",
blank=True,
null=True,
)
class Meta:

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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"}

View File

@@ -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)

View File

@@ -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
):

View File

@@ -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)

View File

@@ -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.

View File

@@ -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."
msgstr ""
#: core/models.py:149
msgid "full name"
msgstr ""
#: core/models.py:150
msgid "short name"
#: core/models.py:143
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: "
"characters only."
msgstr ""
#: core/models.py:152
msgid "full name"
msgstr ""
#: core/models.py:153
msgid "short name"
msgstr ""
#: 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:574
#: 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: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
msgid "Document/user relation"
#: core/models.py:636
msgid "Document favorite"
msgstr ""
#: core/models.py:603
msgid "Document/user relations"
msgstr ""
#: core/models.py:609
msgid "This user is already in this document."
msgstr ""
#: core/models.py:615
msgid "This team is already in this document."
msgstr ""
#: core/models.py:621 core/models.py:810
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:639
msgid "description"
msgstr ""
#: core/models.py:640
msgid "code"
msgstr ""
#: core/models.py:641
msgid "css"
#: 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:666
msgid "Document/user relations"
msgstr ""
#: core/models.py:672
msgid "This user is already in this document."
msgstr ""
#: core/models.py:678
msgid "This team is already in this document."
msgstr ""
#: core/models.py:684 core/models.py:873
msgid "Either user or team must be set, not both."
msgstr ""
#: core/models.py:702
msgid "description"
msgstr ""
#: core/models.py:703
msgid "code"
msgstr ""
#: core/models.py:704
msgid "css"
msgstr ""
#: 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 <a href=\"%(href)s\">%(name)s</a>"
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 !"

View File

@@ -22,17 +22,11 @@
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>
{% blocktrans %}
{{sender_name}} shared a document with you !
{% endblocktrans %}
</h1>
<h1>{{title|capfirst}}</h1>
</mj-text>
<!-- Main Message -->
<mj-text>
{% blocktrans %}
{{sender_name_email}} invited you with the role "{{role}}" on the following document :
{% endblocktrans %}
{{message|capfirst}}
<a href="{{link}}">{{document.title}}</a>
</mj-text>
<mj-button