From 5b47a0c9df87bbe49d8812a06b1384b2a1288510 Mon Sep 17 00:00:00 2001 From: Radoslav Georgiev Date: Mon, 16 May 2022 17:06:06 +0300 Subject: [PATCH 1/3] Add initial implementation of simple email sending --- config/django/base.py | 2 + config/settings/email_sending.py | 22 +++++++ styleguide_example/emails/__init__.py | 0 styleguide_example/emails/admin.py | 24 ++++++++ styleguide_example/emails/apps.py | 6 ++ styleguide_example/emails/enums.py | 6 ++ .../emails/migrations/0001_initial.py | 31 ++++++++++ .../emails/migrations/0002_email_subject.py | 19 ++++++ .../emails/migrations/__init__.py | 0 styleguide_example/emails/models.py | 26 ++++++++ styleguide_example/emails/services.py | 61 +++++++++++++++++++ styleguide_example/emails/tasks.py | 12 ++++ 12 files changed, 209 insertions(+) create mode 100644 config/settings/email_sending.py create mode 100644 styleguide_example/emails/__init__.py create mode 100644 styleguide_example/emails/admin.py create mode 100644 styleguide_example/emails/apps.py create mode 100644 styleguide_example/emails/enums.py create mode 100644 styleguide_example/emails/migrations/0001_initial.py create mode 100644 styleguide_example/emails/migrations/0002_email_subject.py create mode 100644 styleguide_example/emails/migrations/__init__.py create mode 100644 styleguide_example/emails/models.py create mode 100644 styleguide_example/emails/services.py create mode 100644 styleguide_example/emails/tasks.py 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..1f353ee9 --- /dev/null +++ b/styleguide_example/emails/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.13 on 2022-05-16 13:28 + +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)), + ('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/0002_email_subject.py b/styleguide_example/emails/migrations/0002_email_subject.py new file mode 100644 index 00000000..47c23a94 --- /dev/null +++ b/styleguide_example/emails/migrations/0002_email_subject.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.13 on 2022-05-16 13:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emails', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='email', + name='subject', + field=models.CharField(default='', max_length=255), + preserve_default=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..34d75378 --- /dev/null +++ b/styleguide_example/emails/services.py @@ -0,0 +1,61 @@ +from django.db import transaction +from django.db.models.query import QuerySet +from django.core.mail import EmailMultiAlternatives +from django.utils import timezone + +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_send(email: Email) -> Email: + if email.status != Email.Status.SENDING: + raise ApplicationError(f"Cannot send non-ready emails. Current status is {email.status}") + + 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. + """ + 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..f614a55e --- /dev/null +++ b/styleguide_example/emails/tasks.py @@ -0,0 +1,12 @@ +from celery import shared_task + +from styleguide_example.emails.models import Email + + +@shared_task +def email_send(email_id): + # TODO: Add error handling + email = Email.objects.get(id=email_id) + + from styleguide_example.emails.services import email_send + email_send(email) From e21397791fad6ad8872ef4faae77d84f6d1308d6 Mon Sep 17 00:00:00 2001 From: Radoslav Georgiev Date: Tue, 17 May 2022 09:43:04 +0300 Subject: [PATCH 2/3] Add error handling to the email sending task --- styleguide_example/emails/services.py | 26 +++++++++++++++++++++++++- styleguide_example/emails/tasks.py | 26 ++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/styleguide_example/emails/services.py b/styleguide_example/emails/services.py index 34d75378..b0b57a6a 100644 --- a/styleguide_example/emails/services.py +++ b/styleguide_example/emails/services.py @@ -1,7 +1,10 @@ +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 @@ -11,11 +14,32 @@ 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 @@ -45,7 +69,7 @@ def email_send_all(emails: QuerySet[Email]): 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. + so we can trigger transaction.on_commit for each email, separately. """ for email in emails: with transaction.atomic(): diff --git a/styleguide_example/emails/tasks.py b/styleguide_example/emails/tasks.py index f614a55e..85e1d7f1 100644 --- a/styleguide_example/emails/tasks.py +++ b/styleguide_example/emails/tasks.py @@ -1,12 +1,30 @@ from celery import shared_task +from celery.utils.log import get_task_logger from styleguide_example.emails.models import Email -@shared_task -def email_send(email_id): - # TODO: Add error handling +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 - email_send(email) + + 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) From 7c039a1f96f073a004e924ee6fdc94c5ddbedb74 Mon Sep 17 00:00:00 2001 From: Radoslav Georgiev Date: Tue, 17 May 2022 10:34:57 +0300 Subject: [PATCH 3/3] Squash migrations --- .../emails/migrations/0001_initial.py | 3 ++- .../emails/migrations/0002_email_subject.py | 19 ------------------- 2 files changed, 2 insertions(+), 20 deletions(-) delete mode 100644 styleguide_example/emails/migrations/0002_email_subject.py diff --git a/styleguide_example/emails/migrations/0001_initial.py b/styleguide_example/emails/migrations/0001_initial.py index 1f353ee9..9839c957 100644 --- a/styleguide_example/emails/migrations/0001_initial.py +++ b/styleguide_example/emails/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.13 on 2022-05-16 13:28 +# Generated by Django 3.2.13 on 2022-05-17 07:34 from django.db import migrations, models import django.utils.timezone @@ -20,6 +20,7 @@ class Migration(migrations.Migration): ('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)), diff --git a/styleguide_example/emails/migrations/0002_email_subject.py b/styleguide_example/emails/migrations/0002_email_subject.py deleted file mode 100644 index 47c23a94..00000000 --- a/styleguide_example/emails/migrations/0002_email_subject.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 3.2.13 on 2022-05-16 13:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('emails', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='email', - name='subject', - field=models.CharField(default='', max_length=255), - preserve_default=False, - ), - ]