♻️(backend) refacto email invitation
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
86
src/backend/core/tests/test_utils.py
Normal file
86
src/backend/core/tests/test_utils.py
Normal file
@@ -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)
|
||||
39
src/backend/core/utils.py
Normal file
39
src/backend/core/utils.py
Normal file
@@ -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)
|
||||
@@ -30,7 +30,7 @@
|
||||
<li>{% trans "Invite members of your community to your document in just a few clicks."%}</li>
|
||||
</ul>
|
||||
</mj-text>
|
||||
<mj-button href="//{{site.domain}}" background-color="#000091" color="white" padding-bottom="30px">
|
||||
<mj-button href="//{{site.domain}}/docs/{{document_id}}/" background-color="#000091" color="white" padding-bottom="30px">
|
||||
{% trans "Visit Docs"%}
|
||||
</mj-button>
|
||||
<mj-text>{% trans "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members." %}</mj-text>
|
||||
|
||||
Reference in New Issue
Block a user