Skip to content

Commit 580e6d2

Browse files
committed
feat: EmailChannel.send_now will try fallback templates (closes #21)
1 parent ab42966 commit 580e6d2

File tree

4 files changed

+97
-40
lines changed

4 files changed

+97
-40
lines changed

docs/customizing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ Customize email templates by creating these files in your templates directory:
6767
- `notifications/email/realtime/{notification_type}.html`
6868
- `notifications/email/realtime/{notification_type}.txt`
6969

70+
If notification-type specific templates are not found, the system will fall back to:
71+
72+
- `notifications/email/realtime/subject.txt`
73+
- `notifications/email/realtime/body.html`
74+
- `notifications/email/realtime/body.txt`
75+
76+
This allows you to create generic templates that work for all notification types while still having the flexibility to create specific templates for certain types.
77+
7078
### Digest emails
7179

7280
- `notifications/email/digest/subject.txt`

generic_notifications/channels.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.core.mail import send_mail as django_send_mail
77
from django.db.models import QuerySet
88
from django.template.defaultfilters import pluralize
9-
from django.template.loader import render_to_string
9+
from django.template.loader import render_to_string, select_template
1010

1111
from .frequencies import BaseFrequency
1212
from .registry import registry
@@ -144,27 +144,39 @@ def send_now(self, notification: "Notification") -> None:
144144
"target": notification.target,
145145
}
146146

147-
subject_template = f"notifications/email/realtime/{notification.notification_type}_subject.txt"
148-
html_template = f"notifications/email/realtime/{notification.notification_type}.html"
149-
text_template = f"notifications/email/realtime/{notification.notification_type}.txt"
147+
subject_templates = [
148+
f"notifications/email/realtime/{notification.notification_type}_subject.txt",
149+
"notifications/email/realtime/subject.txt",
150+
]
151+
html_templates = [
152+
f"notifications/email/realtime/{notification.notification_type}.html",
153+
"notifications/email/realtime/body.html",
154+
]
155+
text_templates = [
156+
f"notifications/email/realtime/{notification.notification_type}.txt",
157+
"notifications/email/realtime/body.txt",
158+
]
150159

151160
# Load subject
152161
try:
153-
subject = render_to_string(subject_template, context).strip()
162+
subject_template = select_template(subject_templates)
163+
subject = subject_template.render(context).strip()
154164
except Exception:
155165
# Fallback to notification's subject
156166
subject = notification.get_subject()
157167

158168
# Load HTML message
159169
try:
160-
html_message = render_to_string(html_template, context)
170+
html_template = select_template(html_templates)
171+
html_message = html_template.render(context)
161172
except Exception:
162173
html_message = None
163174

