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