diff --git a/entity_emailer/interface.py b/entity_emailer/interface.py index cf83ff9..aae5baf 100644 --- a/entity_emailer/interface.py +++ b/entity_emailer/interface.py @@ -1,4 +1,5 @@ import sys +import traceback from datetime import datetime @@ -6,7 +7,7 @@ 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 @@ -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( @@ -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) @@ -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 diff --git a/entity_emailer/migrations/0003_email_exception.py b/entity_emailer/migrations/0003_email_exception.py new file mode 100644 index 0000000..c11943e --- /dev/null +++ b/entity_emailer/migrations/0003_email_exception.py @@ -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), + ), + ] diff --git a/entity_emailer/models.py b/entity_emailer/models.py index ff10a7a..f4290b2 100644 --- a/entity_emailer/models.py +++ b/entity_emailer/models.py @@ -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): diff --git a/entity_emailer/signals.py b/entity_emailer/signals.py index f6e307d..8283156 100644 --- a/entity_emailer/signals.py +++ b/entity_emailer/signals.py @@ -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']) diff --git a/entity_emailer/tests/interface_tests.py b/entity_emailer/tests/interface_tests.py index 9867906..d5bd1bd 100644 --- a/entity_emailer/tests/interface_tests.py +++ b/entity_emailer/tests/interface_tests.py @@ -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'), + ['
This is a test html email.
', '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): diff --git a/entity_emailer/version.py b/entity_emailer/version.py index b3ddbc4..7b344ec 100644 --- a/entity_emailer/version.py +++ b/entity_emailer/version.py @@ -1 +1 @@ -__version__ = '1.1.1' +__version__ = '1.1.2' diff --git a/release_notes.rst b/release_notes.rst index 3dc8faf..671679f 100644 --- a/release_notes.rst +++ b/release_notes.rst @@ -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 diff --git a/requirements/requirements-testing.txt b/requirements/requirements-testing.txt index 073bc4f..2608f0b 100644 --- a/requirements/requirements-testing.txt +++ b/requirements/requirements-testing.txt @@ -1,5 +1,5 @@ coverage -django-dynamic-fixture +django-dynamic-fixture<=2.0.0 django-nose flake8 freezegun diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 19454c8..9bbaff2 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -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 diff --git a/settings.py b/settings.py index 907e768..b57f97c 100644 --- a/settings.py +++ b/settings.py @@ -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 = { @@ -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, - }, - ] )