Template
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
This commit was merged in pull request #4.
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""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(),
|
||||
}
|
||||
Reference in New Issue
Block a user