diff --git a/.env.example b/.env.example index 9e3542b..ea8f6c6 100644 --- a/.env.example +++ b/.env.example @@ -85,6 +85,16 @@ SOCIAL_TOKEN_ENCRYPTION_KEY= OLLAMA_BASE_URL=http://10.0.0.128:11434 OLLAMA_MODEL=llama3.2 +# URL shortener (piha.li). Empty locally = long UTM URLs in campaigns. +# Call the API host, never https://piha.li (that host only 302s). +# Required in beta/prod when FEATURE_EMAIL_SMS or FEATURE_DIRECT_MAIL is on. +SHORTENER_BASE_URL= +SHORTENER_API_TOKEN= +# UTM_SOURCE= # optional override; default is a slug of SITE_NAME +# Local shortener compose: +# SHORTENER_BASE_URL=http://127.0.0.1:8005 +# SHORTENER_API_TOKEN=acme:dev-only-token + # Nominatim (LAN) — address autocomplete via Django /api/address-suggest/ NOMINATIM_BASE_URL=http://10.0.0.128:8089 NOMINATIM_TIMEOUT_SECONDS=8 diff --git a/.env.prod.example b/.env.prod.example index 15a84c6..ec97a1f 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -73,6 +73,14 @@ OLLAMA_BASE_URL=http://10.0.0.128:11434 OLLAMA_MODEL=llama3.2 OLLAMA_TIMEOUT_SECONDS=120 +# Required in beta/prod when FEATURE_EMAIL_SMS or FEATURE_DIRECT_MAIL is on. +# POST /api/links/ on the API host. Recipients hit https://piha.li/. +# Token must match SHORTENER_API_TOKENS on url_shortening_service (name:secret). +# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))" +SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_API_TOKEN=acme:replace-me +# UTM_SOURCE=acme # optional; default is a slug of SITE_NAME + NOMINATIM_BASE_URL=http://10.0.0.128:8089 NOMINATIM_TIMEOUT_SECONDS=8 NOMINATIM_COUNTRY_CODES=us @@ -90,3 +98,5 @@ GUNICORN_BIND=0.0.0.0:8000 # PUBLIC_SITE_URL=https://example-preview.aimloperations.com # DATABASE_URL=postgres://westfarn:replace-db-password@10.0.0.230:5432/client_site_beta # WEB_PORT=8014 +# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com +# SHORTENER_API_TOKEN=acme:replace-with-a-different-token diff --git a/README.md b/README.md index 467bc8a..321c45e 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,23 @@ server-infra Ansible deploy. Ollama for social drafts lives on the LAN at `http://10.0.0.128:11434`. Do not call a public Ollama host. + +## Campaign tracked links (piha.li) + +When `FEATURE_EMAIL_SMS` or `FEATURE_DIRECT_MAIL` is on, composers auto-insert a +homepage URL with `utm_source` (slug of `SITE_NAME`, or `UTM_SOURCE`), +`utm_medium` = channel, and `utm_campaign` = campaign name. Configured +`SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` mint that URL via +[url_shortening_service](https://git.aimloperations.com/ai_ml_operations/url_shortening_service) +and put `piha.li` / `beta.piha.li` short links in SMS, email hrefs, and postcard QR codes. + +Caller secrets (this repo's env file): + +```text +SHORTENER_BASE_URL=https://shortener.aimloperations.com +SHORTENER_API_TOKEN=: +``` + +Operator secrets (`url_shortening_service_*`): `SHORTENER_API_TOKENS=:` +and `SHORT_ALLOWED_HOSTS` must include the client domain. Mint against the **API host**, +never `piha.li`. diff --git a/docker-compose.yml b/docker-compose.yml index 33df0bd..c8cca2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,8 @@ services: META_APP_ID: ${META_APP_ID:-} META_APP_SECRET: ${META_APP_SECRET:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} + SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-} + SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-} depends_on: db: condition: service_healthy @@ -108,6 +110,8 @@ services: META_APP_ID: ${META_APP_ID:-} META_APP_SECRET: ${META_APP_SECRET:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} + SHORTENER_BASE_URL: ${SHORTENER_BASE_URL:-} + SHORTENER_API_TOKEN: ${SHORTENER_API_TOKEN:-} depends_on: db: condition: service_healthy diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh index 89235f6..c8d7cc1 100755 --- a/scripts/validate-env.sh +++ b/scripts/validate-env.sh @@ -53,6 +53,14 @@ if _true "${FEATURE_DIRECT_MAIL:-}"; then PCM_RETURN_ADDRESS ) fi +if _true "${FEATURE_EMAIL_SMS:-}" || _true "${FEATURE_DIRECT_MAIL:-}"; then + if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then + required_vars+=( + SHORTENER_BASE_URL + SHORTENER_API_TOKEN + ) + fi +fi if _true "${FEATURE_PAYMENTS:-}"; then required_vars+=( STRIPE_SECRET_KEY diff --git a/site/client_site/settings/base.py b/site/client_site/settings/base.py index 4e6794b..5633140 100644 --- a/site/client_site/settings/base.py +++ b/site/client_site/settings/base.py @@ -2,6 +2,7 @@ import json import os +import sys from pathlib import Path from urllib.parse import urlparse @@ -364,6 +365,20 @@ NOMINATIM_USER_AGENT = env( ) NOMINATIM_API_KEY = env("NOMINATIM_API_KEY", "") +# --- URL shortener (piha.li) --- +# Call the API host, never the public short domain. Empty = skip minting +# (composer falls back to the long UTM URL). Token format: name:secret. +# Required in beta/prod when FEATURE_EMAIL_SMS or FEATURE_DIRECT_MAIL is on. +SHORTENER_BASE_URL = env("SHORTENER_BASE_URL", "") +SHORTENER_API_TOKEN = env("SHORTENER_API_TOKEN", "") +SHORTENER_TIMEOUT_SECONDS = int(env("SHORTENER_TIMEOUT_SECONDS", "10") or "10") +# Optional override for utm_source. Empty → slug of SITE_NAME. +UTM_SOURCE = env("UTM_SOURCE", "") +# manage.py test must not mint against a live API (env often has prod tokens). +if "test" in sys.argv: + SHORTENER_BASE_URL = "" + SHORTENER_API_TOKEN = "" + # --- Tianji analytics (pageviews + events; notice banner, not opt-in) --- TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) TIANJI_TRACKER_URL = env( diff --git a/site/client_site/static/css/portal.css b/site/client_site/static/css/portal.css index 10834da..d33f1db 100644 --- a/site/client_site/static/css/portal.css +++ b/site/client_site/static/css/portal.css @@ -690,3 +690,39 @@ body.portal { padding: 8px 10px; border: 1px solid var(--monica-border); font: inherit; font-size: 13px; background: #fff; } .empty-state { padding: 24px; color: var(--monica-muted); font-size: 14px; } + +.utm-panel { + margin-top: 4px; + padding-top: 4px; +} +.utm-url { + display: block; + font-size: 12px; + line-height: 1.45; + word-break: break-all; + background: #f8fafc; + border: 1px solid var(--monica-border); + padding: 8px 10px; + color: #0f172a; +} +.utm-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} +.utm-qr { + display: flex; + gap: 16px; + align-items: flex-start; + flex-wrap: wrap; + margin-top: 12px; +} +.utm-qr canvas { + display: block; + border: 1px solid var(--monica-border); + background: #fff; +} +.utm-qr-actions { min-width: 160px; } +.utm-qr-actions .btn { margin: 0 8px 8px 0; } +#preview-body a { color: var(--monica-primary); } diff --git a/site/client_site/static/js/campaign-utm.js b/site/client_site/static/js/campaign-utm.js new file mode 100644 index 0000000..4114d76 --- /dev/null +++ b/site/client_site/static/js/campaign-utm.js @@ -0,0 +1,301 @@ +/** + * Campaign UTM link helper (composer + report). + * utm_source from data-utm-source (brand slug), utm_medium=email|sms|postcard, + * utm_campaign=slug(name). Short URLs come from the portal JSON endpoint. + */ +(function (global) { + "use strict"; + + var SLUG_MAX = 80; + var SHORTEN_WAIT_MS = 400; + var SHORT_URL_RE = + /https?:\/\/(?:(?:www\.)?(?:beta\.)?piha\.li|(?:127\.0\.0\.1|localhost):\d+)\/[a-z0-9]{4,8}/i; + + function slug(name) { + var s = String(name || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return (s || "campaign").slice(0, SLUG_MAX); + } + + function sourceFrom(panel) { + return slug((panel && panel.getAttribute("data-utm-source")) || "campaign"); + } + + function utmUrlRe(source) { + return new RegExp( + "https?:\\/\\/[^\\s<>\"]+utm_source=" + source + "[^\\s<>\"]*", + "i" + ); + } + + function buildUrl(base, medium, name, source) { + var root = String(base || "").replace(/\/+$/, ""); + var ch = String(medium || "email").toLowerCase(); + if (ch !== "email" && ch !== "sms" && ch !== "postcard") ch = "email"; + var q = + "utm_source=" + + encodeURIComponent(source || "campaign") + + "&utm_medium=" + + encodeURIComponent(ch) + + "&utm_campaign=" + + encodeURIComponent(slug(name)); + return root + "/?" + q; + } + + function replaceTextUrl(text, url, source) { + var raw = String(text || ""); + var utmRe = utmUrlRe(source || "campaign"); + if (utmRe.test(raw)) { + return raw.replace(utmRe, url); + } + if (SHORT_URL_RE.test(raw)) { + return raw.replace(SHORT_URL_RE, url); + } + var trimmed = raw.replace(/\s+$/, ""); + return trimmed ? trimmed + "\n\n" + url : url; + } + + function linkify(text) { + var esc = String(text || "").replace(/[&<>"']/g, function (c) { + return ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ + c + ] || c + ); + }); + return esc.replace( + /(https?:\/\/[^\s]+)/g, + '$1' + ); + } + + function ensureQuillLink(quill, url, label, source) { + if (!quill || !quill.root) return; + var src = source || "campaign"; + var existing = quill.root.querySelectorAll( + 'a[data-campaign-utm], a[href*="utm_source=' + + src + + '"], a[href*="piha.li/"]' + ); + if (existing.length) { + existing.forEach(function (a) { + a.setAttribute("href", url); + a.setAttribute("data-campaign-utm", "1"); + }); + return; + } + var labelText = label || "Website"; + var start = Math.max(0, quill.getLength() - 1); + quill.insertText(start, "\n" + labelText, "silent"); + quill.formatText(start + 1, labelText.length, "link", url, "silent"); + var links = quill.root.querySelectorAll("a"); + var last = links[links.length - 1]; + if (last) last.setAttribute("data-campaign-utm", "1"); + } + + function renderQr(canvas, url) { + if (!canvas || !url || typeof QRCode === "undefined") return Promise.resolve(); + return QRCode.toCanvas(canvas, url, { + width: 192, + margin: 1, + color: { dark: "#00626c", light: "#ffffff" }, + }); + } + + function flash(el, okText, errText, ok) { + if (!el) return; + el.textContent = ok ? okText : errText; + el.hidden = false; + window.setTimeout(function () { + el.hidden = true; + }, 2200); + } + + function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return Promise.reject(new Error("clipboard unavailable")); + } + + function copyCanvas(canvas) { + if (!canvas || !canvas.toBlob) { + return Promise.reject(new Error("canvas unavailable")); + } + return new Promise(function (resolve, reject) { + canvas.toBlob(function (blob) { + if (!blob) { + reject(new Error("qr blob failed")); + return; + } + if ( + !navigator.clipboard || + !navigator.clipboard.write || + typeof ClipboardItem === "undefined" + ) { + reject(new Error("image clipboard unavailable")); + return; + } + navigator.clipboard + .write([new ClipboardItem({ "image/png": blob })]) + .then(resolve, reject); + }, "image/png"); + }); + } + + function downloadCanvas(canvas, filename) { + if (!canvas) return; + var a = document.createElement("a"); + a.href = canvas.toDataURL("image/png"); + a.download = filename || "campaign-qr.png"; + document.body.appendChild(a); + a.click(); + a.remove(); + } + + function bindPanel(panel, opts) { + if (!panel) return; + opts = opts || {}; + var urlEl = panel.querySelector("[data-utm-url]"); + var canvas = panel.querySelector("[data-utm-qr]"); + var qrWrap = panel.querySelector("[data-utm-qr-wrap]"); + var smsHint = panel.querySelector("[data-utm-sms-hint]"); + var emailHint = panel.querySelector("[data-utm-email-hint]"); + var postcardHint = panel.querySelector("[data-utm-postcard-hint]"); + var statusEl = panel.querySelector("[data-utm-status]"); + var base = panel.getAttribute("data-base-url") || ""; + var source = sourceFrom(panel); + var label = panel.getAttribute("data-link-label") || "Website"; + var live = panel.getAttribute("data-live") === "1"; + var shortenUrl = panel.getAttribute("data-shorten-url") || ""; + var csrf = (opts.csrfToken || panel.getAttribute("data-csrf") || "").trim(); + var shortenTimer = null; + var shortenSeq = 0; + + function medium() { + if (typeof opts.getMedium === "function") return opts.getMedium(); + return panel.getAttribute("data-medium") || "email"; + } + + function name() { + if (typeof opts.getName === "function") return opts.getName(); + return panel.getAttribute("data-campaign-name") || ""; + } + + function campaignId() { + return ( + (typeof opts.getCampaignId === "function" && opts.getCampaignId()) || + panel.getAttribute("data-campaign-id") || + "" + ); + } + + function paint(url, inject) { + var ch = medium(); + if (urlEl) urlEl.textContent = url; + if (qrWrap) qrWrap.hidden = ch !== "postcard"; + if (smsHint) smsHint.hidden = ch !== "sms"; + if (emailHint) emailHint.hidden = ch !== "email"; + if (postcardHint) postcardHint.hidden = ch !== "postcard"; + if (ch === "postcard" && canvas) { + renderQr(canvas, url).catch(function () {}); + } + if (inject && typeof opts.onUrlChange === "function") { + opts.onUrlChange(url, ch, label); + } + } + + function apply() { + var ch = medium(); + var longUrl = buildUrl(base, ch, name(), source); + if (!shortenUrl) { + paint(longUrl, true); + return longUrl; + } + paint(longUrl, false); + var seq = ++shortenSeq; + window.clearTimeout(shortenTimer); + shortenTimer = window.setTimeout(function () { + fetch(shortenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "X-CSRFToken": csrf, + }, + credentials: "same-origin", + body: JSON.stringify({ + name: name(), + medium: ch, + campaign_id: campaignId(), + }), + }) + .then(function (r) { + return r.ok ? r.json() : Promise.reject(); + }) + .then(function (data) { + if (seq !== shortenSeq) return; + paint((data && data.display_url) || longUrl, true); + }) + .catch(function () { + if (seq !== shortenSeq) return; + paint(longUrl, true); + }); + }, SHORTEN_WAIT_MS); + return longUrl; + } + + panel.querySelectorAll("[data-utm-copy-url]").forEach(function (btn) { + btn.addEventListener("click", function () { + var url = (urlEl && urlEl.textContent) || ""; + copyText(url).then( + function () { + flash(statusEl, "Link copied.", "Could not copy.", true); + }, + function () { + flash(statusEl, "", "Could not copy link.", false); + } + ); + }); + }); + panel.querySelectorAll("[data-utm-copy-qr]").forEach(function (btn) { + btn.addEventListener("click", function () { + copyCanvas(canvas).then( + function () { + flash(statusEl, "QR image copied.", "Could not copy QR.", true); + }, + function () { + flash(statusEl, "", "Copy failed — use Download PNG.", false); + } + ); + }); + }); + panel.querySelectorAll("[data-utm-download-qr]").forEach(function (btn) { + btn.addEventListener("click", function () { + downloadCanvas(canvas, "qr-" + slug(name()) + ".png"); + flash(statusEl, "QR downloaded.", "", true); + }); + }); + + if (live) { + var nameField = document.getElementById("id_name"); + if (nameField) nameField.addEventListener("input", apply); + } + + panel._utmApply = apply; + apply(); + return { apply: apply, buildUrl: buildUrl }; + } + + global.CampaignUtm = { + slug: slug, + buildUrl: buildUrl, + replaceTextUrl: replaceTextUrl, + linkify: linkify, + ensureQuillLink: ensureQuillLink, + bindPanel: bindPanel, + }; +})(window); diff --git a/site/client_site/urls.py b/site/client_site/urls.py index 5f4de61..8bdd7fb 100644 --- a/site/client_site/urls.py +++ b/site/client_site/urls.py @@ -5,7 +5,7 @@ from django.contrib import admin from django.urls import include, path from contacts.views import address_suggest -from core.views import healthz +from core.views import campaign_short_link, healthz urlpatterns = [ path("healthz/", healthz, name="healthz"), @@ -24,6 +24,14 @@ if apps.is_installed("email_sms"): urlpatterns += [path("portal/messaging/", include("email_sms.urls"))] if apps.is_installed("directmail"): urlpatterns += [path("portal/direct-mail/", include("directmail.urls"))] +if apps.is_installed("email_sms") or apps.is_installed("directmail"): + urlpatterns += [ + path( + "portal/campaigns/short-link/", + campaign_short_link, + name="campaign_short_link", + ), + ] if apps.is_installed("blog"): urlpatterns += [ path("blog/", include("blog.public_urls")), diff --git a/site/core/campaign_utm.py b/site/core/campaign_utm.py new file mode 100644 index 0000000..b754e30 --- /dev/null +++ b/site/core/campaign_utm.py @@ -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"(]*>)(.*?)()", 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 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'{inner}' + + out = _UTM_ANCHOR_RE.sub(_repl, body or "") + if replaced: + return out + anchor = f'{label_html}' + text = (body or "").rstrip() + if text: + return f"{text}\n

