Skip to content

Commit 5cb309c

Browse files
authored
Merge pull request #168 from HackSoftware/emails/examples
Example for email sending & working with Celery tasks
2 parents e98a68b + 7c039a1 commit 5cb309c

File tree

11 files changed

+233
-0
lines changed

11 files changed

+233
-0
lines changed

config/django/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
'styleguide_example.testing_examples.apps.TestingExamplesConfig',
4141
'styleguide_example.integrations.apps.IntegrationsConfig',
4242
'styleguide_example.files.apps.FilesConfig',
43+
'styleguide_example.emails.apps.EmailsConfig',
4344
]
4445

4546
THIRD_PARTY_APPS = [
@@ -181,3 +182,4 @@
181182
from config.settings.sentry import * # noqa
182183

183184
from config.settings.files_and_storages import * # noqa
185+
from config.settings.email_sending import * # noqa

config/settings/email_sending.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from config.env import env, env_to_enum
2+
3+
from styleguide_example.emails.enums import EmailSendingStrategy
4+
5+
# local | mailtrap
6+
EMAIL_SENDING_STRATEGY = env_to_enum(
7+
EmailSendingStrategy,
8+
env("EMAIL_SENDING_STRATEGY", default="local")
9+
)
10+
11+
EMAIL_SENDING_FAILURE_TRIGGER = env.bool("EMAIL_SENDING_FAILURE_TRIGGER", default=False)
12+
EMAIL_SENDING_FAILURE_RATE = env.float("EMAIL_SENDING_FAILURE_RATE", default=0.2)
13+
14+
if EMAIL_SENDING_STRATEGY == EmailSendingStrategy.LOCAL:
15+
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
16+
17+
if EMAIL_SENDING_STRATEGY == EmailSendingStrategy.MAILTRAP:
18+
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
19+
EMAIL_HOST = env("MAILTRAP_EMAIL_HOST")
20+
EMAIL_HOST_USER = env("MAILTRAP_EMAIL_HOST_USER")
21+
EMAIL_HOST_PASSWORD = env("MAILTRAP_EMAIL_HOST_PASSWORD")
22+
EMAIL_PORT = env("MAILTRAP_EMAIL_PORT")

styleguide_example/emails/__init__.py

Whitespace-only changes.

styleguide_example/emails/admin.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.contrib import admin
2+
3+
from styleguide_example.emails.models import Email
4+
from styleguide_example.emails.services import email_send_all
5+
6+
7+
@admin.register(Email)
8+
class EmailAdmin(admin.ModelAdmin):
9+
list_display = ["id", "subject", "to", "status", "sent_at"]
10+
actions = ["send_email"]
11+
12+
def get_queryset(self, request):
13+
"""
14+
We want to defer the `html` and `plain_text` fields,
15+
since we are not showing them in the list & we don't need to fetch them.
16+
17+
Potentially, those fields can be quite heavy.
18+
"""
19+
queryset = super().get_queryset(request)
20+
return queryset.defer("html", "plain_text")
21+
22+
@admin.action(description="Send selected emails.")
23+
def send_email(self, request, queryset):
24+
email_send_all(queryset)

styleguide_example/emails/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class EmailsConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'styleguide_example.emails'

styleguide_example/emails/enums.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from enum import Enum
2+
3+
4+
class EmailSendingStrategy(Enum):
5+
LOCAL = "local"
6+
MAILTRAP = "mailtrap"
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 3.2.13 on 2022-05-17 07:34
2+
3+
from django.db import migrations, models
4+
import django.utils.timezone
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
initial = True
10+
11+
dependencies = [
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Email',
17+
fields=[
18+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('created_at', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
20+
('updated_at', models.DateTimeField(auto_now=True)),
21+
('status', models.CharField(choices=[('READY', 'Ready'), ('SENDING', 'Sending'), ('SENT', 'Sent'), ('FAILED', 'Failed')], db_index=True, default='READY', max_length=255)),
22+
('to', models.EmailField(max_length=254)),
23+
('subject', models.CharField(max_length=255)),
24+
('html', models.TextField()),
25+
('plain_text', models.TextField()),
26+
('sent_at', models.DateTimeField(blank=True, null=True)),
27+
],
28+
options={
29+
'abstract': False,
30+
},
31+
),
32+
]

styleguide_example/emails/migrations/__init__.py

Whitespace-only changes.

styleguide_example/emails/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.db import models
2+
3+
from styleguide_example.common.models import BaseModel
4+
5+
6+
class Email(BaseModel):
7+
class Status(models.TextChoices):
8+
READY = "READY", "Ready"
9+
SENDING = "SENDING", "Sending"
10+
SENT = "SENT", "Sent"
11+
FAILED = "FAILED", "Failed"
12+
13+
status = models.CharField(
14+
max_length=255,
15+
db_index=True,
16+
choices=Status.choices,
17+
default=Status.READY
18+
)
19+
20+
to = models.EmailField()
21+
subject = models.CharField(max_length=255)
22+
23+
html = models.TextField()
24+
plain_text = models.TextField()
25+
26+
sent_at = models.DateTimeField(blank=True, null=True)

styleguide_example/emails/services.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import random
2+
3+
from django.db import transaction
4+
from django.db.models.query import QuerySet
5+
from django.core.mail import EmailMultiAlternatives
6+
from django.utils import timezone
7+
from django.conf import settings
8+
9+
from styleguide_example.core.exceptions import ApplicationError
10+
11+
from styleguide_example.common.services import model_update
12+
13+
from styleguide_example.emails.models import Email
14+
from styleguide_example.emails.tasks import email_send as email_send_task
15+
16+
17+
@transaction.atomic
18+
def email_failed(email: Email) -> Email:
19+
if email.status != Email.Status.SENDING:
20+
raise ApplicationError(f"Cannot fail non-sending emails. Current status is {email.status}")
21+
22+
email, _ = model_update(
23+
instance=email,
24+
fields=["status"],
25+
data={
26+
"status": Email.Status.FAILED
27+
}
28+
)
29+
return email
30+
31+
32+
@transaction.atomic
33+
def email_send(email: Email) -> Email:
34+
if email.status != Email.Status.SENDING:
35+
raise ApplicationError(f"Cannot send non-ready emails. Current status is {email.status}")
36+
37+
if settings.EMAIL_SENDING_FAILURE_TRIGGER:
38+
failure_dice = random.uniform(0, 1)
39+
40+
if failure_dice <= settings.EMAIL_SENDING_FAILURE_RATE:
41+
raise ApplicationError("Email sending failure triggered.")
42+
43+
subject = email.subject
44+
from_email = "[email protected]"
45+
to = email.to
46+
47+
html = email.html
48+
plain_text = email.plain_text
49+
50+
msg = EmailMultiAlternatives(subject, plain_text, from_email, [to])
51+
msg.attach_alternative(html, "text/html")
52+
53+
msg.send()
54+
55+
email, _ = model_update(
56+
instance=email,
57+
fields=["status", "sent_at"],
58+
data={
59+
"status": Email.Status.SENT,
60+
"sent_at": timezone.now()
61+
}
62+
)
63+
return email
64+
65+
66+
def email_send_all(emails: QuerySet[Email]):
67+
"""
68+
This is a very specific service.
69+
70+
We don't want to decorate with @transaction.atomic,
71+
since we are executing updates, 1 by 1, in a separate atomic block,
72+
so we can trigger transaction.on_commit for each email, separately.
73+
"""
74+
for email in emails:
75+
with transaction.atomic():
76+
Email.objects.filter(id=email.id).update(
77+
status=Email.Status.SENDING
78+
)
79+
80+
# Create a closure, to capture the proper value of each id
81+
transaction.on_commit(
82+
(
83+
lambda email_id: lambda: email_send_task.delay(email_id)
84+
)(email.id)
85+
)

styleguide_example/emails/tasks.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from celery import shared_task
2+
from celery.utils.log import get_task_logger
3+
4+
from styleguide_example.emails.models import Email
5+
6+
7+
logger = get_task_logger(__name__)
8+
9+
10+
def _email_send_failure(self, exc, task_id, args, kwargs, einfo):
11+
email_id = args[0]
12+
email = Email.objects.get(id=email_id)
13+
14+
from styleguide_example.emails.services import email_failed
15+
16+
email_failed(email)
17+
18+
19+
@shared_task(bind=True, on_failure=_email_send_failure)
20+
def email_send(self, email_id):
21+
email = Email.objects.get(id=email_id)
22+
23+
from styleguide_example.emails.services import email_send
24+
25+
try:
26+
email_send(email)
27+
except Exception as exc:
28+
# https://docs.celeryq.dev/en/stable/userguide/tasks.html#retrying
29+
logger.warning(f"Exception occurred while sending email: {exc}")
30+
self.retry(exc=exc, countdown=5)

0 commit comments

Comments
 (0)