Add bsm url handling and whitepaper email capture (#1957)

This commit is contained in:
Greg Kaleka
2025-10-10 12:15:21 -04:00
committed by GitHub
parent 2b392f8538
commit 83e6bc45f5
17 changed files with 609 additions and 113 deletions

0
marketing/__init__.py Normal file
View File

9
marketing/admin.py Normal file
View File

@@ -0,0 +1,9 @@
from django.contrib import admin
from marketing.models import CapturedEmail
@admin.register(CapturedEmail)
class CapturedEmailAdmin(admin.ModelAdmin):
model = CapturedEmail
list_display = ("email", "referrer", "page_slug")

6
marketing/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MarketingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "marketing"

17
marketing/forms.py Normal file
View File

@@ -0,0 +1,17 @@
from django import forms
from .models import CapturedEmail
class CapturedEmailForm(forms.ModelForm):
class Meta:
model = CapturedEmail
fields = ["email"]
widgets = {
"email": forms.EmailInput(
attrs={
"placeholder": "your@email.com",
"autocomplete": "email",
}
)
}

View File

@@ -0,0 +1,30 @@
# Generated by Django 4.2.24 on 2025-10-08 18:18
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="CapturedEmail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("email", models.EmailField(max_length=254)),
("referrer", models.CharField(blank=True, default="")),
("page_slug", models.CharField(blank=True, default="")),
],
),
]

View File

13
marketing/models.py Normal file
View File

@@ -0,0 +1,13 @@
from django.db import models
class CapturedEmail(models.Model):
email = models.EmailField()
referrer = models.CharField(blank=True, default="")
page_slug = models.CharField(blank=True, default="")
def __str__(self):
return self.email
def __repr__(self):
return f"<{self.__class__.__name__} ({self.pk}): {self}>"

59
marketing/tests.py Normal file
View File

@@ -0,0 +1,59 @@
import logging
from unittest.mock import patch
import pytest
def test_whitepaper_view(tp):
tp.assertGoodView("whitepaper", slug="_example")
@pytest.mark.parametrize("url_stem", ["qrc", "bsm"])
def test_plausible_redirect_and_plausible_payload(tp, url_stem):
"""XFF present; querystring preserved; payload/headers correct."""
with patch("marketing.views.requests.post", return_value=None) as post_mock:
url = f"/{url_stem}/pv-01/library/latest/beast/?x=1&y=2"
res = tp.get(url)
tp.response_302(res)
assert res["Location"] == "/library/latest/beast/?x=1&y=2"
# Plausible call
(endpoint,), kwargs = post_mock.call_args
assert endpoint == "https://plausible.io/api/event"
# View uses request.path, so no querystring in payload URL
assert kwargs["json"] == {
"name": "pageview",
"domain": "qrc.boost.org",
"url": f"http://testserver/{url_stem}/pv-01/library/latest/beast/",
"referrer": "", # matches view behavior with no forwarded referer
}
headers = kwargs["headers"]
assert headers["Content-Type"] == "application/json"
assert kwargs["timeout"] == 2.0
def test_qrc_falls_back_to_remote_addr_when_no_xff(tp):
"""No XFF provided -> uses REMOTE_ADDR (127.0.0.1 in Django test client)."""
with patch("marketing.views.requests.post", return_value=None) as post_mock:
res = tp.get("/qrc/camp/library/latest/algorithm/")
tp.response_302(res)
assert res["Location"] == "/library/latest/algorithm/"
(_, kwargs) = post_mock.call_args
headers = kwargs["headers"]
assert headers["X-Forwarded-For"] == "127.0.0.1" # Django test client default
def test_qrc_logs_plausible_error_but_still_redirects(tp, caplog):
"""Plausible post raises -> error logged; redirect not interrupted."""
with patch("marketing.views.requests.post", side_effect=RuntimeError("boom")):
with caplog.at_level(logging.ERROR, logger="core.views"):
res = tp.get("/qrc/c1/library/", HTTP_USER_AGENT="ua")
tp.response_302(res)
assert res["Location"] == "/library/"
assert any("Plausible event post failed" in r.message for r in caplog.records)

103
marketing/views.py Normal file
View File

@@ -0,0 +1,103 @@
import requests
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponseRedirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.cache import never_cache
from django.views.generic import CreateView
from core.views import logger
from marketing.forms import CapturedEmailForm
from marketing.models import CapturedEmail
@method_decorator(never_cache, name="dispatch")
class PlausibleRedirectView(View):
"""Handles QR code and social media urls, sending them to Plausible, then redirecting to the desired url.
QR code urls are formatted /qrc/<campaign_identifier>/desired/path/to/content/, and will
result in a redirect to /desired/path/to/content/.
Social media urls are formatted /bsm/<campaign_identifier>/desired/path/to/content/, and will
result in a redirect to /desired/path/to/content/.
E.g. https://www.boost.org/qrc/pv-01/library/latest/beast/ will send this full url to Plausible,
then redirect to https://www.boost.org/library/latest/beast/
"""
def get(self, request: HttpRequest, campaign_identifier: str, main_path: str = ""):
absolute_url = request.build_absolute_uri(request.path)
referrer = request.META.get("HTTP_REFERER", "")
print(f"\n\n{referrer = }\n")
user_agent = request.META.get("HTTP_USER_AGENT", "")
plausible_payload = {
"name": "pageview",
"domain": "qrc.boost.org",
"url": absolute_url,
"referrer": referrer,
}
headers = {"Content-Type": "application/json", "User-Agent": user_agent}
client_ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip()
client_ip = client_ip or request.META.get("REMOTE_ADDR")
if client_ip:
headers["X-Forwarded-For"] = client_ip
try:
requests.post(
"https://plausible.io/api/event",
json=plausible_payload,
headers=headers,
timeout=2.0,
)
except Exception as e:
# Dont interrupt the redirect - just log it
logger.error(f"Plausible event post failed: {e}")
# Now that we've sent the request url to plausible, we can redirect to the main_path
# Preserve the original querystring, if any.
# Example: /qrc/3/library/latest/algorithm/?x=1 -> /library/latest/algorithm/?x=1
# `main_path` is everything after qrc/<campaign>/ thanks to <path:main_path>.
redirect_path = "/" + main_path if main_path else "/"
qs = request.META.get("QUERY_STRING")
if qs:
redirect_path = f"{redirect_path}?{qs}"
request.session["original_referrer"] = referrer or campaign_identifier
return HttpResponseRedirect(redirect_path)
class WhitePaperView(SuccessMessageMixin, CreateView):
"""Email capture and whitepaper view."""
model = CapturedEmail
form_class = CapturedEmailForm
success_message = "Thanks! We'll be in touch."
referrer = ""
def get(self, request, *args, **kwargs):
"""Store self.referrer for use in form submission."""
# If this view originated from PlausibleRedirectView, we should have original_referrer in the session
if original_referrer := self.request.session.pop("original_referrer", ""):
self.referrer = original_referrer
else:
self.referrer = self.request.META.get("HTTP_REFERER", "")
return super().get(request, *args, **kwargs)
def get_template_names(self):
category = self.kwargs["category"]
slug = self.kwargs["slug"]
return [f"marketing/whitepapers/{category}/{slug}.html"]
def form_valid(self, form):
form.instance.referrer = self.referrer
form.instance.page_slug = f"{self.kwargs['category']}/{self.kwargs['slug']}"
return super().form_valid(form)
def get_success_url(self):
return reverse("whitepaper", kwargs=self.kwargs)