Skip to content

Commit 83e6bc4

Browse files
authored
Add bsm url handling and whitepaper email capture (boostorg#1957)
1 parent 2b392f8 commit 83e6bc4

File tree

17 files changed

+609
-113
lines changed

17 files changed

+609
-113
lines changed

config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"versions",
9797
"libraries",
9898
"mailing_list",
99+
"marketing",
99100
"news",
100101
"reports",
101102
"core",

config/urls.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
UserGuideTemplateView,
3434
BoostDevelopmentView,
3535
ModernizedDocsView,
36-
QRCodeView,
3736
)
37+
from marketing.views import PlausibleRedirectView, WhitePaperView
3838
from libraries.api import LibrarySearchView
3939
from libraries.views import (
4040
LibraryDetail,
@@ -122,13 +122,30 @@
122122
path("feed/news.atom", AtomNewsFeed(), name="news_feed_atom"),
123123
path("LICENSE_1_0.txt", BSLView, name="license"),
124124
path(
125-
"qrc/<str:campaign_identifier>/", QRCodeView.as_view(), name="qr_code_root"
125+
"qrc/<str:campaign_identifier>/",
126+
PlausibleRedirectView.as_view(),
127+
name="qr_code_root",
126128
), # just in case
127129
path(
128130
"qrc/<str:campaign_identifier>/<path:main_path>",
129-
QRCodeView.as_view(),
131+
PlausibleRedirectView.as_view(),
130132
name="qr_code",
131133
),
134+
path(
135+
"bsm/<str:campaign_identifier>/",
136+
PlausibleRedirectView.as_view(),
137+
name="bsm_root",
138+
),
139+
path(
140+
"bsm/<str:campaign_identifier>/<path:main_path>",
141+
PlausibleRedirectView.as_view(),
142+
name="bsm",
143+
),
144+
path(
145+
"outreach/<slug:category>/<slug:slug>",
146+
WhitePaperView.as_view(),
147+
name="whitepaper",
148+
),
132149
path(
133150
"accounts/social/signup/",
134151
CustomSocialSignupViewView.as_view(),

core/tests/test_views.py

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import logging
21
from unittest.mock import patch
32

43
import pytest
@@ -357,53 +356,3 @@ def test_docs_libs_gateway_200_html_transformed(rf, tp, mock_get_file_data):
357356
def test_calendar(rf, tp):
358357
response = tp.get("calendar")
359358
tp.response_200(response)
360-
361-
362-
def test_qrc_redirect_and_plausible_payload(tp):
363-
"""XFF present; querystring preserved; payload/headers correct."""
364-
with patch("core.views.requests.post", return_value=None) as post_mock:
365-
url = "/qrc/pv-01/library/latest/beast/?x=1&y=2"
366-
res = tp.get(url)
367-
368-
tp.response_302(res)
369-
assert res["Location"] == "/library/latest/beast/?x=1&y=2"
370-
371-
# Plausible call
372-
(endpoint,), kwargs = post_mock.call_args
373-
assert endpoint == "https://plausible.io/api/event"
374-
375-
# View uses request.path, so no querystring in payload URL
376-
assert kwargs["json"] == {
377-
"name": "pageview",
378-
"domain": "qrc.boost.org",
379-
"url": "http://testserver/qrc/pv-01/library/latest/beast/",
380-
"referrer": "", # matches view behavior with no forwarded referer
381-
}
382-
383-
headers = kwargs["headers"]
384-
assert headers["Content-Type"] == "application/json"
385-
assert kwargs["timeout"] == 2.0
386-
387-
388-
def test_qrc_falls_back_to_remote_addr_when_no_xff(tp):
389-
"""No XFF provided -> uses REMOTE_ADDR (127.0.0.1 in Django test client)."""
390-
with patch("core.views.requests.post", return_value=None) as post_mock:
391-
res = tp.get("/qrc/camp/library/latest/algorithm/")
392-
393-
tp.response_302(res)
394-
assert res["Location"] == "/library/latest/algorithm/"
395-
396-
(_, kwargs) = post_mock.call_args
397-
headers = kwargs["headers"]
398-
assert headers["X-Forwarded-For"] == "127.0.0.1" # Django test client default
399-
400-
401-
def test_qrc_logs_plausible_error_but_still_redirects(tp, caplog):
402-
"""Plausible post raises -> error logged; redirect not interrupted."""
403-
with patch("core.views.requests.post", side_effect=RuntimeError("boom")):
404-
with caplog.at_level(logging.ERROR, logger="core.views"):
405-
res = tp.get("/qrc/c1/library/", HTTP_USER_AGENT="ua")
406-
407-
tp.response_302(res)
408-
assert res["Location"] == "/library/"
409-
assert any("Plausible event post failed" in r.message for r in caplog.records)

core/views.py

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from urllib.parse import urljoin
55

6-
import requests
76
import structlog
87
from bs4 import BeautifulSoup
98
import chardet
@@ -16,14 +15,11 @@
1615
HttpResponse,
1716
HttpResponseNotFound,
1817
HttpResponseRedirect,
19-
HttpRequest,
2018
)
2119
from django.shortcuts import redirect
2220
from django.template.loader import render_to_string
2321
from django.urls import reverse
24-
from django.utils.decorators import method_decorator
2522
from django.views import View
26-
from django.views.decorators.cache import never_cache
2723
from django.views.generic import TemplateView
2824

2925
from config.settings import ENABLE_DB_CACHE
@@ -915,57 +911,3 @@ def get(self, request, requested_version):
915911
if requested_version == "release":
916912
new_path = "/libraries/"
917913
return HttpResponseRedirect(new_path)
918-
919-
920-
@method_decorator(never_cache, name="dispatch")
921-
class QRCodeView(View):
922-
"""Handles QR code urls, sending them to Plausible, then redirecting to the desired url.
923-
924-
QR code urls are formatted /qrc/<campaign_identifier>/desired/path/to/content/, and will
925-
result in a redirect to /desired/path/to/content/.
926-
927-
E.g. https://www.boost.org/qrc/pv-01/library/latest/beast/ will send this full url to Plausible,
928-
then redirect to https://www.boost.org/library/latest/beast/
929-
"""
930-
931-
def get(self, request: HttpRequest, campaign_identifier: str, main_path: str = ""):
932-
absolute_url = request.build_absolute_uri(request.path)
933-
referrer = request.META.get("HTTP_REFERER", "")
934-
user_agent = request.META.get("HTTP_USER_AGENT", "")
935-
936-
plausible_payload = {
937-
"name": "pageview",
938-
"domain": "qrc.boost.org",
939-
"url": absolute_url,
940-
"referrer": referrer,
941-
}
942-
943-
headers = {"Content-Type": "application/json", "User-Agent": user_agent}
944-
945-
client_ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip()
946-
client_ip = client_ip or request.META.get("REMOTE_ADDR")
947-
948-
if client_ip:
949-
headers["X-Forwarded-For"] = client_ip
950-
951-
try:
952-
requests.post(
953-
"https://plausible.io/api/event",
954-
json=plausible_payload,
955-
headers=headers,
956-
timeout=2.0,
957-
)
958-
except Exception as e:
959-
# Don’t interrupt the redirect - just log it
960-
logger.error(f"Plausible event post failed: {e}")
961-
962-
# Now that we've sent the request url to plausible, we can redirect to the main_path
963-
# Preserve the original querystring, if any.
964-
# Example: /qrc/3/library/latest/algorithm/?x=1 -> /library/latest/algorithm/?x=1
965-
# `main_path` is everything after qrc/<campaign>/ thanks to <path:main_path>.
966-
redirect_path = "/" + main_path if main_path else "/"
967-
qs = request.META.get("QUERY_STRING")
968-
if qs:
969-
redirect_path = f"{redirect_path}?{qs}"
970-
971-
return HttpResponseRedirect(redirect_path)

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,5 @@ alias shell := console
141141
docker compose cp "db:/tmp/${DUMP_FILENAME}" "./${DUMP_FILENAME}"
142142
echo "Database dumped successfully to ${DUMP_FILENAME}"
143143

144-
@manage args:
144+
@manage +args:
145145
docker compose run --rm web python manage.py {{ args }}

marketing/__init__.py

Whitespace-only changes.

marketing/admin.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.contrib import admin
2+
3+
from marketing.models import CapturedEmail
4+
5+
6+
@admin.register(CapturedEmail)
7+
class CapturedEmailAdmin(admin.ModelAdmin):
8+
model = CapturedEmail
9+
list_display = ("email", "referrer", "page_slug")

marketing/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class MarketingConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "marketing"

marketing/forms.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from django import forms
2+
3+
from .models import CapturedEmail
4+
5+
6+
class CapturedEmailForm(forms.ModelForm):
7+
class Meta:
8+
model = CapturedEmail
9+
fields = ["email"]
10+
widgets = {
11+
"email": forms.EmailInput(
12+
attrs={
13+
"placeholder": "your@email.com",
14+
"autocomplete": "email",
15+
}
16+
)
17+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 4.2.24 on 2025-10-08 18:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
initial = True
9+
10+
dependencies = []
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="CapturedEmail",
15+
fields=[
16+
(
17+
"id",
18+
models.BigAutoField(
19+
auto_created=True,
20+
primary_key=True,
21+
serialize=False,
22+
verbose_name="ID",
23+
),
24+
),
25+
("email", models.EmailField(max_length=254)),
26+
("referrer", models.CharField(blank=True, default="")),
27+
("page_slug", models.CharField(blank=True, default="")),
28+
],
29+
),
30+
]

0 commit comments

Comments
 (0)