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:
@@ -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