-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support: update contact information via Front webhook
- Loading branch information
Showing
9 changed files
with
287 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
"""Front's API client.""" | ||
|
||
import requests | ||
|
||
|
||
class FrontClient: | ||
|
||
"""Wrapper around Front's API.""" | ||
|
||
BASE_URL = 'https://api2.frontapp.com' | ||
|
||
def __init__(self, token): | ||
self.token = token | ||
|
||
@property | ||
def _headers(self): | ||
headers = { | ||
"Authorization": f"Bearer {self.token}", | ||
} | ||
return headers | ||
|
||
def _get_url(self, path): | ||
return f'{self.BASE_URL}{path}' | ||
|
||
def get(self, path, **kwargs): | ||
kwargs.setdefault('headers', {}).update(self._headers) | ||
return requests.get(self._get_url(path), **kwargs) | ||
|
||
def patch(self, path, **kwargs): | ||
kwargs.setdefault('headers', {}).update(self._headers) | ||
return requests.patch(self._get_url(path), **kwargs) |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
from unittest import mock | ||
|
||
import requests_mock | ||
from django.contrib.auth.models import User | ||
from django.test import TestCase, override_settings | ||
from django.urls import reverse | ||
from django_dynamic_fixture import get | ||
|
||
from readthedocs.support.views import FrontWebhookBase | ||
|
||
|
||
@override_settings(ADMIN_URL='https://readthedocs.org/admin') | ||
class TestFrontWebhook(TestCase): | ||
|
||
def setUp(self): | ||
self.user = get(User, email='test@example.com', username='test') | ||
self.url = reverse('front_webhook') | ||
|
||
def test_invalid_payload(self): | ||
resp = self.client.post( | ||
self.url, | ||
data={'foo': 'bar'}, | ||
content_type='application/json', | ||
) | ||
self.assertEqual(resp.status_code, 400) | ||
self.assertEqual(resp.data['detail'], 'Invalid payload') | ||
|
||
@mock.patch.object(FrontWebhookBase, '_is_payload_valid') | ||
def test_invalid_event(self, is_payload_valid): | ||
is_payload_valid.return_value = True | ||
resp = self.client.post( | ||
self.url, | ||
data={'type': 'outbound'}, | ||
content_type='application/json', | ||
) | ||
self.assertEqual(resp.status_code, 200) | ||
self.assertEqual(resp.data['detail'], 'Skipping outbound event') | ||
|
||
@requests_mock.Mocker(kw='mock_request') | ||
@mock.patch.object(FrontWebhookBase, '_is_payload_valid') | ||
def test_inbound_event(self, is_payload_valid, mock_request): | ||
is_payload_valid.return_value = True | ||
self._mock_request(mock_request) | ||
resp = self.client.post( | ||
self.url, | ||
data={ | ||
'type': 'inbound', | ||
'conversation': {'id': 'cnv_123'} | ||
}, | ||
content_type='application/json', | ||
) | ||
self.assertEqual(resp.status_code, 200) | ||
self.assertEqual(resp.data['detail'], 'User updated test@example.com') | ||
last_request = mock_request.last_request | ||
self.assertEqual(last_request.method, 'PATCH') | ||
# Existing custom fields are left unchanged. | ||
custom_fields = last_request.json()['custom_fields'] | ||
for field in ['com:dont-change', 'org:dont-change', 'ads:dont-change']: | ||
self.assertEqual(custom_fields[field], 'Do not change this') | ||
|
||
@requests_mock.Mocker(kw='mock_request') | ||
@mock.patch.object(FrontWebhookBase, '_is_payload_valid') | ||
def test_inbound_event_unknow_email(self, is_payload_valid, mock_request): | ||
self.user.email = 'unknown@example.com' | ||
self.user.save() | ||
is_payload_valid.return_value = True | ||
self._mock_request(mock_request) | ||
resp = self.client.post( | ||
self.url, | ||
data={ | ||
'type': 'inbound', | ||
'conversation': {'id': 'cnv_123'} | ||
}, | ||
content_type='application/json', | ||
) | ||
self.assertEqual(resp.status_code, 200) | ||
self.assertEqual( | ||
resp.data['detail'], | ||
'User with email test@example.com not found in our database', | ||
) | ||
|
||
def _mock_request(self, mock_request): | ||
mock_request.get( | ||
'https://api2.frontapp.com/conversations/cnv_123', | ||
json={ | ||
'recipient': { | ||
'handle': 'test@example.com', | ||
}, | ||
}, | ||
) | ||
mock_request.get( | ||
'https://api2.frontapp.com/contacts/alt:email:test@example.com', | ||
json={ | ||
'custom_fields': { | ||
'org:dont-change': 'Do not change this', | ||
'com:dont-change': 'Do not change this', | ||
'ads:dont-change': 'Do not change this', | ||
}, | ||
}, | ||
) | ||
mock_request.patch('https://api2.frontapp.com/contacts/alt:email:test@example.com') |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from django.conf.urls import url | ||
|
||
from readthedocs.support.views import FrontWebhook | ||
|
||
urlpatterns = [ | ||
url(r'^front-webhook/$', FrontWebhook.as_view(), name='front_webhook'), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
"""Support views.""" | ||
|
||
import base64 | ||
import hashlib | ||
import hmac | ||
import logging | ||
|
||
from django.conf import settings | ||
from django.contrib.auth.models import User | ||
from django.db.models import Q | ||
from rest_framework.response import Response | ||
from rest_framework.status import ( | ||
HTTP_400_BAD_REQUEST, | ||
HTTP_500_INTERNAL_SERVER_ERROR, | ||
) | ||
from rest_framework.views import APIView | ||
|
||
from readthedocs.core.utils.extend import SettingsOverrideObject | ||
from readthedocs.support.front import FrontClient | ||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
class FrontWebhookBase(APIView): | ||
|
||
""" | ||
Front's webhook handler. | ||
Currently we only listen to inbound messages events. | ||
Contact information is updated when a new message is received. | ||
See https://dev.frontapp.com/docs/webhooks-1. | ||
""" | ||
|
||
http_method_names = ['post'] | ||
|
||
def post(self, request): | ||
if not self._is_payload_valid(): | ||
return Response( | ||
{'detail': 'Invalid payload'}, | ||
status=HTTP_400_BAD_REQUEST, | ||
) | ||
|
||
event = request.data.get('type') | ||
if event == 'inbound': | ||
return self._update_contact_information(request.data) | ||
return Response({'detail': f'Skipping {event} event'}) | ||
|
||
def _update_contact_information(self, data): | ||
""" | ||
Update contact information using Front's API. | ||
The webhook event give us the conversation_id, | ||
we use that to | ||
""" | ||
client = FrontClient(token=settings.FRONT_TOKEN) | ||
|
||
# Retrieve the user from the email from the conversation. | ||
conversation_id = data.get('conversation', {}).get('id') | ||
try: | ||
resp = client.get(f'/conversations/{conversation_id}').json() | ||
email = resp.get('recipient', {}).get('handle') | ||
except Exception: # noqa | ||
msg = f'Error while getting conversation {conversation_id}' | ||
log.exception(msg) | ||
return Response({'detail': msg}, status=HTTP_500_INTERNAL_SERVER_ERROR) | ||
|
||
user = ( | ||
User.objects | ||
.filter(Q(email=email) | Q(emailaddress__email=email)) | ||
.first() | ||
) | ||
if not user: | ||
return Response({'detail': f'User with email {email} not found in our database'}) | ||
|
||
# Get current custom fields, and update them. | ||
try: | ||
resp = client.get(f'/contacts/alt:email:{email}').json() | ||
except Exception: # noqa | ||
msg = f'Error while getting contact {email}' | ||
log.exception(msg) | ||
return Response({'detail': msg}, HTTP_500_INTERNAL_SERVER_ERROR) | ||
|
||
new_custom_fields = self._get_custom_fields(user) | ||
custom_fields = resp.get('custom_fields', {}) | ||
custom_fields.update(new_custom_fields) | ||
|
||
try: | ||
client.patch( | ||
f'/contacts/alt:email:{email}', | ||
json={'custom_fields': custom_fields} | ||
) | ||
except Exception: # noqa | ||
msg = f'Error while updating contact information for {email}' | ||
log.exception(msg) | ||
return Response( | ||
{ | ||
'detail': msg, | ||
'custom_fields': new_custom_fields, | ||
}, | ||
status=HTTP_500_INTERNAL_SERVER_ERROR, | ||
) | ||
else: | ||
return Response({ | ||
'detail': f'User updated {email}', | ||
'custom_fields': new_custom_fields, | ||
}) | ||
|
||
# pylint: disable=no-self-use | ||
def _get_custom_fields(self, user): | ||
""" | ||
Attach custom fields for this user. | ||
These fields need to be created on Front (settings -> Contacts -> Custom Fields). | ||
""" | ||
custom_fields = {} | ||
custom_fields['org:username'] = user.username | ||
custom_fields['org:admin'] = f'{settings.ADMIN_URL}/auth/user/{user.pk}/change' | ||
return custom_fields | ||
|
||
def _is_payload_valid(self): | ||
""" | ||
Check if the signature and the payload from the webhook matches. | ||
https://dev.frontapp.com/docs/webhooks-1#validating-data-integrity | ||
""" | ||
digest = self._get_digest() | ||
signature = self.request.headers.get('X-Front-Signature', '') | ||
result = hmac.compare_digest(digest, signature.encode()) | ||
return result | ||
|
||
def _get_digest(self): | ||
"""Get a HMAC digest of the request using Front's API secret.""" | ||
secret = settings.FRONT_API_SECRET | ||
digest = hmac.new( | ||
secret.encode(), | ||
msg=self.request.body, | ||
digestmod=hashlib.sha1, | ||
) | ||
return base64.b64encode(digest.digest()) | ||
|
||
|
||
class FrontWebhook(SettingsOverrideObject): | ||
_default_class = FrontWebhookBase |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters