mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
Add bsm url handling and whitepaper email capture (#1957)
This commit is contained in:
0
marketing/__init__.py
Normal file
0
marketing/__init__.py
Normal file
9
marketing/admin.py
Normal file
9
marketing/admin.py
Normal 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
6
marketing/apps.py
Normal 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
17
marketing/forms.py
Normal 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",
|
||||
}
|
||||
)
|
||||
}
|
||||
30
marketing/migrations/0001_initial.py
Normal file
30
marketing/migrations/0001_initial.py
Normal 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="")),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
marketing/migrations/__init__.py
Normal file
0
marketing/migrations/__init__.py
Normal file
13
marketing/models.py
Normal file
13
marketing/models.py
Normal 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
59
marketing/tests.py
Normal 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
103
marketing/views.py
Normal 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:
|
||||
# Don’t 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)
|
||||
Reference in New Issue
Block a user