From 2f8c5637f467e0f86f564ded81f2b79ac51a7a9c Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 15 Aug 2024 15:38:38 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20refacto=20email?= =?UTF-8?q?=20invitation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove email invitation from Invitation model to be able to use it in other context. We add it in utils.py instead, and it will be called from the viewset. We add the document_id to link to the document from the mail. --- src/backend/core/api/serializers.py | 1 - src/backend/core/api/viewsets.py | 8 ++ src/backend/core/models.py | 25 ----- .../tests/test_api_document_invitations.py | 8 ++ .../core/tests/test_models_invitations.py | 97 +------------------ src/backend/core/tests/test_utils.py | 86 ++++++++++++++++ src/backend/core/utils.py | 39 ++++++++ src/mail/mjml/invitation.mjml | 2 +- 8 files changed, 143 insertions(+), 123 deletions(-) create mode 100644 src/backend/core/tests/test_utils.py create mode 100644 src/backend/core/utils.py 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." %}