diff --git a/config/django/base.py b/config/django/base.py index c5f5a94d..4f8ebd05 100644 --- a/config/django/base.py +++ b/config/django/base.py @@ -40,6 +40,7 @@ 'styleguide_example.testing_examples.apps.TestingExamplesConfig', 'styleguide_example.integrations.apps.IntegrationsConfig', 'styleguide_example.files.apps.FilesConfig', + 'styleguide_example.emails.apps.EmailsConfig', ] THIRD_PARTY_APPS = [ @@ -181,3 +182,4 @@ from config.settings.sentry import * # noqa from config.settings.files_and_storages import * # noqa +from config.settings.email_sending import * # noqa diff --git a/config/settings/email_sending.py b/config/settings/email_sending.py new file mode 100644 index 00000000..608910ea --- /dev/null +++ b/config/settings/email_sending.py @@ -0,0 +1,22 @@ +from config.env import env, env_to_enum + +from styleguide_example.emails.enums import EmailSendingStrategy + +# local | mailtrap +EMAIL_SENDING_STRATEGY = env_to_enum( + EmailSendingStrategy, + env("EMAIL_SENDING_STRATEGY", default="local") +) + +EMAIL_SENDING_FAILURE_TRIGGER = env.bool("EMAIL_SENDING_FAILURE_TRIGGER", default=False) +EMAIL_SENDING_FAILURE_RATE = env.float("EMAIL_SENDING_FAILURE_RATE", default=0.2) + +if EMAIL_SENDING_STRATEGY == EmailSendingStrategy.LOCAL: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +if EMAIL_SENDING_STRATEGY == EmailSendingStrategy.MAILTRAP: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = env("MAILTRAP_EMAIL_HOST") + EMAIL_HOST_USER = env("MAILTRAP_EMAIL_HOST_USER") + EMAIL_HOST_PASSWORD = env("MAILTRAP_EMAIL_HOST_PASSWORD") + EMAIL_PORT = env("MAILTRAP_EMAIL_PORT") diff --git a/styleguide_example/emails/__init__.py b/styleguide_example/emails/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/styleguide_example/emails/admin.py b/styleguide_example/emails/admin.py new file mode 100644 index 00000000..a1672af0 --- /dev/null +++ b/styleguide_example/emails/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from styleguide_example.emails.models import Email +from styleguide_example.emails.services import email_send_all + + +@admin.register(Email) +class EmailAdmin(admin.ModelAdmin): + list_display = ["id", "subject", "to", "status", "sent_at"] + actions = ["send_email"] + + def get_queryset(self, request): + """ + We want to defer the `html` and `plain_text` fields, + since we are not showing them in the list & we don't need to fetch them. + + Potentially, those fields can be quite heavy. + """ + queryset = super().get_queryset(request) + return queryset.defer("html", "plain_text") + + @admin.action(description="Send selected emails.") + def send_email(self, request, queryset): + email_send_all(queryset) diff --git a/styleguide_example/emails/apps.py b/styleguide_example/emails/apps.py new file mode 100644 index 00000000..c8e42833 --- /dev/null +++ b/styleguide_example/emails/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EmailsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'styleguide_example.emails' diff --git a/styleguide_example/emails/enums.py b/styleguide_example/emails/enums.py new file mode 100644 index 00000000..738a6ae9 --- /dev/null +++ b/styleguide_example/emails/enums.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class EmailSendingStrategy(Enum): + LOCAL = "local" + MAILTRAP = "mailtrap" diff --git a/styleguide_example/emails/migrations/0001_initial.py b/styleguide_example/emails/migrations/0001_initial.py new file mode 100644 index 00000000..9839c957 --- /dev/null +++ b/styleguide_example/emails/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.13 on 2022-05-17 07:34 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Email', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('status', models.CharField(choices=[('READY', 'Ready'), ('SENDING', 'Sending'), ('SENT', 'Sent'), ('FAILED', 'Failed')], db_index=True, default='READY', max_length=255)), + ('to', models.EmailField(max_length=254)), + ('subject', models.CharField(max_length=255)), + ('html', models.TextField()), + ('plain_text', models.TextField()), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/styleguide_example/emails/migrations/__init__.py b/styleguide_example/emails/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/styleguide_example/emails/models.py b/styleguide_example/emails/models.py new file mode 100644 index 00000000..c372f1d3 --- /dev/null +++ b/styleguide_example/emails/models.py @@ -0,0 +1,26 @@ +from django.db import models + +from styleguide_example.common.models import BaseModel + + +class Email(BaseModel): + class Status(models.TextChoices): + READY = "READY", "Ready" + SENDING = "SENDING", "Sending" + SENT = "SENT", "Sent" + FAILED = "FAILED", "Failed" + + status = models.CharField( + max_length=255, + db_index=True, + choices=Status.choices, + default=Status.READY + ) + + to = models.EmailField() + subject = models.CharField(max_length=255) + + html = models.TextField() + plain_text = models.TextField() + + sent_at = models.DateTimeField(blank=True, null=True) diff --git a/styleguide_example/emails/services.py b/styleguide_example/emails/services.py new file mode 100644 index 00000000..b0b57a6a --- /dev/null +++ b/styleguide_example/emails/services.py @@ -0,0 +1,85 @@ +import random + +from django.db import transaction +from django.db.models.query import QuerySet +from django.core.mail import EmailMultiAlternatives +from django.utils import timezone +from django.conf import settings + +from styleguide_example.core.exceptions import ApplicationError + +from styleguide_example.common.services import model_update + +from styleguide_example.emails.models import Email +from styleguide_example.emails.tasks import email_send as email_send_task + + +@transaction.atomic +def email_failed(email: Email) -> Email: + if email.status != Email.Status.SENDING: + raise ApplicationError(f"Cannot fail non-sending emails. Current status is {email.status}") + + email, _ = model_update( + instance=email, + fields=["status"], + data={ + "status": Email.Status.FAILED + } + ) + return email + + +@transaction.atomic +def email_send(email: Email) -> Email: + if email.status != Email.Status.SENDING: + raise ApplicationError(f"Cannot send non-ready emails. Current status is {email.status}") + + if settings.EMAIL_SENDING_FAILURE_TRIGGER: + failure_dice = random.uniform(0, 1) + + if failure_dice <= settings.EMAIL_SENDING_FAILURE_RATE: + raise ApplicationError("Email sending failure triggered.") + + subject = email.subject + from_email = "styleguide-example@hacksoft.io" + to = email.to + + html = email.html + plain_text = email.plain_text + + msg = EmailMultiAlternatives(subject, plain_text, from_email, [to]) + msg.attach_alternative(html, "text/html") + + msg.send() + + email, _ = model_update( + instance=email, + fields=["status", "sent_at"], + data={ + "status": Email.Status.SENT, + "sent_at": timezone.now() + } + ) + return email + + +def email_send_all(emails: QuerySet[Email]): + """ + This is a very specific service. + + We don't want to decorate with @transaction.atomic, + since we are executing updates, 1 by 1, in a separate atomic block, + so we can trigger transaction.on_commit for each email, separately. + """ + for email in emails: + with transaction.atomic(): + Email.objects.filter(id=email.id).update( + status=Email.Status.SENDING + ) + + # Create a closure, to capture the proper value of each id + transaction.on_commit( + ( + lambda email_id: lambda: email_send_task.delay(email_id) + )(email.id) + ) diff --git a/styleguide_example/emails/tasks.py b/styleguide_example/emails/tasks.py new file mode 100644 index 00000000..85e1d7f1 --- /dev/null +++ b/styleguide_example/emails/tasks.py @@ -0,0 +1,30 @@ +from celery import shared_task +from celery.utils.log import get_task_logger + +from styleguide_example.emails.models import Email + + +logger = get_task_logger(__name__) + + +def _email_send_failure(self, exc, task_id, args, kwargs, einfo): + email_id = args[0] + email = Email.objects.get(id=email_id) + + from styleguide_example.emails.services import email_failed + + email_failed(email) + + +@shared_task(bind=True, on_failure=_email_send_failure) +def email_send(self, email_id): + email = Email.objects.get(id=email_id) + + from styleguide_example.emails.services import email_send + + try: + email_send(email) + except Exception as exc: + # https://docs.celeryq.dev/en/stable/userguide/tasks.html#retrying + logger.warning(f"Exception occurred while sending email: {exc}") + self.retry(exc=exc, countdown=5)