Skip to content

Commit 6ecae0c

Browse files
authored
Merge pull request #66 from jaredlewis/develop
Better exception handling
2 parents 2c37879 + 7172c60 commit 6ecae0c

File tree

10 files changed

+121
-14
lines changed

10 files changed

+121
-14
lines changed

entity_emailer/interface.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import sys
2+
import traceback
23

34
from datetime import datetime
45

56
from django.core import mail
67
from entity_event import context_loader
78

89
from entity_emailer.models import Email
9-
from entity_emailer.signals import pre_send
10+
from entity_emailer.signals import pre_send, email_exception
1011

1112
from entity_emailer.utils import get_medium, get_from_email_address, get_subscribed_email_addresses, \
1213
create_email_message, extract_email_subject_from_html_content
@@ -23,6 +24,7 @@ def send_unsent_scheduled_emails():
2324
Send out any scheduled emails that are unsent
2425
"""
2526

27+
# Get the emails that we need to send
2628
current_time = datetime.utcnow()
2729
email_medium = get_medium()
2830
to_send = Email.objects.filter(
@@ -32,15 +34,30 @@ def send_unsent_scheduled_emails():
3234
'event__source'
3335
).prefetch_related(
3436
'recipients'
37+
).order_by(
38+
'scheduled',
39+
'id'
3540
)
3641

3742
# Fetch the contexts of every event so that they may be rendered
3843
context_loader.load_contexts_and_renderers([e.event for e in to_send], [email_medium])
3944

45+
# Keep track of what emails we will be sending
4046
emails = []
47+
48+
# Loop over each email and generate the recipients, and message
49+
# and handle any exceptions that may occur
4150
for email in to_send:
51+
# Compute what email addresses we actually want to send this email to
4252
to_email_addresses = get_subscribed_email_addresses(email)
43-
if to_email_addresses:
53+
54+
# If there are no recipients we can just skip rendering
55+
if not to_email_addresses:
56+
continue
57+
58+
# If any exceptions occur we will catch the exception and store it as a reference
59+
# As well as fire off a signal with the error and mark the email as sent and errored
60+
try:
4461
# Render the email
4562
text_message, html_message = email.render(email_medium)
4663

@@ -64,9 +81,23 @@ def send_unsent_scheduled_emails():
6481

6582
# Add the email to the list of emails that need to be sent
6683
emails.append(message)
84+
except Exception as e:
85+
# Save the exception on the model
86+
email.exception = traceback.format_exc()
87+
email.save(update_fields=['exception'])
88+
89+
# Fire the email exception event
90+
email_exception.send(
91+
sender=Email,
92+
email=email,
93+
exception=e
94+
)
6795

96+
# Send all the emails that were generated properly
6897
connection = mail.get_connection()
6998
connection.send_messages(emails)
99+
100+
# Update the emails as sent
70101
to_send.update(sent=current_time)
71102

72103
@staticmethod
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.10 on 2020-02-10 18:37
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('entity_emailer', '0002_auto_20170919_1653'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='email',
15+
name='exception',
16+
field=models.TextField(default=None, null=True),
17+
),
18+
]

entity_emailer/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ class Email(models.Model):
6060
# allows for a different schedule to be set (to schedule the email for some
6161
# time in the future), which would not be possible with an auto_add_now=True.
6262
scheduled = models.DateTimeField(null=True, default=datetime.utcnow)
63+
64+
# The time that the email was actually sent, or None if the email is still waiting to be sent
6365
sent = models.DateTimeField(null=True, default=None)
6466

67+
# Any exception that occurred when attempting to send the email last
68+
exception = models.TextField(default=None, null=True)
69+
6570
objects = EmailManager()
6671

6772
def render(self, medium):

entity_emailer/signals.py

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

44
# An event that will be fired prior to an email being sent
55
pre_send = Signal(providing_args=['email', 'event', 'context', 'message'])
6+
7+
# An event that will be fired if an exception occurs when trying to send an email
8+
email_exception = Signal(providing_args=['email', 'exception'])

entity_emailer/tests/interface_tests.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,38 @@ def test_updates_times(self, render_mock, address_mock):
412412
sent_email = Email.objects.filter(sent__isnull=False)
413413
self.assertEqual(sent_email.count(), 1)
414414

415+
@patch('entity_emailer.interface.email_exception')
416+
@patch('entity_emailer.interface.get_subscribed_email_addresses')
417+
@patch.object(Event, 'render', spec_set=True)
418+
def test_exceptions(self, render_mock, address_mock, mock_email_exception):
419+
"""
420+
Test that we properly handle when an exception occurs
421+
"""
422+
423+
# Mock the render method to raise an exception that we should properly catch
424+
render_mock.side_effect = [
425+
Exception('test'),
426+
['<p>This is a test html email.</p>', 'This is a test text email.']
427+
]
428+
address_mock.return_value = ['[email protected]', '[email protected]']
429+
430+
# Create a test emails to send
431+
g_email(context={}, scheduled=datetime.min)
432+
g_email(context={
433+
'test': 'test'
434+
}, scheduled=datetime.min)
435+
436+
# Send the emails
437+
EntityEmailerInterface.send_unsent_scheduled_emails()
438+
439+
# Assert that both emails were marked as sent
440+
self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 2)
441+
442+
# Assert that one email raised an exception
443+
exception_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
444+
self.assertIsNotNone(exception_email)
445+
self.assertTrue('Exception: test' in exception_email.exception)
446+
415447

416448
class CreateEmailObjectTest(TestCase):
417449
def test_no_html(self):

entity_emailer/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.1.1'
1+
__version__ = '1.1.2'

release_notes.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Release Notes
22
=============
33

4+
v1.1.2
5+
------
6+
* Handle email render exceptions
7+
* Add `exception` field
8+
* Add `email_exception` signal
9+
410
v1.1.1
511
------
612
* Add `pre_send` signal

requirements/requirements-testing.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
coverage
2-
django-dynamic-fixture
2+
django-dynamic-fixture<=2.0.0
33
django-nose
44
flake8
55
freezegun

requirements/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
beautifulsoup4>=4.3.2
2-
Django>=2.0
2+
Django>=2.0,<3.0
33
django-db-mutex>=1.2.0
44
django-entity>=4.2.0
55
django-entity-event>=1.2.0

settings.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ def configure_settings():
1717
if test_db is None:
1818
db_config = {
1919
'ENGINE': 'django.db.backends.postgresql_psycopg2',
20-
'NAME': 'ambition_test',
20+
'NAME': 'ambition',
2121
'USER': 'postgres',
2222
'PASSWORD': '',
2323
'HOST': 'db',
24+
'TEST': {
25+
'NAME': 'test_entity_emailer'
26+
}
2427
}
2528
elif test_db == 'postgres':
2629
db_config = {
@@ -32,30 +35,39 @@ def configure_settings():
3235
raise RuntimeError('Unsupported test DB {0}'.format(test_db))
3336

3437
settings.configure(
35-
MIDDLEWARE_CLASSES=(),
3638
DATABASES={
3739
'default': db_config,
3840
},
3941
INSTALLED_APPS=(
4042
'db_mutex',
43+
'django.contrib.admin',
4144
'django.contrib.auth',
4245
'django.contrib.contenttypes',
46+
'django.contrib.messages',
4347
'django.contrib.sessions',
44-
'django.contrib.admin',
4548
'entity',
4649
'entity_event',
4750
'entity_emailer',
4851
'entity_emailer.tests',
4952
),
53+
MIDDLEWARE=(
54+
'django.contrib.sessions.middleware.SessionMiddleware',
55+
'django.contrib.auth.middleware.AuthenticationMiddleware',
56+
'django.contrib.messages.middleware.MessageMiddleware',
57+
),
5058
ROOT_URLCONF='entity_emailer.urls',
5159
DEFAULT_FROM_EMAIL='[email protected]',
5260
DEBUG=False,
61+
TEMPLATES=[{
62+
'BACKEND': 'django.template.backends.django.DjangoTemplates',
63+
'APP_DIRS': True,
64+
'OPTIONS': {
65+
'context_processors': [
66+
'django.contrib.auth.context_processors.auth',
67+
'django.contrib.messages.context_processors.messages'
68+
]
69+
}
70+
}],
5371
TEST_RUNNER='django_nose.NoseTestSuiteRunner',
5472
NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'],
55-
TEMPLATES=[
56-
{
57-
'BACKEND': 'django.template.backends.django.DjangoTemplates',
58-
'APP_DIRS': True,
59-
},
60-
]
6173
)

0 commit comments

Comments
 (0)