{anchor}

" + return f"

{anchor}

" + + +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(), + } diff --git a/site/core/shortener.py b/site/core/shortener.py new file mode 100644 index 0000000..023e626 --- /dev/null +++ b/site/core/shortener.py @@ -0,0 +1,87 @@ +"""Server-to-server client for url_shortening_service (piha.li). + +Call the API host (`SHORTENER_BASE_URL`), never the public short domain. +Auth: Authorization: Bearer (name:secret). +""" + +from __future__ import annotations + +import logging + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +def is_configured() -> bool: + base = (getattr(settings, "SHORTENER_BASE_URL", None) or "").strip() + token = (getattr(settings, "SHORTENER_API_TOKEN", None) or "").strip() + return bool(base and token) + + +def mint_short_url( + target_url: str, + *, + title: str = "", + external_ref: str = "", +) -> str | None: + """POST /api/links/. Return short_url, or None if unconfigured / failed. + + Does not raise. Compose must still work when the shortener is down or unset. + """ + if not is_configured(): + return None + target = (target_url or "").strip() + if not target.lower().startswith("https://"): + logger.info("shortener skip: target is not https") + return None + + base = str(settings.SHORTENER_BASE_URL).rstrip("/") + token = str(settings.SHORTENER_API_TOKEN).strip() + timeout = int(getattr(settings, "SHORTENER_TIMEOUT_SECONDS", 10) or 10) + try: + response = requests.post( + f"{base}/api/links/", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json={ + "target_url": target, + "title": (title or "")[:200], + "external_ref": (external_ref or "")[:64], + }, + timeout=timeout, + ) + except requests.RequestException: + logger.exception("shortener request failed") + return None + + if response.status_code not in (200, 201): + logger.warning( + "shortener mint failed status=%s", + response.status_code, + ) + return None + try: + data = response.json() + except ValueError: + logger.warning("shortener mint returned non-json") + return None + if not isinstance(data, dict): + return None + short = (data.get("short_url") or "").strip() + return short or None + + +def resolve_display_url( + target_url: str, + *, + title: str = "", + external_ref: str = "", +) -> str: + """Short URL when mint succeeds, otherwise the original target.""" + return mint_short_url( + target_url, title=title, external_ref=external_ref + ) or target_url diff --git a/site/core/templates/core/_utm_link_panel.html b/site/core/templates/core/_utm_link_panel.html new file mode 100644 index 0000000..315243e --- /dev/null +++ b/site/core/templates/core/_utm_link_panel.html @@ -0,0 +1,45 @@ + diff --git a/site/core/tests_campaign_utm.py b/site/core/tests_campaign_utm.py new file mode 100644 index 0000000..4d5ce75 --- /dev/null +++ b/site/core/tests_campaign_utm.py @@ -0,0 +1,169 @@ +"""Campaign UTM + shortener helpers (always-on core).""" + +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase, TestCase, override_settings +from django.urls import reverse + +from core.campaign_utm import ( + build_campaign_utm_url, + campaign_utm_slug, + campaign_utm_source, + ensure_campaign_utm_in_text, + ensure_campaign_utm_link, +) +from core.shortener import mint_short_url, resolve_display_url + + +class CampaignUtmHelperTests(SimpleTestCase): + def test_slug(self): + self.assertEqual(campaign_utm_slug("Spring seller tips"), "spring-seller-tips") + + @override_settings(SITE_NAME="Acme HVAC", UTM_SOURCE="") + def test_source_from_site_name(self): + self.assertEqual(campaign_utm_source(), "acme-hvac") + + @override_settings(SITE_NAME="Acme HVAC", UTM_SOURCE="acme") + def test_source_env_override(self): + self.assertEqual(campaign_utm_source(), "acme") + + @override_settings( + SITE_NAME="Acme HVAC", + UTM_SOURCE="", + PUBLIC_SITE_URL="https://acmehvac.com", + ) + def test_url_shape(self): + url = build_campaign_utm_url(name="Spring seller tips", medium="email") + self.assertEqual( + url, + "https://acmehvac.com/?utm_source=acme-hvac" + "&utm_medium=email&utm_campaign=spring-seller-tips", + ) + + def test_sms_replaces_short_url(self): + updated = ensure_campaign_utm_in_text( + "Hi\n\nhttps://piha.li/a3k9xm", + "https://piha.li/zzzzzz", + ) + self.assertEqual(updated, "Hi\n\nhttps://piha.li/zzzzzz") + + +class ShortenerClientTests(SimpleTestCase): + @override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="") + def test_unconfigured_returns_none(self): + self.assertIsNone(mint_short_url("https://acmehvac.com/")) + self.assertEqual( + resolve_display_url("https://acmehvac.com/x"), + "https://acmehvac.com/x", + ) + + @override_settings( + SHORTENER_BASE_URL="https://shortener.aimloperations.com", + SHORTENER_API_TOKEN="acme:test-token", + ) + def test_mint_posts_bearer(self): + mock = MagicMock() + mock.status_code = 201 + mock.json.return_value = { + "code": "a3k9xm", + "short_url": "https://piha.li/a3k9xm", + } + with patch("core.shortener.requests.post", return_value=mock) as post: + short = mint_short_url( + "https://acmehvac.com/?utm_source=acme", + title="Spring", + external_ref="campaign-uuid", + ) + self.assertEqual(short, "https://piha.li/a3k9xm") + post.assert_called_once() + headers = post.call_args.kwargs["headers"] + self.assertEqual(headers["Authorization"], "Bearer acme:test-token") + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["target_url"], "https://acmehvac.com/?utm_source=acme") + self.assertEqual(payload["external_ref"], "campaign-uuid") + + @override_settings( + SHORTENER_BASE_URL="https://shortener.aimloperations.com", + SHORTENER_API_TOKEN="acme:test-token", + SITE_NAME="Acme HVAC", + UTM_SOURCE="", + PUBLIC_SITE_URL="https://acmehvac.com", + ) + def test_ensure_link_falls_back_on_401(self): + mock = MagicMock() + mock.status_code = 401 + mock.text = '{"detail":"Unauthorized"}' + with patch("core.shortener.requests.post", return_value=mock): + body = ensure_campaign_utm_link( + "See you Saturday", + name="Open house", + medium="sms", + html=False, + campaign_id="11111111-1111-1111-1111-111111111111", + ) + self.assertIn("utm_medium=sms", body) + self.assertIn("utm_campaign=open-house", body) + + +class CampaignShortLinkViewTests(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + + User = get_user_model() + self.user = User.objects.create_user( + username="utm-composer", password="test-pass-123" + ) + self.client.login(username="utm-composer", password="test-pass-123") + + def test_requires_login(self): + from django.test import Client + + response = Client().post( + reverse("campaign_short_link"), + data='{"name":"Open house","medium":"sms"}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 302) + + @override_settings( + PUBLIC_SITE_URL="https://acmehvac.com", + SITE_NAME="Acme HVAC", + UTM_SOURCE="", + SHORTENER_BASE_URL="", + SHORTENER_API_TOKEN="", + ) + def test_unconfigured_returns_long_url(self): + response = self.client.post( + reverse("campaign_short_link"), + data='{"name":"Open house","medium":"sms"}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data["shortened"]) + self.assertIn("utm_medium=sms", data["display_url"]) + self.assertIn("utm_source=acme-hvac", data["display_url"]) + + @override_settings( + PUBLIC_SITE_URL="https://acmehvac.com", + SITE_NAME="Acme HVAC", + SHORTENER_BASE_URL="https://shortener.aimloperations.com", + SHORTENER_API_TOKEN="acme:test-token", + ) + def test_mints_short_url(self): + mock = MagicMock() + mock.status_code = 201 + mock.json.return_value = { + "code": "a3k9xm", + "short_url": "https://piha.li/a3k9xm", + } + with patch("core.shortener.requests.post", return_value=mock): + response = self.client.post( + reverse("campaign_short_link"), + data='{"name":"Open house","medium":"sms"}', + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data["shortened"]) + self.assertEqual(data["display_url"], "https://piha.li/a3k9xm") diff --git a/site/core/tests_features.py b/site/core/tests_features.py index 8997fdf..41594de 100644 --- a/site/core/tests_features.py +++ b/site/core/tests_features.py @@ -50,6 +50,8 @@ class AlwaysOnImportIsolationTests(SimpleTestCase): "dashboard/views.py", "public/views.py", "core/management/commands/dispatch_due.py", + "core/shortener.py", + "core/campaign_utm.py", "client_site/urls.py", ] diff --git a/site/core/views.py b/site/core/views.py index 266726c..f4ab0aa 100644 --- a/site/core/views.py +++ b/site/core/views.py @@ -1,9 +1,12 @@ import io +import json +from django.contrib.auth.decorators import login_required from django.http import FileResponse, JsonResponse from django.shortcuts import get_object_or_404 -from django.views.decorators.http import require_GET +from django.views.decorators.http import require_GET, require_POST +from core.campaign_utm import resolve_campaign_tracked_url from core.models import StoredFile @@ -24,3 +27,40 @@ def stored_file(request, pk): response["Content-Disposition"] = f'inline; filename="{stored.filename}"' response["Cache-Control"] = "public, max-age=86400" return response + + +@login_required +@require_POST +def campaign_short_link(request): + """Mint (or reuse) a short URL for the campaign tracked landing link. + + Browser talks to this portal endpoint only. The shortener is server-to-server. + Does not import optional campaign apps — campaign_id is only an external_ref. + """ + try: + payload = json.loads(request.body.decode() or "{}") + except (json.JSONDecodeError, UnicodeDecodeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + name = (payload.get("name") or request.POST.get("name") or "").strip() + medium = (payload.get("medium") or request.POST.get("medium") or "").strip() + campaign_id = ( + payload.get("campaign_id") or request.POST.get("campaign_id") or "" + ) + campaign_id = str(campaign_id).strip() or None + target, display = resolve_campaign_tracked_url( + name=name or "campaign", + medium=medium, + campaign_id=campaign_id, + user_id=getattr(request.user, "pk", None), + shorten=True, + ) + return JsonResponse( + { + "target_url": target, + "short_url": display if display != target else "", + "display_url": display, + "shortened": display != target, + } + ) diff --git a/site/directmail/templates/directmail/campaign_detail.html b/site/directmail/templates/directmail/campaign_detail.html index 445d697..1da1715 100644 --- a/site/directmail/templates/directmail/campaign_detail.html +++ b/site/directmail/templates/directmail/campaign_detail.html @@ -1,4 +1,5 @@ {% extends "portal_base.html" %} +{% load static %} {% block title %}{{ campaign.name }} · Campaign{% endblock %} {% block topbar_title %}{{ campaign.name }}{% endblock %} {% block portal_content %} @@ -51,6 +52,13 @@ {% endif %} +
+

Tracked site link

+
+ {% include "core/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %} +
+
+
Messages
@@ -208,7 +216,10 @@
{% endblock %} {% block extra_js %} + + + + diff --git a/site/directmail/tests.py b/site/directmail/tests.py index 321c81f..b549321 100644 --- a/site/directmail/tests.py +++ b/site/directmail/tests.py @@ -279,3 +279,65 @@ class PostcardDesignPickTests(TestCase): self.assertEqual(campaign.messages.count(), 1) +class PostcardUtmPanelTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="utm-mail", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="utm-mail", password="test-pass-123") + self.contact = Contact.objects.create( + email="mail@example.com", + first_name="Pat", + postal_address=Contact.make_postal_address(line1="9 Main"), + ) + + def test_composer_shows_tracked_link_panel(self): + with patch( + "directmail.views._fetch_pcm_designs", + return_value=([], ""), + ): + url = reverse("directmail:campaign_list") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Tracked site link") + self.assertContains(response, "campaign-utm.js") + self.assertContains(response, "qrcode.min.js") + self.assertContains(response, "data-utm-qr") + self.assertContains(response, reverse("campaign_short_link")) + + def test_postcard_does_not_inject_into_body(self): + campaign = create_campaign_draft( + name="March mailer", + audience=Campaign.Audience.POSTCARD_OPT_IN, + body="Internal note only", + created_by=self.user, + ) + self.assertEqual(campaign.body_override, "Internal note only") + self.assertNotIn("utm_source", campaign.body_override) + + @override_settings( + PUBLIC_SITE_URL="https://acmehvac.com", + SITE_NAME="Acme HVAC", + UTM_SOURCE="", + SHORTENER_BASE_URL="", + SHORTENER_API_TOKEN="", + ) + def test_detail_shows_tracked_url(self): + campaign = create_campaign_draft( + name="March mailer", + audience=Campaign.Audience.POSTCARD_OPT_IN, + body="Internal note only", + created_by=self.user, + ) + url = reverse("directmail:campaign_detail", kwargs={"pk": campaign.pk}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "utm_campaign=march-mailer") + self.assertContains(response, "utm_medium=postcard") + self.assertContains(response, "qrcode.min.js") + self.assertNotContains(response, "Internal note onlyutm") + + + diff --git a/site/directmail/urls.py b/site/directmail/urls.py index 0e8ef00..35ccbff 100644 --- a/site/directmail/urls.py +++ b/site/directmail/urls.py @@ -13,6 +13,11 @@ urlpatterns = [ name="campaign_status_json", ), path("campaigns//send/", views.campaign_send, name="campaign_send"), + path( + "campaigns/upload-image/", + views.campaign_image_upload, + name="campaign_image_upload", + ), path( "campaigns//messages//remove/", views.campaign_message_remove, diff --git a/site/directmail/views.py b/site/directmail/views.py index 388fcf3..e7fa8ff 100644 --- a/site/directmail/views.py +++ b/site/directmail/views.py @@ -27,6 +27,7 @@ from directmail.providers.postcard.pcm import ( list_designs, ) from contacts.consent import opted_in_contacts +from core.campaign_utm import utm_panel_context from core.scheduling import parse_scheduled_for from directmail.services import ( create_campaign_draft, @@ -58,8 +59,6 @@ _MAX_IMAGE_BYTES = 5 * 1024 * 1024 def _audience_choices() -> list[tuple[str, str]]: """Labeled audience options with live opted-in counts.""" rows = [ - (Campaign.Audience.POSTCARD_OPT_IN, Channel.EMAIL, "email"), - (Campaign.Audience.SMS_OPT_IN, Channel.SMS, "SMS"), (Campaign.Audience.POSTCARD_OPT_IN, Channel.POSTCARD, "postcard"), ] choices = [] @@ -453,21 +452,13 @@ def campaign_list(request): form_errors.append("Campaign name is required.") if audience not in Campaign.Audience.values: form_errors.append("Choose a recipient list.") - if audience == Campaign.Audience.POSTCARD_OPT_IN: + elif audience == Campaign.Audience.POSTCARD_OPT_IN: if not template or template.channel != Channel.POSTCARD: form_errors.append( "Choose a postcard design (create one under Postcard design)." ) if not body: body = "Postcard mailing" - else: - if not body: - form_errors.append("Body is required.") - if ( - audience == Campaign.Audience.POSTCARD_OPT_IN - and not subject - ): - form_errors.append("Subject is required for email campaigns.") scheduled_for = None try: @@ -505,6 +496,7 @@ def campaign_list(request): "form_data": form_data, "form_errors": form_errors, "image_upload_url": reverse("directmail:campaign_image_upload"), + **utm_panel_context(live=True, medium=Channel.POSTCARD), }, ) @@ -528,6 +520,7 @@ def campaign_detail(request, pk): "recent_events": ctx["recent_events"], "events_title": ctx["events_title"], "events_empty": ctx["events_empty"], + **utm_panel_context(campaign=campaign, live=False), "can_send": campaign.status in { Campaign.Status.DRAFT, diff --git a/site/email_sms/services.py b/site/email_sms/services.py index b66cdc6..c64f234 100644 --- a/site/email_sms/services.py +++ b/site/email_sms/services.py @@ -23,6 +23,7 @@ from contacts.consent import ( # noqa: F401 — re-export for tests + providers unsubscribe_all, ) from contacts.models import Channel, Contact +from core.campaign_utm import ensure_campaign_utm_link from core.scheduling import parse_scheduled_for from email_sms.models import Campaign, Message, MessageTemplate @@ -108,6 +109,17 @@ def create_campaign_draft( created_by=created_by, template=template, ) + if channel in (Channel.EMAIL, Channel.SMS): + body = ensure_campaign_utm_link( + body, + name=name, + medium=channel, + html=(channel == Channel.EMAIL), + campaign_id=campaign.pk, + ) + if body != campaign.body_override: + campaign.body_override = body + campaign.save(update_fields=["body_override", "updated_at"]) contacts = list(opted_in_contacts(channel)) Message.objects.bulk_create( [ diff --git a/site/email_sms/templates/email_sms/campaign_detail.html b/site/email_sms/templates/email_sms/campaign_detail.html index 1b3a913..90caa55 100644 --- a/site/email_sms/templates/email_sms/campaign_detail.html +++ b/site/email_sms/templates/email_sms/campaign_detail.html @@ -1,4 +1,5 @@ {% extends "portal_base.html" %} +{% load static %} {% block title %}{{ campaign.name }} · Campaign{% endblock %} {% block topbar_title %}{{ campaign.name }}{% endblock %} {% block portal_content %} @@ -51,6 +52,13 @@
{% endif %} +
+

Tracked site link

+
+ {% include "core/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %} +
+
+
Messages
@@ -208,7 +216,10 @@
{% endblock %} {% block extra_js %} + + + + diff --git a/site/email_sms/tests.py b/site/email_sms/tests.py index 2156eab..e24012d 100644 --- a/site/email_sms/tests.py +++ b/site/email_sms/tests.py @@ -1,5 +1,5 @@ from django.contrib.auth import get_user_model -from django.test import Client, TestCase +from django.test import Client, TestCase, override_settings from django.urls import reverse from contacts.models import Channel, ConsentRecord, Contact, Suppression @@ -1070,6 +1070,7 @@ class CampaignRecipientTableTests(TestCase): self.assertEqual(self.campaign.messages.count(), 2) +@override_settings(SHORTENER_BASE_URL="", SHORTENER_API_TOKEN="") class Smtp2goSmsSendTests(TestCase): def setUp(self): self.contact = Contact.objects.create( @@ -1127,7 +1128,8 @@ class Smtp2goSmsSendTests(TestCase): payload = post.call_args.kwargs["json"] self.assertEqual(payload["api_key"], "api-test-key") self.assertEqual(payload["destination"], ["+13304022675"]) - self.assertEqual(payload["content"], "Hello Rufus") + self.assertTrue(payload["content"].startswith("Hello Rufus")) + self.assertIn("utm_medium=sms", payload["content"]) self.assertNotIn("to", payload) self.assertNotIn("text", payload) @@ -1161,3 +1163,165 @@ class Smtp2goSmsSendTests(TestCase): self.assertIn("Missing required field", str(ctx.exception)) self.assertIn("INVALID_REQUEST", str(ctx.exception)) + + +class CampaignUtmLinkTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user( + username="utm-composer", password="test-pass-123" + ) + self.client = Client() + self.client.login(username="utm-composer", password="test-pass-123") + self.contact = Contact.objects.create( + email="pat@example.com", + phone="5550100199", + first_name="Pat", + ) + set_channel_consent( + self.contact, Channel.EMAIL, opted_in=True, reason="test" + ) + set_channel_consent( + self.contact, Channel.SMS, opted_in=True, reason="test" + ) + + @override_settings( + PUBLIC_SITE_URL="https://acmehvac.com", + SITE_NAME="Acme HVAC", + UTM_SOURCE="", + SHORTENER_BASE_URL="", + SHORTENER_API_TOKEN="", + ) + def test_email_draft_inserts_html_link(self): + campaign = create_campaign_draft( + name="Spring seller tips", + audience=Campaign.Audience.EMAIL_OPT_IN, + subject="Hello", + body="

Hi there

", + created_by=self.user, + ) + self.assertIn('data-campaign-utm="1"', campaign.body_override) + self.assertIn("utm_source=acme-hvac", campaign.body_override) + self.assertIn("utm_medium=email", campaign.body_override) + self.assertIn("utm_campaign=spring-seller-tips", campaign.body_override) + self.assertIn("Hi there", campaign.body_override) + + @override_settings( + PUBLIC_SITE_URL="https://acmehvac.com", + SITE_NAME="Acme HVAC", + UTM_SOURCE="", + SHORTENER_BASE_URL="", + SHORTENER_API_TOKEN="", + ) + def test_sms_draft_inserts_plain_url(self): + campaign = create_campaign_draft( + name="Open house", + audience=Campaign.Audience.SMS_OPT_IN, + body="See you Saturday", + created_by=self.user, + ) + self.assertIn("See you Saturday", campaign.body_override) + self.assertIn( + "https://acmehvac.com/?utm_source=acme-hvac&utm_medium=sms" + "&utm_campaign=open-house", + campaign.body_override, + ) + self.assertNotIn("