Skip to content

Example for email sending & working with Celery tasks #168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/django/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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
22 changes: 22 additions & 0 deletions config/settings/email_sending.py
Original file line number Diff line number Diff line change
@@ -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")
Empty file.
24 changes: 24 additions & 0 deletions styleguide_example/emails/admin.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions styleguide_example/emails/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class EmailsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'styleguide_example.emails'
6 changes: 6 additions & 0 deletions styleguide_example/emails/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from enum import Enum


class EmailSendingStrategy(Enum):
LOCAL = "local"
MAILTRAP = "mailtrap"
32 changes: 32 additions & 0 deletions styleguide_example/emails/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
Empty file.
26 changes: 26 additions & 0 deletions styleguide_example/emails/models.py
Original file line number Diff line number Diff line change
@@ -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)
85 changes: 85 additions & 0 deletions styleguide_example/emails/services.py
Original file line number Diff line number Diff line change
@@ -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)
)
30 changes: 30 additions & 0 deletions styleguide_example/emails/tasks.py
Original file line number Diff line number Diff line change
@@ -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)