generated from westfarn/web_django_template
Add campaign UTM links and piha.li shortener as a core helper.
Keep mint/UTM in always-on core so email_sms and directmail stay optional, and brand utm_source from SITE_NAME (or UTM_SOURCE) instead of a hardcoded client name. Closes westfarn/web_django_template#3
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<code>.
|
||||
# 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
|
||||
|
||||
@@ -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=<slug>:<secret>
|
||||
```
|
||||
|
||||
Operator secrets (`url_shortening_service_*`): `SHORTENER_API_TOKENS=<slug>:<same-secret>`
|
||||
and `SHORT_ALLOWED_HOSTS` must include the client domain. Mint against the **API host**,
|
||||
never `piha.li`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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,
|
||||
'<a href="$1" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -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")),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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 <SHORTENER_API_TOKEN> (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
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="utm-panel" id="utm-link-panel"
|
||||
data-base-url="{{ utm_base_url }}"
|
||||
data-link-label="{{ utm_link_label }}"
|
||||
data-utm-source="{{ utm_source }}"
|
||||
data-medium="{{ utm_medium|default:'' }}"
|
||||
data-campaign-name="{{ utm_campaign_name|default:'' }}"
|
||||
data-shorten-url="{{ utm_shorten_url|default:'' }}"
|
||||
data-campaign-id="{{ utm_campaign_id }}"
|
||||
data-csrf="{{ csrf_token }}"
|
||||
data-live="{% if utm_live %}1{% else %}0{% endif %}">
|
||||
<div class="field" style="margin:0">
|
||||
<label>Tracked site link</label>
|
||||
<p class="hint" data-utm-email-hint hidden>
|
||||
Auto-added to the email as a styled link. Updates as you type the campaign name.
|
||||
<code>utm_medium=email</code> · <code>utm_campaign</code> matches the name.
|
||||
Recipients tap a short <code>piha.li</code> link that redirects to this URL.
|
||||
</p>
|
||||
<p class="hint" data-utm-sms-hint hidden>
|
||||
SMS is plain text — phones cannot show HTML links. A short <code>piha.li</code>
|
||||
URL is in the message and phones make it tappable. Preview below shows it as a link.
|
||||
</p>
|
||||
<p class="hint" data-utm-postcard-hint hidden>
|
||||
Copy the QR image into the postcard designer. Recipients scan it to open the
|
||||
short tracked link (<code>piha.li</code>).
|
||||
</p>
|
||||
<code class="utm-url" data-utm-url>{{ utm_url|default:"" }}</code>
|
||||
<div class="utm-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-url>Copy link</button>
|
||||
</div>
|
||||
<div class="utm-qr" data-utm-qr-wrap hidden>
|
||||
<canvas data-utm-qr width="192" height="192" aria-label="Campaign QR code"></canvas>
|
||||
<div class="utm-qr-actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm" data-utm-copy-qr>Copy QR image</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" data-utm-download-qr>Download PNG</button>
|
||||
{% if utm_postcard_designer_url %}
|
||||
<p class="hint" style="margin-top:8px">
|
||||
Paste or upload the PNG in
|
||||
<a href="{{ utm_postcard_designer_url }}">Postcard design</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" data-utm-status hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+41
-1
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-h"><h2>Tracked site link</h2></div>
|
||||
<div class="panel-b">
|
||||
{% include "core/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row" id="campaign-stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Messages</div>
|
||||
@@ -208,7 +216,10 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
||||
<script>
|
||||
window.CampaignUtm.bindPanel(document.getElementById("utm-link-panel"));
|
||||
(function () {
|
||||
var panel = document.getElementById("recipients-panel");
|
||||
var page = (panel && panel.getAttribute("data-page")) || "1";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Campaigns · Portal{% endblock %}
|
||||
{% block topbar_title %}Campaign composer{% endblock %}
|
||||
{% block extra_head %}
|
||||
@@ -64,6 +65,7 @@
|
||||
<label for="id_name">Campaign name</label>
|
||||
<input id="id_name" name="name" type="text" required
|
||||
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
||||
<div class="hint">Used as <code>utm_campaign</code> on the tracked site link.</div>
|
||||
</div>
|
||||
<div class="field" id="subject-field">
|
||||
<label for="id_subject">Subject</label>
|
||||
@@ -128,6 +130,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
||||
{% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
|
||||
<button class="btn btn-primary" type="submit">Save draft</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -179,6 +182,8 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var uploadUrl = "{{ image_upload_url|escapejs }}";
|
||||
@@ -283,6 +288,8 @@
|
||||
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
|
||||
a.classList.toggle('active', active);
|
||||
});
|
||||
var panel = document.getElementById('utm-link-panel');
|
||||
if (panel && panel._utmApply) panel._utmApply();
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
@@ -370,6 +377,13 @@
|
||||
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||
|
||||
initQuill();
|
||||
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
|
||||
getName: function () {
|
||||
return (document.getElementById('id_name') || {}).value || '';
|
||||
},
|
||||
getMedium: function () { return 'postcard'; },
|
||||
csrfToken: csrfToken
|
||||
});
|
||||
syncComposeChannel();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ urlpatterns = [
|
||||
name="campaign_status_json",
|
||||
),
|
||||
path("campaigns/<uuid:pk>/send/", views.campaign_send, name="campaign_send"),
|
||||
path(
|
||||
"campaigns/upload-image/",
|
||||
views.campaign_image_upload,
|
||||
name="campaign_image_upload",
|
||||
),
|
||||
path(
|
||||
"campaigns/<uuid:pk>/messages/<uuid:message_id>/remove/",
|
||||
views.campaign_message_remove,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="panel" style="margin-bottom:16px">
|
||||
<div class="panel-h"><h2>Tracked site link</h2></div>
|
||||
<div class="panel-b">
|
||||
{% include "core/_utm_link_panel.html" with utm_live=False utm_medium=campaign.channel utm_campaign_name=campaign.name %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-row" id="campaign-stats">
|
||||
<div class="stat-card">
|
||||
<div class="label">Messages</div>
|
||||
@@ -208,7 +216,10 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
||||
<script>
|
||||
window.CampaignUtm.bindPanel(document.getElementById("utm-link-panel"));
|
||||
(function () {
|
||||
var panel = document.getElementById("recipients-panel");
|
||||
var page = (panel && panel.getAttribute("data-page")) || "1";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Campaigns · Portal{% endblock %}
|
||||
{% block topbar_title %}Campaign composer{% endblock %}
|
||||
{% block extra_head %}
|
||||
@@ -63,6 +64,7 @@
|
||||
<label for="id_name">Campaign name</label>
|
||||
<input id="id_name" name="name" type="text" required
|
||||
placeholder="Spring seller tips" value="{{ form_data.name }}">
|
||||
<div class="hint">Used as <code>utm_campaign</code> on the tracked site link.</div>
|
||||
</div>
|
||||
<div class="field" id="subject-field">
|
||||
<label for="id_subject">Subject</label>
|
||||
@@ -102,6 +104,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
|
||||
{% include "core/_utm_link_panel.html" with utm_live=True %}
|
||||
<button class="btn btn-primary" type="submit">Save draft</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -153,6 +156,8 @@
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<script src="{% static 'js/campaign-utm.js' %}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var uploadUrl = "{{ image_upload_url|escapejs }}";
|
||||
@@ -160,11 +165,38 @@
|
||||
var bodyField = document.getElementById('id_body');
|
||||
var smsField = document.getElementById('id_body_sms');
|
||||
var quill = null;
|
||||
var utmLock = false;
|
||||
var utmLinkLabel = "{{ utm_link_label|escapejs }}";
|
||||
var utmSource = "{{ utm_source|escapejs }}";
|
||||
|
||||
function csrfHeader() {
|
||||
return { 'X-CSRFToken': csrfToken };
|
||||
}
|
||||
|
||||
function audienceChannel() {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
if (audience === 'sms_opt_in') return 'sms';
|
||||
if (audience === 'postcard_opt_in') return 'postcard';
|
||||
return 'email';
|
||||
}
|
||||
|
||||
function applyUtmToBodies(url, channel, label) {
|
||||
if (utmLock) return;
|
||||
utmLock = true;
|
||||
try {
|
||||
if (channel === 'email' && quill) {
|
||||
window.CampaignUtm.ensureQuillLink(quill, url, label || utmLinkLabel, utmSource);
|
||||
syncBodyFromQuill();
|
||||
} else if (channel === 'sms' && smsField) {
|
||||
smsField.value = window.CampaignUtm.replaceTextUrl(smsField.value, url, utmSource);
|
||||
if (bodyField) bodyField.value = smsField.value;
|
||||
syncCampaignPreview();
|
||||
}
|
||||
} finally {
|
||||
utmLock = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncBodyFromQuill() {
|
||||
if (!quill || !bodyField) return;
|
||||
var html = quill.root.innerHTML;
|
||||
@@ -188,6 +220,7 @@
|
||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isEmail = audience === 'email_opt_in';
|
||||
var isSms = audience === 'sms_opt_in';
|
||||
var isPostcard = audience === 'postcard_opt_in';
|
||||
var body = '';
|
||||
if (isEmail && quill) {
|
||||
@@ -218,6 +251,9 @@
|
||||
if (isEmail) {
|
||||
bodyEl.style.whiteSpace = 'normal';
|
||||
bodyEl.innerHTML = body;
|
||||
} else if (isSms) {
|
||||
bodyEl.style.whiteSpace = 'pre-wrap';
|
||||
bodyEl.innerHTML = window.CampaignUtm.linkify(body);
|
||||
} else {
|
||||
bodyEl.style.whiteSpace = 'pre-wrap';
|
||||
bodyEl.textContent = body;
|
||||
@@ -257,6 +293,8 @@
|
||||
var active = (ch === 'email' && isEmail) || (ch === 'sms' && isSms) || (ch === 'postcard' && isPostcard);
|
||||
a.classList.toggle('active', active);
|
||||
});
|
||||
var panel = document.getElementById('utm-link-panel');
|
||||
if (panel && panel._utmApply) panel._utmApply();
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
@@ -315,7 +353,10 @@
|
||||
} else if (initial) {
|
||||
quill.setText(initial);
|
||||
}
|
||||
quill.on('text-change', syncBodyFromQuill);
|
||||
quill.on('text-change', function () {
|
||||
if (utmLock) return;
|
||||
syncBodyFromQuill();
|
||||
});
|
||||
document.getElementById('campaign-compose').addEventListener('submit', function () {
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
if (audience === 'email_opt_in') syncBodyFromQuill();
|
||||
@@ -344,6 +385,14 @@
|
||||
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||
|
||||
initQuill();
|
||||
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
|
||||
getName: function () {
|
||||
return (document.getElementById('id_name') || {}).value || '';
|
||||
},
|
||||
getMedium: audienceChannel,
|
||||
csrfToken: csrfToken,
|
||||
onUrlChange: applyUtmToBodies
|
||||
});
|
||||
syncComposeChannel();
|
||||
})();
|
||||
</script>
|
||||
|
||||
+166
-2
@@ -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="<p>Hi there</p>",
|
||||
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("<a ", campaign.body_override)
|
||||
|
||||
def test_composer_shows_tracked_link_panel(self):
|
||||
url = reverse("email_sms: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, "utm_campaign")
|
||||
self.assertContains(response, reverse("campaign_short_link"))
|
||||
|
||||
@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="Spring seller tips",
|
||||
audience=Campaign.Audience.EMAIL_OPT_IN,
|
||||
subject="Hello",
|
||||
body="Hi",
|
||||
created_by=self.user,
|
||||
)
|
||||
url = reverse("email_sms:campaign_detail", kwargs={"pk": campaign.pk})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "utm_campaign=spring-seller-tips")
|
||||
self.assertContains(response, "utm_medium=email")
|
||||
self.assertContains(response, "qrcode.min.js")
|
||||
self.assertContains(response, "data-utm-qr")
|
||||
|
||||
@override_settings(
|
||||
PUBLIC_SITE_URL="https://acmehvac.com",
|
||||
SITE_NAME="Acme HVAC",
|
||||
UTM_SOURCE="",
|
||||
SHORTENER_BASE_URL="https://shortener.aimloperations.com",
|
||||
SHORTENER_API_TOKEN="acme:test-token",
|
||||
)
|
||||
def test_shortener_mints_into_sms_body(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
mock = MagicMock()
|
||||
mock.status_code = 201
|
||||
mock.json.return_value = {
|
||||
"code": "a3k9xm",
|
||||
"short_url": "https://piha.li/a3k9xm",
|
||||
"target_url": (
|
||||
"https://acmehvac.com/?utm_source=acme-hvac"
|
||||
"&utm_medium=sms&utm_campaign=open-house"
|
||||
),
|
||||
}
|
||||
with patch("core.shortener.requests.post", return_value=mock) as post:
|
||||
campaign = create_campaign_draft(
|
||||
name="Open house",
|
||||
audience=Campaign.Audience.SMS_OPT_IN,
|
||||
body="See you Saturday",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.assertIn("https://piha.li/a3k9xm", campaign.body_override)
|
||||
self.assertIn("See you Saturday", campaign.body_override)
|
||||
self.assertNotIn("utm_medium=sms", campaign.body_override)
|
||||
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.assertIn("utm_medium=sms", payload["target_url"])
|
||||
self.assertIn("utm_campaign=open-house", payload["target_url"])
|
||||
self.assertEqual(payload["external_ref"], str(campaign.pk))
|
||||
self.assertEqual(campaign.messages.get().body_snapshot, campaign.body_override)
|
||||
|
||||
@override_settings(
|
||||
PUBLIC_SITE_URL="https://acmehvac.com",
|
||||
SITE_NAME="Acme HVAC",
|
||||
UTM_SOURCE="",
|
||||
SHORTENER_BASE_URL="https://shortener.aimloperations.com",
|
||||
SHORTENER_API_TOKEN="acme:test-token",
|
||||
)
|
||||
def test_shortener_failure_falls_back_to_long_url(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
mock = MagicMock()
|
||||
mock.status_code = 401
|
||||
mock.text = '{"detail":"Unauthorized"}'
|
||||
with patch("core.shortener.requests.post", return_value=mock):
|
||||
campaign = create_campaign_draft(
|
||||
name="Open house",
|
||||
audience=Campaign.Audience.SMS_OPT_IN,
|
||||
body="See you Saturday",
|
||||
created_by=self.user,
|
||||
)
|
||||
self.assertIn("utm_medium=sms", campaign.body_override)
|
||||
self.assertIn("utm_campaign=open-house", campaign.body_override)
|
||||
self.assertNotIn("piha.li", campaign.body_override)
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ from django.views.decorators.http import require_GET, require_http_methods, requ
|
||||
|
||||
from contacts.consent import opted_in_contacts, record_sms_stop
|
||||
from contacts.models import Channel
|
||||
from core.campaign_utm import ensure_campaign_utm_link, utm_panel_context
|
||||
from core.models import StoredFile
|
||||
from core.scheduling import parse_scheduled_for
|
||||
from email_sms.models import Campaign, Message, ProviderEvent
|
||||
from email_sms.services import (
|
||||
channel_for_audience,
|
||||
create_campaign_draft,
|
||||
enqueue_campaign_send,
|
||||
message_is_removable,
|
||||
@@ -291,6 +293,16 @@ 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.")
|
||||
else:
|
||||
channel = channel_for_audience(audience)
|
||||
if channel in (Channel.EMAIL, Channel.SMS):
|
||||
body = ensure_campaign_utm_link(
|
||||
body,
|
||||
name=name or "campaign",
|
||||
medium=channel,
|
||||
html=(channel == Channel.EMAIL),
|
||||
)
|
||||
form_data["body"] = body
|
||||
if not body:
|
||||
form_errors.append("Body is required.")
|
||||
if audience == Campaign.Audience.EMAIL_OPT_IN and not subject:
|
||||
@@ -330,6 +342,7 @@ def campaign_list(request):
|
||||
"form_data": form_data,
|
||||
"form_errors": form_errors,
|
||||
"image_upload_url": reverse("email_sms:campaign_image_upload"),
|
||||
**utm_panel_context(live=True),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -353,6 +366,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,
|
||||
|
||||
Reference in New Issue
Block a user