diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/notifications.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/notifications.py new file mode 100644 index 0000000..172be14 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/notifications.py @@ -0,0 +1,82 @@ +import logging + +from dataclasses import dataclass + +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import get_template +from django.utils.html import strip_tags + + +logger = logging.getLogger(__name__) + + +@dataclass +class NotificationHandlerConfig: + # Unique identifier of the notification. + # if no templates are specified, the templates will be named after this + kind: str + subject: str + # Name of the template to use for the html version of the email. + # It should be relative to the account/notifications directory. + template_html_filename: str | None = None + # Name of the template to use for the plain text version of the email. + # It should be relative to the account/notifications directory. + template_plain_filename: str | None = None + # Email address to use as the sender of the email. + from_email: str = settings.DEFAULT_FROM_EMAIL + + @property + def template_html_name(self): + filename = self.template_html_filename or f"{self.kind}.html" + return f"accounts/notifications/{filename}" + + @property + def template_plain_name(self): + filename = self.template_plain_filename or f"{self.kind}.html" + return f"accounts/notifications/{filename}" + + @property + def html_only(self) -> bool: + return self.template_plain_name == self.template_html_name + + +_HANDLERS = [ + NotificationHandlerConfig( + kind="user-reset-password", + subject="Reset your password", + ), +] + +HANDLERS = {handler.kind: handler for handler in _HANDLERS} + + +def notification(user_email: str, kind: str, context: dict) -> None: + try: + config = HANDLERS[kind] + except KeyError: + logger.warning("Attempted to send notification of unknown kind %s", kind) + return + + template_html = get_template(config.template_html_name) + template_plain = get_template(config.template_plain_name) + + message_html = template_html.render(context) + + if config.html_only: + message_plain = strip_tags(message_html) + else: + message_plain = template_plain.render(context) + + res = send_mail( + subject=config.subject, + message=message_plain, + from_email=config.from_email, + recipient_list=[user_email], + html_message=message_html, + ) + + if res != 1: + logger.warning(f"Failed to send {kind} notification to user {user_email}") + + return diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py index 3378243..ae04a77 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/services/password.py @@ -11,6 +11,7 @@ WrongPasswordError, ) from {{ cookiecutter.project_slug }}.apps.accounts.models import UserAccount +from {{ cookiecutter.project_slug }}.apps.accounts.tasks import send_notification class PasswordService: @@ -46,7 +47,7 @@ def send_reset_password_link(cls, email: str) -> None: "domain_name": domain_name, "reset_password_link": reset_password_link, } - cls._send_notification(user.pk, context) + cls._send_notification(user.email, context) @classmethod def reset_password(cls, signature: str, new_password: str) -> None: @@ -60,9 +61,8 @@ def reset_password(cls, signature: str, new_password: str) -> None: cls.change_password(user, new_password) @staticmethod - def _send_notification(user_pk: str, context: dict): - # send_notification.delay(user_pk, "user-reset-password", context) # TODO: Implement notification sending - pass + def _send_notification(user_email: str, context: dict): + send_notification.delay(user_email, "user-reset-password", context) @staticmethod def _generate_reset_password_signature(user: UserAccount) -> str: diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tasks.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tasks.py new file mode 100644 index 0000000..17724ae --- /dev/null +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tasks.py @@ -0,0 +1,14 @@ +import logging + +from {{ cookiecutter.project_slug }} import celery_app +from {{ cookiecutter.project_slug }}.apps.accounts.services.notifications import notification + + +logger = logging.getLogger(__name__) + + +@celery_app.task +def send_notification(user_email: str, kind: str, context: dict): + logger.debug(f"Sending {kind} notification to user {user_email}") + + notification(user_email, kind, context) diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py index 77d28c5..bffd2de 100644 --- a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/apps/accounts/tests/test_services/test_password_service.py @@ -111,7 +111,7 @@ def test_send_reset_password_link_success(settings, user_account, mocker): mocked_generate_reset_password_signature.assert_called_once_with(user) mocked_send_notification.assert_called_once_with( - user.pk, + user.email, { "user_notification_salutation": "Dear client", "domain_name": domain_name, diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/templates/accounts/notifications/base-email.html b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/templates/accounts/notifications/base-email.html new file mode 100644 index 0000000..88bfcd9 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/templates/accounts/notifications/base-email.html @@ -0,0 +1,25 @@ +{% raw %} +{% load static %} + + + + + + + + + + + + + + +{% endraw %} diff --git a/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/templates/accounts/notifications/user-reset-password.html b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/templates/accounts/notifications/user-reset-password.html new file mode 100644 index 0000000..978d0b4 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/api/{{ cookiecutter.project_slug }}/templates/accounts/notifications/user-reset-password.html @@ -0,0 +1,20 @@ +{% raw %}{% extends 'accounts/notifications/base-email.html' %} + +{% block content %} + + + + + +{% endblock %}{% endraw %}