Skip to content

Commit 7bb569a

Browse files
authored
Implement Draft SentEmail (#4785)
1 parent 4e02d41 commit 7bb569a

5 files changed

Lines changed: 281 additions & 4 deletions

File tree

backend/notifications/admin/admins.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import functools
2+
import logging
3+
from django.db import transaction
4+
from django.utils import timezone
15
from typing import Any
26
from django.http import HttpResponseRedirect
37
from django.http.request import HttpRequest
@@ -15,6 +19,10 @@
1519

1620
from notifications.models import EmailTemplate, SentEmail, SentEmailEvent
1721
from django.forms import Textarea
22+
from django.db.models import QuerySet
23+
from notifications.tasks import send_pending_email
24+
25+
logger = logging.getLogger(__name__)
1826

1927

2028
class SentEmailEventInline(admin.TabularInline):
@@ -111,6 +119,17 @@ def get_view_on_site_url(self, obj) -> str | None:
111119
return reverse("admin:view-email-template", args=(obj.id,))
112120

113121

122+
def _submit_emails_for_sending(sent_emails_ids: list[int]) -> None:
123+
for sent_email_id in sent_emails_ids:
124+
try:
125+
send_pending_email.delay(sent_email_id)
126+
except Exception:
127+
logger.exception(
128+
"Could not queue sent_email_id=%s, leaving it pending",
129+
sent_email_id,
130+
)
131+
132+
114133
@admin.register(SentEmail)
115134
class SentEmailAdmin(admin.ModelAdmin):
116135
list_display = [
@@ -146,6 +165,7 @@ class SentEmailAdmin(admin.ModelAdmin):
146165
ordering = ["-sent_at"]
147166
autocomplete_fields = ["recipient"]
148167
inlines = [SentEmailEventInline]
168+
actions = ["send_email"]
149169

150170
def email_template_display_name(self, obj):
151171
if obj.email_template.is_custom:
@@ -182,3 +202,29 @@ def get_queryset(self, request: HttpRequest) -> Any:
182202
qs = qs.filter(email_template__is_system_template=False)
183203

184204
return qs
205+
206+
@transaction.atomic
207+
def send_email(self, request: HttpRequest, queryset: QuerySet[SentEmail]):
208+
affected_emails_ids = list(
209+
queryset.filter(
210+
status__in=[
211+
SentEmail.Status.draft,
212+
SentEmail.Status.pending,
213+
SentEmail.Status.failed,
214+
]
215+
)
216+
.select_for_update(skip_locked=True)
217+
.values_list("id", flat=True)
218+
)
219+
220+
SentEmail.objects.filter(id__in=affected_emails_ids).update(
221+
status=SentEmail.Status.pending,
222+
modified=timezone.now(),
223+
)
224+
225+
transaction.on_commit(
226+
functools.partial(_submit_emails_for_sending, affected_emails_ids)
227+
)
228+
self.message_user(
229+
request, f"Emails queued for sending: {len(affected_emails_ids)}"
230+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.8 on 2026-08-15 10:04
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('notifications', '0022_remove_grant_voucher_code_template_identifier'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='sentemail',
15+
name='status',
16+
field=models.CharField(choices=[('draft', 'Draft'), ('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed')], db_index=True, default='pending', max_length=200, verbose_name='status'),
17+
),
18+
]

backend/notifications/models.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,18 +211,17 @@ def render(
211211
placeholders=placeholders,
212212
)
213213

214-
def send_email(
214+
def _prepare_email(
215215
self,
216216
*,
217+
status: "SentEmail.Status",
217218
recipient: User | None = None,
218219
recipient_email: str | None = None,
219220
placeholders: dict = None,
220221
):
221222
if not recipient and not recipient_email:
222223
raise ValueError("Either recipient or recipient_email must be provided")
223224

224-
from notifications.tasks import send_pending_email
225-
226225
recipient_email = recipient_email or recipient.email
227226

228227
placeholders = placeholders or {}
@@ -236,7 +235,8 @@ def send_email(
236235
or settings.DEFAULT_FROM_EMAIL
237236
)
238237

239-
sent_email = SentEmail.objects.create(
238+
return SentEmail.objects.create(
239+
status=status,
240240
email_template=self,
241241
conference=self.conference,
242242
from_email=from_email,
@@ -252,6 +252,35 @@ def send_email(
252252
bcc_addresses=self.bcc_addresses,
253253
)
254254

255+
def draft_email(
256+
self,
257+
*,
258+
recipient: User | None = None,
259+
recipient_email: str | None = None,
260+
placeholders: dict = None,
261+
):
262+
return self._prepare_email(
263+
status=SentEmail.Status.draft,
264+
recipient=recipient,
265+
recipient_email=recipient_email,
266+
placeholders=placeholders,
267+
)
268+
269+
def send_email(
270+
self,
271+
*,
272+
recipient: User | None = None,
273+
recipient_email: str | None = None,
274+
placeholders: dict = None,
275+
):
276+
from notifications.tasks import send_pending_email
277+
278+
sent_email = self._prepare_email(
279+
status=SentEmail.Status.pending,
280+
recipient=recipient,
281+
recipient_email=recipient_email,
282+
placeholders=placeholders,
283+
)
255284
transaction.on_commit(lambda: send_pending_email.delay(sent_email.id))
256285

257286
@property
@@ -284,6 +313,7 @@ class Meta:
284313

285314
class SentEmail(TimeStampedModel):
286315
class Status(models.TextChoices):
316+
draft = "draft", _("Draft")
287317
pending = "pending", _("Pending")
288318
sent = "sent", _("Sent")
289319
failed = "failed", _("Failed")

backend/notifications/tests/admin/test_sent_email_admin.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,102 @@ def test_email_template_display_name():
9898
visible_name = admin.email_template_display_name(sent_email)
9999

100100
assert visible_name == sent_email.email_template.get_identifier_display()
101+
102+
103+
def test_send_email_action(rf, admin_user, django_capture_on_commit_callbacks, mocker):
104+
mock_send_pending_email = mocker.patch(
105+
"notifications.admin.admins.send_pending_email.delay"
106+
)
107+
admin = SentEmailAdmin(
108+
model=SentEmail,
109+
admin_site=AdminSite(),
110+
)
111+
admin.message_user = mocker.Mock()
112+
113+
request = rf.post("/")
114+
request.user = admin_user
115+
116+
draft_email_1 = SentEmailFactory(status=SentEmail.Status.draft)
117+
draft_email_2 = SentEmailFactory(status=SentEmail.Status.draft)
118+
pending_email = SentEmailFactory(status=SentEmail.Status.pending)
119+
sent_email = SentEmailFactory(status=SentEmail.Status.sent)
120+
failed_email = SentEmailFactory(status=SentEmail.Status.failed)
121+
122+
with django_capture_on_commit_callbacks(execute=True):
123+
admin.send_email(request, SentEmail.objects.all())
124+
125+
# drafts, pending and failed emails are all (re)queued for sending
126+
queued_ids = {call.args[0] for call in mock_send_pending_email.call_args_list}
127+
assert queued_ids == {
128+
draft_email_1.id,
129+
draft_email_2.id,
130+
pending_email.id,
131+
failed_email.id,
132+
}
133+
assert mock_send_pending_email.call_count == 4
134+
135+
for email in (draft_email_1, draft_email_2, pending_email, failed_email):
136+
email.refresh_from_db()
137+
assert email.status == SentEmail.Status.pending
138+
139+
# already sent emails are never touched
140+
sent_email.refresh_from_db()
141+
assert sent_email.status == SentEmail.Status.sent
142+
143+
admin.message_user.assert_called_once_with(request, "Emails queued for sending: 4")
144+
145+
146+
def test_send_email_action_with_a_status_filtered_queryset(
147+
rf, admin_user, django_capture_on_commit_callbacks, mocker
148+
):
149+
"""The changelist queryset carries the active list_filter, so the action
150+
receives a queryset already narrowed to a single status."""
151+
mock_send_pending_email = mocker.patch(
152+
"notifications.admin.admins.send_pending_email.delay"
153+
)
154+
admin = SentEmailAdmin(
155+
model=SentEmail,
156+
admin_site=AdminSite(),
157+
)
158+
admin.message_user = mocker.Mock()
159+
160+
request = rf.post("/")
161+
request.user = admin_user
162+
163+
draft_email = SentEmailFactory(status=SentEmail.Status.draft)
164+
165+
with django_capture_on_commit_callbacks(execute=True):
166+
admin.send_email(
167+
request, SentEmail.objects.filter(status=SentEmail.Status.draft)
168+
)
169+
170+
mock_send_pending_email.assert_called_once_with(draft_email.id)
171+
172+
draft_email.refresh_from_db()
173+
assert draft_email.status == SentEmail.Status.pending
174+
175+
176+
def test_send_email_action_keeps_queueing_after_a_broker_failure(
177+
rf, admin_user, django_capture_on_commit_callbacks, mocker
178+
):
179+
mock_send_pending_email = mocker.patch(
180+
"notifications.admin.admins.send_pending_email.delay",
181+
side_effect=[Exception("broker is down"), None],
182+
)
183+
admin = SentEmailAdmin(
184+
model=SentEmail,
185+
admin_site=AdminSite(),
186+
)
187+
admin.message_user = mocker.Mock()
188+
189+
request = rf.post("/")
190+
request.user = admin_user
191+
192+
SentEmailFactory(status=SentEmail.Status.draft)
193+
SentEmailFactory(status=SentEmail.Status.draft)
194+
195+
with django_capture_on_commit_callbacks(execute=True):
196+
admin.send_email(request, SentEmail.objects.all())
197+
198+
# the first publish blowing up must not strand the remaining emails
199+
assert mock_send_pending_email.call_count == 2

backend/notifications/tests/test_models.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,87 @@ def test_email_template_system_templates_filter():
264264

265265
assert EmailTemplate.objects.system_templates().count() == 1
266266
assert EmailTemplate.objects.system_templates().first().id == email_template.id
267+
268+
269+
def test_draft_email_template_to_recipient_email(
270+
mocker, django_capture_on_commit_callbacks
271+
):
272+
email_template = EmailTemplateFactory(
273+
subject="Subject {{ test }}",
274+
body="Body {{ test }}",
275+
preview_text="Preview {{ test }}",
276+
reply_to="replyto@example.com",
277+
)
278+
279+
mock_send_pending_email = mocker.patch(
280+
"notifications.tasks.send_pending_email.delay"
281+
)
282+
283+
with django_capture_on_commit_callbacks(execute=True):
284+
sent_email = email_template.draft_email(
285+
recipient_email="example@example.com",
286+
placeholders={
287+
"test": "abc",
288+
},
289+
)
290+
291+
# drafts are only queued once the action in the admin is used
292+
mock_send_pending_email.assert_not_called()
293+
294+
assert sent_email.status == SentEmail.Status.draft
295+
assert sent_email.sent_at is None
296+
assert sent_email.recipient is None
297+
assert sent_email.recipient_email == "example@example.com"
298+
299+
assert sent_email.subject == "Subject abc"
300+
assert "Body abc" in sent_email.body
301+
assert sent_email.preview_text == "Preview abc"
302+
assert sent_email.reply_to == "replyto@example.com"
303+
304+
305+
def test_draft_email_template_to_recipient_user():
306+
user = UserFactory()
307+
email_template = EmailTemplateFactory(
308+
subject="Subject {{ test }}",
309+
body="Body {{ test }}",
310+
preview_text="Preview {{ test }}",
311+
)
312+
313+
sent_email = email_template.draft_email(
314+
recipient=user,
315+
placeholders={
316+
"test": "abc",
317+
},
318+
)
319+
320+
assert sent_email.status == SentEmail.Status.draft
321+
assert sent_email.recipient == user
322+
assert sent_email.recipient_email == user.email
323+
assert sent_email.subject == "Subject abc"
324+
325+
326+
def test_draft_email_requires_a_recipient():
327+
email_template = EmailTemplateFactory()
328+
329+
with pytest.raises(
330+
ValueError, match="Either recipient or recipient_email must be provided"
331+
):
332+
email_template.draft_email(placeholders={})
333+
334+
assert not SentEmail.objects.exists()
335+
336+
337+
def test_send_email_creates_a_pending_email(mocker, django_capture_on_commit_callbacks):
338+
email_template = EmailTemplateFactory()
339+
340+
mock_send_pending_email = mocker.patch(
341+
"notifications.tasks.send_pending_email.delay"
342+
)
343+
344+
with django_capture_on_commit_callbacks(execute=True):
345+
email_template.send_email(recipient_email="example@example.com")
346+
347+
sent_email = SentEmail.objects.get(email_template=email_template)
348+
349+
assert sent_email.status == SentEmail.Status.pending
350+
mock_send_pending_email.assert_called_once_with(sent_email.id)

0 commit comments

Comments
 (0)