Files
web_django_template/site/core/campaign_utm.py
T
westfarn 97b8607bf2 Add campaign UTM links and piha.li shortener (#4)
## Summary
- Port Monica campaign UTM + piha.li minting into always-on `core` (`shortener.py`, `campaign_utm.py`) so `email_sms` and `directmail` stay optional and never import each other.
- `utm_source` is a slug of `SITE_NAME` (override with `UTM_SOURCE`). Email gets an HTML `data-campaign-utm` link; SMS gets a plain URL; postcard QR only — no body inject.
- Live composer mints through login+CSRF `POST /portal/campaigns/short-link/` (registered only when an outreach app is installed). Empty `SHORTENER_*` falls back to the long UTM URL.

Reference: [monica_site PR #10](ai_ml_operations/monica_site#10)

Closes #3

## Test plan
- [x] `cd site && uv run python manage.py test` (125 tests)
- [ ] Email composer: type a name, confirm HTML link + `utm_campaign` updates, save draft
- [ ] SMS composer: plain `piha.li` (or long UTM if shortener unset) in the body
- [ ] Postcard composer: QR copies the tracked URL; campaign body has no `utm_source`
- [ ] Campaign report pages show the same panel
- [ ] With `FEATURE_EMAIL_SMS` and `FEATURE_DIRECT_MAIL` off, no extra nav and no `/portal/campaigns/short-link/` route

Reviewed-on: #4
2026-09-01 13:36:01 -07:00

239 lines
7.0 KiB
Python

"""Campaign tracked homepage links (UTM) plus optional piha.li short URLs.
utm_source comes from UTM_SOURCE env, else a slug of SITE_NAME — never a
hardcoded client name. Shared by email_sms and directmail.
"""
from __future__ import annotations
import html
import re
from urllib.parse import urlencode
from django.conf import settings
from django.urls import NoReverseMatch, reverse
from contacts.models import Channel
from core.shortener import resolve_display_url
UTM_CAMPAIGN_SLUG_MAX = 80
_UTM_ANCHOR_RE = re.compile(r"(<a\b[^>]*>)(.*?)(</a>)", re.IGNORECASE | re.DOTALL)
_SHORT_TEXT_URL_RE = re.compile(
r"https?://(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)"
r"/[a-z0-9]{4,8}",
re.IGNORECASE,
)
def campaign_utm_slug(name: str) -> str:
"""Lowercase hyphenated slug (utm_campaign / utm_source)."""
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower())
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return (slug or "campaign")[:UTM_CAMPAIGN_SLUG_MAX]
def campaign_utm_source() -> str:
"""Brand slug for utm_source. Override with UTM_SOURCE env."""
explicit = (getattr(settings, "UTM_SOURCE", None) or "").strip()
if explicit:
return campaign_utm_slug(explicit)
return campaign_utm_slug(getattr(settings, "SITE_NAME", None) or "campaign")
def public_site_base_url() -> str:
"""Public homepage used as the UTM landing URL."""
return (getattr(settings, "PUBLIC_SITE_URL", None) or "").rstrip("/")
def public_site_link_label() -> str:
"""Visible email-link text (host), or SITE_NAME in local/dev."""
host = (
public_site_base_url()
.replace("https://", "")
.replace("http://", "")
.split("/")[0]
)
brand = (getattr(settings, "SITE_NAME", None) or "").strip() or "our website"
if (
not host
or host.startswith("127.")
or host.startswith("localhost")
or host.startswith("0.0.0.0")
):
return brand
return host
def build_campaign_utm_url(
*,
name: str,
medium: str,
base_url: str = "",
) -> str:
"""Homepage URL with utm_source / utm_medium / utm_campaign."""
base = (base_url or public_site_base_url()).rstrip("/")
channel = (medium or "").strip().lower()
if channel not in Channel.values:
channel = Channel.EMAIL
query = urlencode(
{
"utm_source": campaign_utm_source(),
"utm_medium": channel,
"utm_campaign": campaign_utm_slug(name),
}
)
return f"{base}/?{query}"
def campaign_shortener_ref(
*,
campaign_id=None,
user_id=None,
medium: str = "",
name: str = "",
) -> str:
"""external_ref for the shortener (max 64). Prefer campaign UUID."""
if campaign_id:
return str(campaign_id)[:64]
slug = campaign_utm_slug(name)[:40]
ch = (medium or "email").strip().lower()[:8]
uid = "" if user_id is None else str(user_id)
return f"p{uid}-{ch}-{slug}"[:64]
def resolve_campaign_tracked_url(
*,
name: str,
medium: str,
campaign_id=None,
user_id=None,
shorten: bool = True,
) -> tuple[str, str]:
"""Return (long UTM URL, display URL). Display is short when mint works."""
target = build_campaign_utm_url(name=name, medium=medium)
if not shorten:
return target, target
display = resolve_display_url(
target,
title=f"{(name or 'campaign').strip()} ({medium})"[:200],
external_ref=campaign_shortener_ref(
campaign_id=campaign_id, user_id=user_id, medium=medium, name=name
),
)
return target, display
def _utm_text_url_re() -> re.Pattern[str]:
return re.compile(
r"https?://[^\s<>\"]+utm_source="
+ re.escape(campaign_utm_source())
+ r"[^\s<>\"]*",
re.IGNORECASE,
)
def _is_our_utm_anchor(attrs: str) -> bool:
lower = attrs.lower()
source = campaign_utm_source().lower()
return "data-campaign-utm" in lower or f"utm_source={source}" in lower
def ensure_campaign_utm_in_html(body: str, url: str, label: str) -> str:
"""Insert or refresh the tracked <a> in an HTML (email) body."""
href = html.escape(url, quote=True)
label_html = html.escape(label)
replaced = False
def _repl(match: re.Match[str]) -> str:
nonlocal replaced
if replaced or not _is_our_utm_anchor(match.group(1)):
return match.group(0)
replaced = True
inner = match.group(2) if (match.group(2) or "").strip() else label_html
return f'<a href="{href}" data-campaign-utm="1">{inner}</a>'
out = _UTM_ANCHOR_RE.sub(_repl, body or "")
if replaced:
return out
anchor = f'<a href="{href}" data-campaign-utm="1">{label_html}</a>'
text = (body or "").rstrip()
if text:
return f"{text}\n<p>{anchor}</p>"
return f"<p>{anchor}</p>"
def ensure_campaign_utm_in_text(body: str, url: str) -> str:
"""Insert or refresh the tracked URL in a plain-text (SMS) body."""
raw = body or ""
utm_re = _utm_text_url_re()
if utm_re.search(raw):
return utm_re.sub(url, raw, count=1)
if _SHORT_TEXT_URL_RE.search(raw):
return _SHORT_TEXT_URL_RE.sub(url, raw, count=1)
text = raw.rstrip()
return f"{text}\n\n{url}" if text else url
def ensure_campaign_utm_link(
body: str,
*,
name: str,
medium: str,
html: bool,
campaign_id=None,
shorten: bool | None = None,
) -> str:
"""Keep a tracked site link in the body, matching campaign name + channel."""
do_shorten = bool(campaign_id) if shorten is None else shorten
_target, url = resolve_campaign_tracked_url(
name=name,
medium=medium,
campaign_id=campaign_id,
shorten=do_shorten,
)
if html:
return ensure_campaign_utm_in_html(body, url, public_site_link_label())
return ensure_campaign_utm_in_text(body, url)
def postcard_designer_url() -> str:
try:
return reverse("directmail:postcard_designer")
except NoReverseMatch:
return ""
def utm_panel_context(
*,
campaign=None,
live: bool = False,
medium: str = "",
) -> dict:
"""Template context for `_utm_link_panel.html`."""
name = getattr(campaign, "name", "") or ""
ch = medium or getattr(campaign, "channel", "") or ""
display = ""
if campaign is not None:
_target, display = resolve_campaign_tracked_url(
name=name,
medium=ch,
campaign_id=getattr(campaign, "pk", None),
shorten=True,
)
try:
shorten_url = reverse("campaign_short_link")
except NoReverseMatch:
shorten_url = ""
return {
"utm_base_url": public_site_base_url(),
"utm_link_label": public_site_link_label(),
"utm_source": campaign_utm_source(),
"utm_shorten_url": shorten_url,
"utm_url": display,
"utm_live": live,
"utm_medium": ch,
"utm_campaign_name": name,
"utm_campaign_id": str(getattr(campaign, "pk", "") or ""),
"utm_postcard_designer_url": postcard_designer_url(),
}