Compare commits

..
2 Commits
Author SHA1 Message Date
westfarn a817b9caf7 Update public About bio, Wheaton address, and headshot.
CI / test (pull_request) Successful in 13s
Closes #1 — refresh About copy and photo, surface the Butterfield Rd office on Home/Contact/nav, and align service-area defaults with Chicago's western suburbs.
2026-08-20 03:35:12 -07:00
westfarn 46abaa6917 Add public PageView tracking for portal analytics (#4)
Deploy Beta / unit-tests (push) Successful in 12s
Deploy Beta / docker (push) Successful in 22s
Deploy Beta / deploy-beta (push) Successful in 5m40s
## Summary
Closes #3.

- Add `PageView` model + migration for successful public marketing GET hits
- Wire `PublicPageViewMiddleware` (skips portal/admin/accounts/api/static/health/etc.; fails soft on DB errors)
- Portal analytics report: last-30-day views + top pages table
- Admin registration and tests

## Test plan
- [ ] Apply migration `analytics.0002_pageview`
- [ ] Hit `/` and `/about/` → rows in `PageView` / admin
- [ ] Hit `/healthz/`, `/portal/`, `/admin/` → no new pageviews
- [ ] Open portal Analytics → views count + Top pages look right
- [ ] `uv run python site/manage.py test analytics.tests`

Reviewed-on: #4
2026-08-20 03:34:56 -07:00
9 changed files with 234 additions and 7 deletions
+10 -1
View File
@@ -1,6 +1,15 @@
from django.contrib import admin from django.contrib import admin
from analytics.models import Attribution, UTMVisit from analytics.models import Attribution, PageView, UTMVisit
@admin.register(PageView)
class PageViewAdmin(admin.ModelAdmin):
list_display = ("path", "created_at")
list_filter = ("created_at",)
search_fields = ("path",)
date_hierarchy = "created_at"
readonly_fields = ("id", "path", "created_at", "updated_at")
@admin.register(UTMVisit) @admin.register(UTMVisit)
+45 -1
View File
@@ -1,10 +1,54 @@
import logging
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from analytics.models import Attribution, UTMVisit from analytics.models import Attribution, PageView, UTMVisit
logger = logging.getLogger(__name__)
CORRELATION_COOKIE = "ms_cid" CORRELATION_COOKIE = "ms_cid"
# Portal, auth, health, assets, and transactional public endpoints.
_SKIP_PREFIXES = (
"/portal/",
"/admin/",
"/accounts/",
"/api/",
"/healthz",
"/static/",
"/media/",
"/unsubscribe/",
)
_SKIP_PATHS = frozenset({"/robots.txt", "/sitemap.xml", "/favicon.ico"})
class PublicPageViewMiddleware:
"""Record successful GET hits on public marketing pages."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if _should_record_page_view(request, response):
try:
PageView.objects.create(path=request.path[:512])
except Exception:
logger.exception("Failed to record page view for %s", request.path)
return response
def _should_record_page_view(request, response) -> bool:
if request.method != "GET":
return False
if getattr(response, "status_code", 0) != 200:
return False
path = request.path
if path in _SKIP_PATHS:
return False
return not any(path.startswith(prefix) for prefix in _SKIP_PREFIXES)
class UTMTrackingMiddleware: class UTMTrackingMiddleware:
"""Capture UTM params into UTMVisit and stash a correlation id cookie.""" """Capture UTM params into UTMVisit and stash a correlation id cookie."""
@@ -0,0 +1,27 @@
# Generated by Django 6.1 on 2026-08-13 12:38
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('analytics', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PageView',
fields=[
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('path', models.CharField(db_index=True, max_length=512)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['created_at', 'path'], name='analytics_p_created_9e8b64_idx')],
},
),
]
+15
View File
@@ -4,6 +4,21 @@ from core.models import TimeStampedModel, UUIDPrimaryKeyModel
from leads.models import Lead from leads.models import Lead
class PageView(UUIDPrimaryKeyModel, TimeStampedModel):
"""One successful GET of a public marketing page."""
path = models.CharField(max_length=512, db_index=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["created_at", "path"]),
]
def __str__(self) -> str:
return self.path or "/"
class UTMVisit(UUIDPrimaryKeyModel, TimeStampedModel): class UTMVisit(UUIDPrimaryKeyModel, TimeStampedModel):
correlation_id = models.CharField(max_length=64, db_index=True) correlation_id = models.CharField(max_length=64, db_index=True)
path = models.CharField(max_length=512, blank=True) path = models.CharField(max_length=512, blank=True)
+28 -2
View File
@@ -2,11 +2,37 @@
{% block title %}Analytics · Portal{% endblock %} {% block title %}Analytics · Portal{% endblock %}
{% block topbar_title %}Analytics{% endblock %} {% block topbar_title %}Analytics{% endblock %}
{% block portal_content %} {% block portal_content %}
<div class="stat-row"> <div class="analytics-overview">
<div class="stat-card"> <div class="stat-card">
<div class="label">Views (last 30 days)</div> <div class="label">Views (last 30 days)</div>
<div class="value">{{ visits_last_30_days }}</div> <div class="value">{{ pageviews_last_30_days }}</div>
</div> </div>
<div class="panel">
<div class="panel-h"><h2>Top pages</h2><span class="muted" style="font-size:12px">Public visits, last 30 days</span></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr>
<th>Page</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
{% for row in top_pages %}
<tr>
<td>{{ row.path }}</td>
<td>{{ row.count }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="empty-state">No public page views in the last 30 days.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="stat-row">
<div class="stat-card"> <div class="stat-card">
<div class="label">UTM landings</div> <div class="label">UTM landings</div>
<div class="value">{{ total_visits }}</div> <div class="value">{{ total_visits }}</div>
+84
View File
@@ -0,0 +1,84 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone
from analytics.models import PageView, UTMVisit
class PublicPageViewTests(TestCase):
def setUp(self):
self.client = Client()
def test_home_records_page_view(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(PageView.objects.count(), 1)
self.assertEqual(PageView.objects.get().path, "/")
def test_about_records_page_view(self):
self.client.get(reverse("public:about"))
self.assertEqual(PageView.objects.filter(path="/about/").count(), 1)
def test_plain_visit_does_not_create_utm_visit(self):
self.client.get("/")
self.assertEqual(PageView.objects.count(), 1)
self.assertEqual(UTMVisit.objects.count(), 0)
def test_utm_hit_records_both(self):
self.client.get("/?utm_source=test&utm_campaign=demo")
self.assertEqual(PageView.objects.count(), 1)
self.assertEqual(UTMVisit.objects.count(), 1)
def test_healthz_not_recorded(self):
self.client.get("/healthz/")
self.assertEqual(PageView.objects.count(), 0)
def test_portal_and_admin_not_recorded(self):
self.client.get("/portal/")
self.client.get("/admin/")
self.assertEqual(PageView.objects.count(), 0)
def test_robots_and_sitemap_not_recorded(self):
self.client.get("/robots.txt")
self.client.get("/sitemap.xml")
self.assertEqual(PageView.objects.count(), 0)
def test_missing_page_not_recorded(self):
response = self.client.get("/not-a-real-page/")
self.assertEqual(response.status_code, 404)
self.assertEqual(PageView.objects.count(), 0)
def test_contact_post_not_recorded(self):
self.client.post(reverse("public:contact"), {})
self.assertEqual(PageView.objects.count(), 0)
class AnalyticsReportTests(TestCase):
def setUp(self):
self.client = Client()
user = get_user_model().objects.create_user(
username="monica", password="pass-word-1"
)
self.client.force_login(user)
def test_views_card_uses_public_pageviews(self):
self.client.get("/")
self.client.get(reverse("public:about"))
self.client.get(reverse("public:about"))
stale = PageView.objects.create(path="/terms/")
PageView.objects.filter(pk=stale.pk).update(
created_at=timezone.now() - timedelta(days=31)
)
response = self.client.get(reverse("analytics:report"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["pageviews_last_30_days"], 3)
top = {row["path"]: row["count"] for row in response.context["top_pages"]}
self.assertEqual(top["/about/"], 2)
self.assertEqual(top["/"], 1)
self.assertNotIn("/terms/", top)
self.assertContains(response, "Top pages")
self.assertContains(response, "/about/")
+8 -3
View File
@@ -5,7 +5,7 @@ from django.db.models import Count
from django.shortcuts import render from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from analytics.models import Attribution, UTMVisit from analytics.models import Attribution, PageView, UTMVisit
def _bar_pct(rows, key="count"): def _bar_pct(rows, key="count"):
@@ -18,7 +18,11 @@ def _bar_pct(rows, key="count"):
@login_required @login_required
def report(request): def report(request):
since_30d = timezone.now() - timedelta(days=30) since_30d = timezone.now() - timedelta(days=30)
visits_last_30_days = UTMVisit.objects.filter(created_at__gte=since_30d).count() pageviews_qs = PageView.objects.filter(created_at__gte=since_30d)
pageviews_last_30_days = pageviews_qs.count()
top_pages = list(
pageviews_qs.values("path").annotate(count=Count("id")).order_by("-count")[:20]
)
visits_by_source = _bar_pct( visits_by_source = _bar_pct(
list( list(
@@ -60,7 +64,8 @@ def report(request):
request, request,
"analytics/report.html", "analytics/report.html",
{ {
"visits_last_30_days": visits_last_30_days, "pageviews_last_30_days": pageviews_last_30_days,
"top_pages": top_pages,
"total_visits": UTMVisit.objects.count(), "total_visits": UTMVisit.objects.count(),
"total_attributed": Attribution.objects.count(), "total_attributed": Attribution.objects.count(),
"top_source": (top_visit or {}).get("utm_source") or "(direct)", "top_source": (top_visit or {}).get("utm_source") or "(direct)",
+1
View File
@@ -134,6 +134,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"analytics.middleware.UTMTrackingMiddleware", "analytics.middleware.UTMTrackingMiddleware",
"analytics.middleware.PublicPageViewMiddleware",
"public.middleware.UnderConstructionMiddleware", "public.middleware.UnderConstructionMiddleware",
] ]
+16
View File
@@ -83,6 +83,22 @@ body.portal {
/* Portal widgets */ /* Portal widgets */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; } .stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
.analytics-overview {
display: grid;
grid-template-columns: minmax(140px, 1fr) 4fr;
gap: 16px;
margin-bottom: 24px;
align-items: stretch;
}
.analytics-overview .stat-card {
display: flex;
flex-direction: column;
justify-content: center;
}
.analytics-overview .panel { margin-bottom: 0; }
@media (max-width: 800px) {
.analytics-overview { grid-template-columns: 1fr; }
}
.stat-card { .stat-card {
background: #fff; background: #fff;
border: 1px solid var(--monica-border); border: 1px solid var(--monica-border);