diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index c09b824f..e43f7e76 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -262,7 +262,6 @@ class InvitationSerializer(serializers.ModelSerializer): "Only owners of a document can invite other users as owners." ) - user.language = request.headers.get("Content-Language", "en") attrs["document_id"] = document_id attrs["issuer"] = user return attrs diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 07691042..13af6198 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1,4 +1,5 @@ """API endpoints""" +from core.utils import email_invitation from django.contrib.postgres.aggregates import ArrayAgg from django.db.models import ( OuterRef, @@ -598,3 +599,10 @@ class InvitationViewset( .distinct() ) return queryset + + def perform_create(self, serializer): + """Save invitation to a document then send an email to the invited user.""" + invitation = serializer.save() + + language = self.request.headers.get("Content-Language", "en-us") + email_invitation(language, invitation.email, invitation.document.id) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a64250b8..3e693260 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -3,7 +3,6 @@ Declare and configure the models for the impress core application """ import hashlib import os -import smtplib import tempfile import textwrap import uuid @@ -14,7 +13,6 @@ from logging import getLogger from django.conf import settings from django.contrib.auth import models as auth_models from django.contrib.auth.base_user import AbstractBaseUser -from django.contrib.sites.models import Site from django.core import exceptions, mail, validators from django.core.files.base import ContentFile from django.core.files.storage import default_storage @@ -22,11 +20,9 @@ from django.db import models from django.http import FileResponse from django.template.base import Template as DjangoTemplate from django.template.context import Context -from django.template.loader import render_to_string from django.utils import html, timezone from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _ -from django.utils.translation import override import frontmatter import markdown @@ -752,7 +748,6 @@ class Invitation(BaseModel): raise exceptions.PermissionDenied() super().save(*args, **kwargs) - self.email_invitation() def clean(self): """Validate fields.""" @@ -800,23 +795,3 @@ class Invitation(BaseModel): "partial_update": False, "retrieve": bool(roles), } - - def email_invitation(self): - """Email invitation to the user.""" - try: - with override(self.issuer.language): - title = _("Invitation to join Docs!") - template_vars = {"title": title, "site": Site.objects.get_current()} - msg_html = render_to_string("mail/html/invitation.html", template_vars) - msg_plain = render_to_string("mail/text/invitation.txt", template_vars) - mail.send_mail( - title, - msg_plain, - settings.EMAIL_FROM, - [self.email], - html_message=msg_html, - fail_silently=False, - ) - - except smtplib.SMTPException as exception: - logger.error("invitation to %s was not sent: %s", self.email, exception) diff --git a/src/backend/core/tests/test_api_document_invitations.py b/src/backend/core/tests/test_api_document_invitations.py index 2452468e..60ab5f6d 100644 --- a/src/backend/core/tests/test_api_document_invitations.py +++ b/src/backend/core/tests/test_api_document_invitations.py @@ -100,6 +100,8 @@ def test_api_document_invitations__create__privileged_members( "role": invited, } + assert len(mail.outbox) == 0 + client = APIClient() client.force_login(user) response = client.post( @@ -110,6 +112,12 @@ def test_api_document_invitations__create__privileged_members( if is_allowed: assert response.status_code == status.HTTP_201_CREATED assert models.Invitation.objects.count() == 1 + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.to == ["guest@example.com"] + email_content = " ".join(email.body.split()) + assert "Invitation to join Docs!" in email_content else: assert response.status_code == status.HTTP_403_FORBIDDEN assert models.Invitation.objects.exists() is False diff --git a/src/backend/core/tests/test_models_invitations.py b/src/backend/core/tests/test_models_invitations.py index 96de9c65..3013047a 100644 --- a/src/backend/core/tests/test_models_invitations.py +++ b/src/backend/core/tests/test_models_invitations.py @@ -1,14 +1,10 @@ """ Unit tests for the Invitation model """ - -import smtplib import time -from logging import Logger -from unittest import mock from django.contrib.auth.models import AnonymousUser -from django.core import exceptions, mail +from django.core import exceptions import pytest from faker import Faker @@ -168,97 +164,6 @@ def test_models_invitation__new_user__user_creation_constant_num_queries( models.User.objects.create(email=user_email, password="!") -def test_models_document_invitations_email(): - """Check email invitation during invitation creation.""" - member_access = factories.UserDocumentAccessFactory(role="reader") - document = member_access.document - - # pylint: disable-next=no-member - assert len(mail.outbox) == 0 - - factories.UserDocumentAccessFactory(document=document) - issuer = factories.UserFactory(language="en-us") - invitation = factories.InvitationFactory( - document=document, email="john@people.com", issuer=issuer - ) - - # pylint: disable-next=no-member - assert len(mail.outbox) == 1 - - # pylint: disable-next=no-member - email = mail.outbox[0] - - assert email.to == [invitation.email] - assert email.subject == "Invitation to join Docs!" - - email_content = " ".join(email.body.split()) - assert "Invitation to join Docs!" in email_content - assert "[//example.com]" in email_content - - -def test_models_document_invitations_email_language_fr(): - """Check email invitation during invitation creation.""" - member_access = factories.UserDocumentAccessFactory(role="reader") - document = member_access.document - - # pylint: disable-next=no-member - assert len(mail.outbox) == 0 - - factories.UserDocumentAccessFactory(document=document) - issuer = factories.UserFactory(language="fr-fr") - invitation = factories.InvitationFactory( - document=document, email="john@people.com", issuer=issuer - ) - - # pylint: disable-next=no-member - assert len(mail.outbox) == 1 - - # pylint: disable-next=no-member - email = mail.outbox[0] - - assert email.to == [invitation.email] - - email_content = " ".join(email.body.split()) - assert "Invitation à rejoindre Docs !" in email_content - assert "[//example.com]" in email_content - - -@mock.patch( - "django.core.mail.send_mail", - side_effect=smtplib.SMTPException("Error SMTPException"), -) -@mock.patch.object(Logger, "error") -def test_models_document_invitations_email_failed(mock_logger, _mock_send_mail): - """Check invitation behavior when an SMTP error occurs during invitation creation.""" - - member_access = factories.UserDocumentAccessFactory(role="reader") - document = member_access.document - - # pylint: disable-next=no-member - assert len(mail.outbox) == 0 - - factories.UserDocumentAccessFactory(document=document) - - # No error should be raised - invitation = factories.InvitationFactory(document=document, email="john@people.com") - - # No email has been sent - # pylint: disable-next=no-member - assert len(mail.outbox) == 0 - - # Logger should be called - mock_logger.assert_called_once() - - ( - _, - email, - exception, - ) = mock_logger.call_args.args - - assert email == invitation.email - assert isinstance(exception, smtplib.SMTPException) - - # get_abilities diff --git a/src/backend/core/tests/test_utils.py b/src/backend/core/tests/test_utils.py new file mode 100644 index 00000000..68c6148f --- /dev/null +++ b/src/backend/core/tests/test_utils.py @@ -0,0 +1,86 @@ +""" +Unit tests for the Invitation model +""" +import smtplib +from logging import Logger +from unittest import mock + +from django.core import mail + +import pytest + +from core.utils import email_invitation + +pytestmark = pytest.mark.django_db + + +def test_utils__email_invitation_success(): + """ + The email invitation is sent successfully. + """ + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + email_invitation("en", "guest@example.com", "123-456-789") + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == ["guest@example.com"] + email_content = " ".join(email.body.split()) + assert "Invitation to join Docs!" in email_content + assert "docs/123-456-789/" in email_content + + +def test_utils__email_invitation_success_fr(): + """ + The email invitation is sent successfully in french. + """ + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + email_invitation("fr-fr", "guest@example.com", "123-456-789") + + # pylint: disable-next=no-member + assert len(mail.outbox) == 1 + + # pylint: disable-next=no-member + email = mail.outbox[0] + + assert email.to == ["guest@example.com"] + email_content = " ".join(email.body.split()) + assert "Invitation à rejoindre Docs !" in email_content + assert "docs/123-456-789/" in email_content + + +@mock.patch( + "core.utils.send_mail", + side_effect=smtplib.SMTPException("Error SMTPException"), +) +@mock.patch.object(Logger, "error") +def test_utils__email_invitation_failed(mock_logger, _mock_send_mail): + """Check mail behavior when an SMTP error occurs when sent an email invitation.""" + + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + email_invitation("en", "guest@example.com", "123-456-789") + + # No email has been sent + # pylint: disable-next=no-member + assert len(mail.outbox) == 0 + + # Logger should be called + mock_logger.assert_called_once() + + ( + _, + email, + exception, + ) = mock_logger.call_args.args + + assert email == "guest@example.com" + assert isinstance(exception, smtplib.SMTPException) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py new file mode 100644 index 00000000..58ed6764 --- /dev/null +++ b/src/backend/core/utils.py @@ -0,0 +1,39 @@ +""" +Utilities for the core app. +""" +import smtplib +from logging import getLogger + +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import override + +logger = getLogger(__name__) + + +def email_invitation(language, email, document_id): + """Send email invitation.""" + try: + with override(language): + title = _("Invitation to join Docs!") + template_vars = { + "title": title, + "site": Site.objects.get_current(), + "document_id": document_id, + } + msg_html = render_to_string("mail/html/invitation.html", template_vars) + msg_plain = render_to_string("mail/text/invitation.txt", template_vars) + send_mail( + title, + msg_plain, + settings.EMAIL_FROM, + [email], + html_message=msg_html, + fail_silently=False, + ) + + except smtplib.SMTPException as exception: + logger.error("invitation to %s was not sent: %s", email, exception) diff --git a/src/mail/mjml/invitation.mjml b/src/mail/mjml/invitation.mjml index 864c2093..19a9fb44 100644 --- a/src/mail/mjml/invitation.mjml +++ b/src/mail/mjml/invitation.mjml @@ -30,7 +30,7 @@
  • {% trans "Invite members of your community to your document in just a few clicks."%}
  • - + {% trans "Visit Docs"%} {% trans "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members." %}