Skip to content
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
35 changes: 33 additions & 2 deletions entity_emailer/interface.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import sys
import traceback

from datetime import datetime

from django.core import mail
from entity_event import context_loader

from entity_emailer.models import Email
from entity_emailer.signals import pre_send
from entity_emailer.signals import pre_send, email_exception

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

# Get the emails that we need to send
current_time = datetime.utcnow()
email_medium = get_medium()
to_send = Email.objects.filter(
Expand All @@ -32,15 +34,30 @@ def send_unsent_scheduled_emails():
'event__source'
).prefetch_related(
'recipients'
).order_by(
'scheduled',
'id'
)

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

# Keep track of what emails we will be sending
emails = []

# Loop over each email and generate the recipients, and message
# and handle any exceptions that may occur
for email in to_send:
# Compute what email addresses we actually want to send this email to
to_email_addresses = get_subscribed_email_addresses(email)
if to_email_addresses:

# If there are no recipients we can just skip rendering
if not to_email_addresses:
continue

# If any exceptions occur we will catch the exception and store it as a reference
# As well as fire off a signal with the error and mark the email as sent and errored
try:
# Render the email
text_message, html_message = email.render(email_medium)

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

# Add the email to the list of emails that need to be sent
emails.append(message)
except Exception as e:
# Save the exception on the model
email.exception = traceback.format_exc()
email.save(update_fields=['exception'])

# Fire the email exception event
email_exception.send(
sender=Email,
email=email,
exception=e
)

# Send all the emails that were generated properly
connection = mail.get_connection()
connection.send_messages(emails)

# Update the emails as sent
to_send.update(sent=current_time)

@staticmethod
Expand Down
18 changes: 18 additions & 0 deletions entity_emailer/migrations/0003_email_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-02-10 18:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('entity_emailer', '0002_auto_20170919_1653'),
]

operations = [
migrations.AddField(
model_name='email',
name='exception',
field=models.TextField(default=None, null=True),
),
]
5 changes: 5 additions & 0 deletions entity_emailer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,13 @@ class Email(models.Model):
# allows for a different schedule to be set (to schedule the email for some
# time in the future), which would not be possible with an auto_add_now=True.
scheduled = models.DateTimeField(null=True, default=datetime.utcnow)

# The time that the email was actually sent, or None if the email is still waiting to be sent
sent = models.DateTimeField(null=True, default=None)

# Any exception that occurred when attempting to send the email last
exception = models.TextField(default=None, null=True)

objects = EmailManager()

def render(self, medium):
Expand Down
3 changes: 3 additions & 0 deletions entity_emailer/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

# An event that will be fired prior to an email being sent
pre_send = Signal(providing_args=['email', 'event', 'context', 'message'])

# An event that will be fired if an exception occurs when trying to send an email
email_exception = Signal(providing_args=['email', 'exception'])
32 changes: 32 additions & 0 deletions entity_emailer/tests/interface_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,38 @@ def test_updates_times(self, render_mock, address_mock):
sent_email = Email.objects.filter(sent__isnull=False)
self.assertEqual(sent_email.count(), 1)

@patch('entity_emailer.interface.email_exception')
@patch('entity_emailer.interface.get_subscribed_email_addresses')
@patch.object(Event, 'render', spec_set=True)
def test_exceptions(self, render_mock, address_mock, mock_email_exception):
"""
Test that we properly handle when an exception occurs
"""

# Mock the render method to raise an exception that we should properly catch
render_mock.side_effect = [
Exception('test'),
['<p>This is a test html email.</p>', 'This is a test text email.']
]
address_mock.return_value = ['test1@example.com', 'test2@example.com']

# Create a test emails to send
g_email(context={}, scheduled=datetime.min)
g_email(context={
'test': 'test'
}, scheduled=datetime.min)

# Send the emails
EntityEmailerInterface.send_unsent_scheduled_emails()

# Assert that both emails were marked as sent
self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 2)

# Assert that one email raised an exception
exception_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
self.assertIsNotNone(exception_email)
self.assertTrue('Exception: test' in exception_email.exception)


class CreateEmailObjectTest(TestCase):
def test_no_html(self):
Expand Down
2 changes: 1 addition & 1 deletion entity_emailer/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '1.1.1'
__version__ = '1.1.2'
6 changes: 6 additions & 0 deletions release_notes.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
Release Notes
=============

v1.1.2
------
* Handle email render exceptions
* Add `exception` field
* Add `email_exception` signal

v1.1.1
------
* Add `pre_send` signal
Expand Down
2 changes: 1 addition & 1 deletion requirements/requirements-testing.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
coverage
django-dynamic-fixture
django-dynamic-fixture<=2.0.0
django-nose
flake8
freezegun
Expand Down
2 changes: 1 addition & 1 deletion requirements/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
beautifulsoup4>=4.3.2
Django>=2.0
Django>=2.0,<3.0
django-db-mutex>=1.2.0
django-entity>=4.2.0
django-entity-event>=1.2.0
Expand Down
30 changes: 21 additions & 9 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@ def configure_settings():
if test_db is None:
db_config = {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'ambition_test',
'NAME': 'ambition',
'USER': 'postgres',
'PASSWORD': '',
'HOST': 'db',
'TEST': {
'NAME': 'test_entity_emailer'
}
}
elif test_db == 'postgres':
db_config = {
Expand All @@ -32,30 +35,39 @@ def configure_settings():
raise RuntimeError('Unsupported test DB {0}'.format(test_db))

settings.configure(
MIDDLEWARE_CLASSES=(),
DATABASES={
'default': db_config,
},
INSTALLED_APPS=(
'db_mutex',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.messages',
'django.contrib.sessions',
'django.contrib.admin',
'entity',
'entity_event',
'entity_emailer',
'entity_emailer.tests',
),
MIDDLEWARE=(
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
),
ROOT_URLCONF='entity_emailer.urls',
DEFAULT_FROM_EMAIL='test@example.com',
DEBUG=False,
TEMPLATES=[{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages'
]
}
}],
TEST_RUNNER='django_nose.NoseTestSuiteRunner',
NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'],
TEMPLATES=[
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
},
]
)