From f1661c29204b45a99a20402b6e87931087b49468 Mon Sep 17 00:00:00 2001 From: Todd Dembrey <todd.dembrey@torchbox.com> Date: Tue, 31 Jul 2018 17:43:39 +0100 Subject: [PATCH] Track the output of any webhook received into anymail --- opentech/apply/activity/__init__.py | 1 + opentech/apply/activity/apps.py | 5 +- .../migrations/0008_message_external_id.py | 18 +++++ opentech/apply/activity/models.py | 1 + opentech/apply/activity/signals.py | 12 ++++ opentech/apply/activity/tasks.py | 6 +- opentech/apply/activity/tests/factories.py | 15 ++++- .../apply/activity/tests/test_messaging.py | 67 ++++++++++++++++--- opentech/apply/activity/urls.py | 9 +++ opentech/apply/urls.py | 1 + opentech/settings/base.py | 3 + 11 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 opentech/apply/activity/migrations/0008_message_external_id.py create mode 100644 opentech/apply/activity/signals.py create mode 100644 opentech/apply/activity/urls.py diff --git a/opentech/apply/activity/__init__.py b/opentech/apply/activity/__init__.py index e69de29bb..1b5686b41 100644 --- a/opentech/apply/activity/__init__.py +++ b/opentech/apply/activity/__init__.py @@ -0,0 +1 @@ +default_app_config = 'opentech.apply.activity.apps.ActivityConfig' diff --git a/opentech/apply/activity/apps.py b/opentech/apply/activity/apps.py index 34cfcc035..7e1f04ff8 100644 --- a/opentech/apply/activity/apps.py +++ b/opentech/apply/activity/apps.py @@ -2,4 +2,7 @@ from django.apps import AppConfig class ActivityConfig(AppConfig): - name = 'activity' + name = 'opentech.apply.activity' + + def ready(self): + from . import signals diff --git a/opentech/apply/activity/migrations/0008_message_external_id.py b/opentech/apply/activity/migrations/0008_message_external_id.py new file mode 100644 index 000000000..6e2d8d540 --- /dev/null +++ b/opentech/apply/activity/migrations/0008_message_external_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.2 on 2018-08-01 09:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0007_message_status'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='external_id', + field=models.CharField(blank=True, max_length=75, null=True), + ), + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index caa8cd54a..e143e8f1c 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -123,6 +123,7 @@ class Message(models.Model): recipient = models.CharField(max_length=250) event = models.ForeignKey(Event, on_delete=models.CASCADE) status = models.TextField() + external_id = models.CharField(max_length=75, null=True, blank=True) # Stores the id of the object from an external system def update_status(self, status): if status: diff --git a/opentech/apply/activity/signals.py b/opentech/apply/activity/signals.py new file mode 100644 index 000000000..11794ddc1 --- /dev/null +++ b/opentech/apply/activity/signals.py @@ -0,0 +1,12 @@ +from anymail.signals import tracking +from django.dispatch import receiver + +from .models import Message + + +@receiver(tracking) +def handle_event(sender, event, esp_name, **kwargs): + status = 'Webhook received: {} [{}]'.format(event.event_type, event.timestamp) + if event.description: + status += ' ' + event.description + Message.objects.get(external_id=event.message_id).update_status(status) diff --git a/opentech/apply/activity/tasks.py b/opentech/apply/activity/tasks.py index 937af737c..eebac11d1 100644 --- a/opentech/apply/activity/tasks.py +++ b/opentech/apply/activity/tasks.py @@ -43,6 +43,8 @@ def send_mail_task(**kwargs): @app.task -def update_message_status(response, message): +def update_message_status(response, message_id): from .models import Message - Message.objects.get(id=message).update_status(response['status']) + message = Message.objects.get(id=message_id) + message.external_id = response['id'] + message.update_status(response['status']) diff --git a/opentech/apply/activity/tests/factories.py b/opentech/apply/activity/tests/factories.py index 89c491bff..c1b6c22ef 100644 --- a/opentech/apply/activity/tests/factories.py +++ b/opentech/apply/activity/tests/factories.py @@ -1,6 +1,8 @@ +import uuid + import factory -from opentech.apply.activity.models import Activity, Event, INTERNAL, MESSAGES, REVIEWER +from opentech.apply.activity.models import Activity, Event, INTERNAL, Message, MESSAGES, REVIEWER from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory from opentech.apply.users.tests.factories import UserFactory @@ -29,3 +31,14 @@ class EventFactory(factory.DjangoModelFactory): type = factory.Iterator([choice[0] for choice in MESSAGES.choices()]) by = factory.SubFactory(UserFactory) submission = factory.SubFactory(ApplicationSubmissionFactory) + + +class MessageFactory(factory.DjangoModelFactory): + class Meta: + model = Message + + type = 'Email' + content = factory.Faker('sentence') + recipient = factory.Faker('email') + event = factory.SubFactory(EventFactory) + external_id = factory.LazyFunction(lambda : '<{}>'.format(uuid.uuid4())) diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index 1ad905581..635c5bc28 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import json from unittest.mock import Mock, patch @@ -20,7 +22,7 @@ from ..messaging import ( MESSAGES, SlackAdapter, ) -from .factories import CommentFactory, EventFactory +from .factories import CommentFactory, EventFactory, MessageFactory class TestAdapter(AdapterBase): @@ -354,6 +356,21 @@ class TestEmailAdapter(AdapterMixin, TestCase): ) class TestAnyMailBehaviour(AdapterMixin, TestCase): adapter = EmailAdapter() + TEST_API_KEY = 'TEST_API_KEY' + + # from: https://github.com/anymail/django-anymail/blob/7d8dbdace92d8addfcf0a517be0aaf481da11952/tests/test_mailgun_webhooks.py#L19 + def mailgun_sign(self, data, api_key=TEST_API_KEY): + """Add a Mailgun webhook signature to data dict""" + # Modifies the dict in place + data.setdefault('timestamp', '1234567890') + data.setdefault('token', '1234567890abcdef1234567890abcdef') + data['signature'] = hmac.new( + key=api_key.encode('ascii'), + msg='{timestamp}{token}'.format(**data).encode('ascii'), + digestmod=hashlib.sha256, + ).hexdigest() + + return data def test_email_new_submission(self): submission = ApplicationSubmissionFactory() @@ -361,11 +378,43 @@ class TestAnyMailBehaviour(AdapterMixin, TestCase): self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, [submission.user.email]) - self.assertEqual(Message.objects.first().status, 'sent') - - def test_email_new_submission(self): - submission = ApplicationSubmissionFactory() - self.adapter_process(MESSAGES.NEW_SUBMISSION, submission=submission) - - self.assertEqual(len(mail.outbox), 1) - self.assertEqual(mail.outbox[0].to, [submission.user.email]) + message = Message.objects.first() + self.assertEqual(message.status, 'sent') + # Anymail test Backend uses the index of the email as id: '0' + self.assertEqual(message.external_id, '0') + + @override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY) + def test_webhook_updates_status(self): + message = MessageFactory() + response = self.client.post( + '/activity/anymail/mailgun/tracking/', + data=self.mailgun_sign({ + 'event': 'delivered', + 'Message-Id': message.external_id + }), + secure=True, + json=True, + ) + self.assertEqual(response.status_code, 200) + message.refresh_from_db() + self.assertTrue('delivered' in message.status) + + @override_settings(ANYMAIL_MAILGUN_API_KEY=TEST_API_KEY) + def test_webhook_adds_reject_reason(self): + message = MessageFactory() + response = self.client.post( + '/activity/anymail/mailgun/tracking/', + data=self.mailgun_sign({ + 'event': 'dropped', + 'reason': 'hardfail', + 'code': 607, + 'description': 'Marked as spam', + 'Message-Id': message.external_id + }), + secure=True, + json=True, + ) + self.assertEqual(response.status_code, 200) + message.refresh_from_db() + self.assertTrue('rejected' in message.status) + self.assertTrue('spam' in message.status) diff --git a/opentech/apply/activity/urls.py b/opentech/apply/activity/urls.py new file mode 100644 index 000000000..cdad5c3af --- /dev/null +++ b/opentech/apply/activity/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path + + +app_name = 'activity' + + +urlpatterns = [ + path('anymail/', include('anymail.urls')), +] diff --git a/opentech/apply/urls.py b/opentech/apply/urls.py index af5565ba1..e730c8025 100644 --- a/opentech/apply/urls.py +++ b/opentech/apply/urls.py @@ -6,6 +6,7 @@ from .dashboard import urls as dashboard_urls urlpatterns = [ path('apply/', include('opentech.apply.funds.urls', 'apply')), + path('activity/', include('opentech.apply.activity.urls', 'activity')), path('account/', include(users_urls)), path('dashboard/', include(dashboard_urls)), path('hijack/', include('hijack.urls', 'hijack')), diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 1ce8e0ef4..4ffdedd5d 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -365,6 +365,9 @@ EMAIL_BACKEND = 'anymail.backends.mailgun.EmailBackend' if 'MAILGUN_API_KEY' in env: MAILGUN_API_KEY = env.get('MAILGUN_API_KEY') +if 'ANYMAIL_WEBHOOK_SECRET' in env: + ANYMAIL_WEBHOOK_SECRET = env.get('ANYMAIL_WEBHOOK_SECRET') + if 'REDIS_URL' in env: CELERY_BROKER_URL = env.get('REDIS_URL') else: -- GitLab