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
This commit was merged in pull request #4.
This commit is contained in:
2026-08-20 03:34:56 -07:00
parent 30ce58e610
commit 46abaa6917
9 changed files with 234 additions and 7 deletions
+45 -1
View File
@@ -1,10 +1,54 @@
import logging
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"
# 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:
"""Capture UTM params into UTMVisit and stash a correlation id cookie."""