164175
# Load plain text message
165176
text_message: str
166177
try:
167-
text_message = render_to_string(text_template, context)
178+
text_template = select_template(text_templates)
179+
text_message = text_template.render(context)
168180
except Exception:
169181
# Fallback to notification's text with URL if available
170182
text_message = notification.get_text()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "django-generic-notifications"
3-
version = "2.2.0"
3+
version = "2.3.0"
44
description = "A flexible, multi-channel notification system for Django applications with built-in support for email digests, user preferences, and extensible delivery channels."
55
authors = [
66
{name = "Kevin Renskers", email = "[email protected]"},

tests/test_channels.py

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from django.contrib.auth import get_user_model
55
from django.core import mail
6+
from django.template import TemplateDoesNotExist
67
from django.test import TestCase, override_settings
78

89
from generic_notifications.channels import BaseChannel, EmailChannel
@@ -207,19 +208,29 @@ def test_send_now_uses_get_methods(self):
207208
self.assertEqual(email.body, "")
208209

209210
@override_settings(DEFAULT_FROM_EMAIL="[email protected]")
210-
@patch("generic_notifications.channels.render_to_string")
211-
def test_send_now_with_template(self, mock_render):
212-
# Set up mock to return different values for different templates
213-
def mock_render_side_effect(template_name, context):
214-
if template_name.endswith("_subject.txt"):
215-
return "Test Subject"
216-
elif template_name.endswith(".html"):
217-
return "<html>Test HTML</html>"
218-
elif template_name.endswith(".txt"):
219-
return "Test plain text"
220-
return ""
221-
222-
mock_render.side_effect = mock_render_side_effect
211+
@patch("generic_notifications.channels.select_template")
212+
def test_send_now_with_template(self, mock_select):
213+
# Create mock template objects that return the expected content
214+
class MockTemplate:
215+
def __init__(self, content):
216+
self.content = content
217+
218+
def render(self, context):
219+
return self.content
220+
221+
# Set up mock to return different templates based on the template list
222+
def mock_select_side_effect(template_list):
223+
# Check the first template in the list to determine what to return
224+
first_template = template_list[0]
225+
if first_template.endswith("_subject.txt"):
226+
return MockTemplate("Test Subject")
227+
elif first_template.endswith(".html"):
228+
return MockTemplate("<html>Test HTML</html>")
229+
elif first_template.endswith(".txt"):
230+
return MockTemplate("Test plain text")
231+
return MockTemplate("")
232+
233+
mock_select.side_effect = mock_select_side_effect
223234

224235
notification = create_notification_with_channels(
225236
user=self.user,
@@ -231,25 +242,7 @@ def mock_render_side_effect(template_name, context):
231242
channel = EmailChannel()
232243
channel.send_now(notification)
233244

234-
# Check templates were rendered (subject, HTML, then text)
235-
self.assertEqual(mock_render.call_count, 3)
236-
237-
# Check subject template call (first)
238-
subject_call = mock_render.call_args_list[0]
239-
self.assertEqual(subject_call[0][0], "notifications/email/realtime/test_type_subject.txt")
240-
self.assertEqual(subject_call[0][1]["notification"], notification)
241-
242-
# Check HTML template call (second)
243-
html_call = mock_render.call_args_list[1]
244-
self.assertEqual(html_call[0][0], "notifications/email/realtime/test_type.html")
245-
self.assertEqual(html_call[0][1]["notification"], notification)
246-
247-
# Check text template call (third)
248-
text_call = mock_render.call_args_list[2]
249-
self.assertEqual(text_call[0][0], "notifications/email/realtime/test_type.txt")
250-
self.assertEqual(text_call[0][1]["notification"], notification)
251-
252-
# Check email was sent with correct subject
245+
# Check email was sent with correct subject and text
253246
self.assertEqual(len(mail.outbox), 1)
254247
email = mail.outbox[0]
255248
self.assertEqual(email.subject, "Test Subject")
@@ -258,6 +251,50 @@ def mock_render_side_effect(template_name, context):
258251
self.assertEqual(len(email.alternatives), 1) # type: ignore
259252
self.assertEqual(email.alternatives[0][0], "<html>Test HTML</html>") # type: ignore
260253

254+
@override_settings(DEFAULT_FROM_EMAIL="[email protected]")
255+
@patch("generic_notifications.channels.select_template")
256+
def test_send_now_with_fallback_templates(self, mock_select):
257+
"""Test that fallback templates are used when notification-specific templates don't exist."""
258+
259+
# Create mock template objects
260+
class MockTemplate:
261+
def __init__(self, content):
262+
self.content = content
263+
264+
def render(self, context):
265+
return self.content
266+
267+
# Set up mock to simulate using fallback templates (second in the list)
268+
def mock_select_side_effect(template_list):
269+
if "subject.txt" in template_list[1]:
270+
return MockTemplate("Fallback Subject")
271+
elif "body.html" in template_list[1]:
272+
return MockTemplate("<html>Fallback HTML Body</html>")
273+
elif "body.txt" in template_list[1]:
274+
return MockTemplate("Fallback Text Body")
275+
raise TemplateDoesNotExist("No templates found")
276+
277+
mock_select.side_effect = mock_select_side_effect
278+
279+
notification = create_notification_with_channels(
280+
user=self.user,
281+
notification_type="new_type",
282+
subject="Original Subject",
283+
text="Original message",
284+
)
285+
286+
channel = EmailChannel()
287+
channel.send_now(notification)
288+
289+
# Check email was sent with fallback content
290+
self.assertEqual(len(mail.outbox), 1)
291+
email = mail.outbox[0]
292+
self.assertEqual(email.subject, "Fallback Subject")
293+
self.assertEqual(email.body, "Fallback Text Body")
294+
# HTML version should be in alternatives
295+
self.assertEqual(len(email.alternatives), 1)
296+
self.assertEqual(email.alternatives[0][0], "<html>Fallback HTML Body</html>")
297+
261298
@override_settings(DEFAULT_FROM_EMAIL="[email protected]")
262299
def test_send_now_template_error_fallback(self):
263300
notification = create_notification_with_channels(

0 commit comments

Comments
 (0)