Mint campaign UTM links through the piha.li shortener (#10)
## Summary Closes #9. - Campaign composer auto-inserts a tracked homepage link (`utm_source=monica`, `utm_medium` = channel, `utm_campaign` = slug of the name) for email, SMS, and postcard QR — no manual UTM paste. - When `SHORTENER_BASE_URL` + `SHORTENER_API_TOKEN` are set, the app mints that long HTTPS URL via `POST /api/links/` on the shortener **API host** and puts the returned `piha.li` / `beta.piha.li` short URL in SMS, email hrefs, and QR codes. Empty env (local) falls back to the long UTM URL. - Live composer resolves shorts through a portal JSON endpoint (login + CSRF). Browser never calls the shortener. ## Secrets (control node, not git) **monica_site** (`~/Documents/secrets/monica_site/`): ``` # prod SHORTENER_BASE_URL=https://shortener.aimloperations.com SHORTENER_API_TOKEN=monica:<secret> # beta SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com SHORTENER_API_TOKEN=monica:<beta-secret> ``` **url_shortening_service** (same secret, named token): ``` SHORTENER_API_TOKENS=monica:<secret> SHORT_ALLOWED_HOSTS=mkdrealtor.com,aimloperations.com ``` Prod public short host: `piha.li`. Beta: `beta.piha.li`. Generate with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. Template port: westfarn/web_django_template#3 ## Test plan - [ ] `cd site && uv run python manage.py test messaging.tests.CampaignUtmLinkTests` - [ ] Composer: type a campaign name — email gets an HTML link, SMS gets a URL, postcard shows a QR - [ ] With shortener env set: SMS/QR show `piha.li` (or `beta.piha.li`); without it, long UTM URL still works - [ ] Copy/download QR into postcard designer - [ ] Secret files have `SHORTENER_*` on both caller and operator sides before beta/prod deploy Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
@@ -26,12 +27,17 @@ from messaging.providers.postcard.pcm import (
|
||||
list_designs,
|
||||
)
|
||||
from messaging.services import (
|
||||
channel_for_audience,
|
||||
create_campaign_draft,
|
||||
enqueue_campaign_send,
|
||||
ensure_campaign_utm_link,
|
||||
message_is_removable,
|
||||
opted_in_contacts,
|
||||
parse_scheduled_for,
|
||||
public_site_base_url,
|
||||
public_site_link_label,
|
||||
record_sms_stop,
|
||||
resolve_campaign_tracked_url,
|
||||
send_campaign_test_email,
|
||||
)
|
||||
from messaging.webhooks import (
|
||||
@@ -462,6 +468,16 @@ def campaign_list(request):
|
||||
if not body:
|
||||
body = "Postcard mailing"
|
||||
else:
|
||||
if audience in Campaign.Audience.values:
|
||||
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 (
|
||||
@@ -506,10 +522,62 @@ def campaign_list(request):
|
||||
"form_data": form_data,
|
||||
"form_errors": form_errors,
|
||||
"image_upload_url": reverse("messaging:campaign_image_upload"),
|
||||
"utm_base_url": public_site_base_url(),
|
||||
"utm_link_label": public_site_link_label(),
|
||||
"utm_shorten_url": reverse("messaging:campaign_short_link"),
|
||||
"utm_url": "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
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()
|
||||
if campaign_id:
|
||||
try:
|
||||
campaign = Campaign.objects.filter(pk=campaign_id).first()
|
||||
except (ValidationError, ValueError):
|
||||
campaign = None
|
||||
if campaign is None:
|
||||
return JsonResponse({"detail": "Campaign not found."}, status=404)
|
||||
name = name or campaign.name
|
||||
medium = medium or campaign.channel
|
||||
campaign_id = str(campaign.pk)
|
||||
else:
|
||||
campaign_id = 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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def campaign_detail(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
@@ -518,6 +586,12 @@ def campaign_detail(request, pk):
|
||||
except (TypeError, ValueError):
|
||||
page = 1
|
||||
ctx = _campaign_report(campaign, page=page)
|
||||
_target, display_url = resolve_campaign_tracked_url(
|
||||
name=campaign.name,
|
||||
medium=campaign.channel,
|
||||
campaign_id=campaign.pk,
|
||||
shorten=True,
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"messaging/campaign_detail.html",
|
||||
@@ -529,6 +603,10 @@ def campaign_detail(request, pk):
|
||||
"recent_events": ctx["recent_events"],
|
||||
"events_title": ctx["events_title"],
|
||||
"events_empty": ctx["events_empty"],
|
||||
"utm_url": display_url,
|
||||
"utm_base_url": public_site_base_url(),
|
||||
"utm_link_label": public_site_link_label(),
|
||||
"utm_shorten_url": reverse("messaging:campaign_short_link"),
|
||||
"can_send": campaign.status
|
||||
in {
|
||||
Campaign.Status.DRAFT,
|
||||
|
||||
Reference in New Issue
Block a user