[8009] Tito basket signup improvements (#8014)
* rebase * [8009] update README for Tito integration. * [8009] wip tito webhook tests * [8009] move tito events into own app * [8009] refactor tito webhook code * [8009] add exception logging to tito webhook basket.subscribe() all * [8009] move test settings overrides * [8009] tit tests: reinstate mock for test, switch to test email for test, change how tests are called with invoke Co-authored-by: Pomax <pomax@nihongoresources.com>
This commit is contained in:
Родитель
354f4e278e
Коммит
ce64714f0a
|
@ -93,6 +93,11 @@ The fake data generator can generate a site structure for the Mozilla Festival t
|
|||
|
||||
In order to access the Mozilla Festival site locally on a different domain than the main Foundation site, you'll need to edit your hosts file (`/etc/hosts` on *nix systems, `C:\Windows\System32\Drivers\etc\hosts` on Windows) to allow you to access the site at `mozfest.localhost:8000`. To enable this, add the following line to your hosts file: `127.0.0.1 mozfest.localhost`
|
||||
|
||||
Ticket purchases are implemented using a third-party integration with [Tito](https://ti.to/).
|
||||
There is a `TitoWidget` Streamfield block that's used to place a button on a page to open the Tito widget.
|
||||
A webhook (Django view) receives requests from Tito when a ticket is completed in order to sign users up for the Mozilla newsletter. The event-specific environment variables `TITO_SECURITY_TOKEN` and `TITO_NEWSLETTER_QUESTION_ID` are required for this to work, and can be found in the Customize > Webhooks section of the Tito admin dashboard for the event. As these are currently global in the Mozilla site only one Tito event can be supported at a time.
|
||||
|
||||
|
||||
## Gotchas
|
||||
|
||||
As this is REST API and CMS built on top of Django, there are some "gotcha!"s to keep in mind due to the high level of magic in the Django code base (where things will happen automatically without the code explicitly telling you).
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.urls import reverse
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
|
||||
from .views import tito_ticket_completed
|
||||
from .utils import sign_tito_request
|
||||
|
||||
|
||||
TITO_SECURITY_TOKEN = "abcdef123456"
|
||||
TITO_NEWSLETTER_QUESTION_ID = 123456
|
||||
|
||||
|
||||
@override_settings(
|
||||
TITO_SECURITY_TOKEN=TITO_SECURITY_TOKEN,
|
||||
TITO_NEWSLETTER_QUESTION_ID=TITO_NEWSLETTER_QUESTION_ID,
|
||||
)
|
||||
class TitoTicketCompletedTest(TestCase):
|
||||
def setUp(self):
|
||||
self.url = reverse("tito-ticket-completed")
|
||||
|
||||
def test_incorrect_http_method(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_incorrect_webhook_name(self):
|
||||
response = self.client.post(self.url, data={}, HTTP_X_WEBHOOK_NAME="invalid")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.content.decode(), "Not a ticket completed request")
|
||||
|
||||
def test_missing_tito_signature(self):
|
||||
response = self.client.post(
|
||||
self.url, data={}, HTTP_X_WEBHOOK_NAME="ticket.completed"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.content.decode(), "Payload verification failed")
|
||||
|
||||
def test_invalid_tito_signature(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data={},
|
||||
HTTP_X_WEBHOOK_NAME="ticket.completed",
|
||||
HTTP_TITO_SIGNATURE="invalid",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.content.decode(), "Payload verification failed")
|
||||
|
||||
@mock.patch("networkapi.events.views.basket")
|
||||
def test_calls_basket_api(self, mock_basket):
|
||||
secret = bytes(TITO_SECURITY_TOKEN, "utf-8")
|
||||
data = {
|
||||
"answers": [
|
||||
{
|
||||
"question": {"id": TITO_NEWSLETTER_QUESTION_ID},
|
||||
"response": ["yes"],
|
||||
},
|
||||
],
|
||||
"email": "rich@test.com",
|
||||
}
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post(
|
||||
self.url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
HTTP_X_WEBHOOK_NAME="ticket.completed",
|
||||
)
|
||||
request.META["HTTP_TITO_SIGNATURE"] = sign_tito_request(secret, request.body)
|
||||
|
||||
response = tito_ticket_completed(request)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
mock_basket.subscribe.assert_called_once_with(
|
||||
"rich@test.com", "mozilla-festival"
|
||||
)
|
||||
|
||||
def test_logs_basket_exception(self):
|
||||
# Using `failure@example.com` as the email causes an exception, see:
|
||||
# https://github.com/mozilla/basket-example#tips
|
||||
|
||||
secret = bytes(TITO_SECURITY_TOKEN, "utf-8")
|
||||
data = {
|
||||
"answers": [
|
||||
{
|
||||
"question": {"id": TITO_NEWSLETTER_QUESTION_ID},
|
||||
"response": ["yes"],
|
||||
},
|
||||
],
|
||||
"email": "failure@example.com",
|
||||
}
|
||||
|
||||
factory = RequestFactory()
|
||||
request = factory.post(
|
||||
self.url,
|
||||
data=json.dumps(data),
|
||||
content_type="application/json",
|
||||
HTTP_X_WEBHOOK_NAME="ticket.completed",
|
||||
)
|
||||
request.META["HTTP_TITO_SIGNATURE"] = sign_tito_request(secret, request.body)
|
||||
|
||||
with self.assertLogs(logger="networkapi.events.views", level="ERROR") as cm:
|
||||
response = tito_ticket_completed(request)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertIn("Basket subscription from Tito webhook failed", cm.output[0])
|
|
@ -1,6 +1,6 @@
|
|||
from django.urls import re_path
|
||||
|
||||
from networkapi.mozfest.views import tito_ticket_completed
|
||||
from .views import tito_ticket_completed
|
||||
|
||||
urlpatterns = [
|
||||
re_path('^ticket-completed/?$', tito_ticket_completed, name='tito-ticket-completed'),
|
|
@ -0,0 +1,29 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def is_valid_tito_request(signature, request_body):
|
||||
secret = bytes(settings.TITO_SECURITY_TOKEN, "utf-8")
|
||||
signed = sign_tito_request(secret, request_body)
|
||||
|
||||
return signature == signed
|
||||
|
||||
|
||||
def sign_tito_request(secret, content):
|
||||
# https://ti.to/docs/api/admin#webhooks-verifying-the-payload
|
||||
return base64.b64encode(
|
||||
hmac.new(secret, content, digestmod=hashlib.sha256).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def has_signed_up_to_newsletter(tito_answers):
|
||||
for answer in tito_answers:
|
||||
if answer["question"]["id"] == int(
|
||||
settings.TITO_NEWSLETTER_QUESTION_ID
|
||||
) and len(answer["response"]):
|
||||
return True
|
||||
|
||||
return False
|
|
@ -1,14 +1,16 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import basket
|
||||
from django.conf import settings
|
||||
|
||||
from django.http import HttpResponseBadRequest, HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from .utils import has_signed_up_to_newsletter, is_valid_tito_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# To configure endpoints see:
|
||||
# https://ti.to/Mozilla/mozilla-festival-2022/admin/settings/webhook_endpoints
|
||||
|
||||
|
@ -22,24 +24,20 @@ def tito_ticket_completed(request):
|
|||
return HttpResponseBadRequest("Not a ticket completed request")
|
||||
|
||||
# does the payload hash signature match
|
||||
# https://ti.to/docs/api/admin#webhooks-verifying-the-payload
|
||||
tito_signature = request.META.get("HTTP_TITO_SIGNATURE", "")
|
||||
secret = bytes(settings.TITO_SECURITY_TOKEN, "utf-8")
|
||||
signature = base64.b64encode(
|
||||
hmac.new(secret, request.body, digestmod=hashlib.sha256).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
if tito_signature != signature:
|
||||
if not is_valid_tito_request(tito_signature, request.body):
|
||||
return HttpResponseBadRequest("Payload verification failed")
|
||||
|
||||
# have they signed up to the newsletter?
|
||||
data = json.loads(request.body.decode())
|
||||
for answer in data.get("answers", []):
|
||||
if (
|
||||
answer["question"]["id"] == int(settings.TITO_NEWSLETTER_QUESTION_ID)
|
||||
and len(answer["response"])
|
||||
and data.get("email")
|
||||
):
|
||||
basket.subscribe(data.get("email"), "mozilla-festival")
|
||||
email = data.get("email")
|
||||
|
||||
if email and has_signed_up_to_newsletter(data.get("answers", [])):
|
||||
try:
|
||||
basket.subscribe(email, "mozilla-festival")
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
f"Basket subscription from Tito webhook failed: {str(error)}"
|
||||
)
|
||||
|
||||
return HttpResponse(status=202)
|
|
@ -244,6 +244,7 @@ INSTALLED_APPS = list(filter(None, [
|
|||
# the network site
|
||||
'networkapi',
|
||||
'networkapi.campaign',
|
||||
'networkapi.events',
|
||||
'networkapi.news',
|
||||
'networkapi.people',
|
||||
'networkapi.utility',
|
||||
|
|
|
@ -86,7 +86,7 @@ urlpatterns = list(filter(None, [
|
|||
re_path(r'^api/people/', include('networkapi.people.urls')),
|
||||
re_path(r'^environment.json', EnvVariablesView.as_view()),
|
||||
re_path(r'^help/', review_app_help_view, name='Review app help'),
|
||||
re_path(r'^tito/', include('networkapi.mozfest.urls')),
|
||||
re_path(r'^tito/', include('networkapi.events.urls')),
|
||||
|
||||
# Wagtail CMS routes
|
||||
re_path(
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { bindEventHandlers } from "./template-js-handler";
|
||||
import { setupTito } from "./tito.js";
|
||||
|
||||
bindEventHandlers();
|
||||
setupTito();
|
||||
|
|
2
tasks.py
2
tasks.py
|
@ -243,7 +243,7 @@ def test_python(ctx):
|
|||
**PLATFORM_ARG,
|
||||
)
|
||||
print("* Running tests")
|
||||
manage(ctx, "test")
|
||||
manage(ctx, "test networkapi")
|
||||
|
||||
|
||||
@task(aliases=["docker-test-node"])
|
||||
|
|
Загрузка…
Ссылка в новой задаче