Improve campaign personalization and contact duplicate handling.
Deploy Beta / unit-tests (push) Successful in 13s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 1m44s

Add merge tags for email/SMS, paginated removable recipients, PCM event panels for postcard campaigns, and a portal modal when phone/address matches an existing contact.
This commit is contained in:
2026-08-10 09:47:04 -05:00
parent 58258f2875
commit 8680c082fe
13 changed files with 747 additions and 101 deletions
+3 -1
View File
@@ -5,7 +5,7 @@ from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from contacts.models import Channel
from messaging.services import one_click_unsubscribe_url, preferences_url
from messaging.services import one_click_unsubscribe_url, preferences_url, render_merge_tags
from public.email_branding import (
campaign_body_to_email_html,
campaign_body_to_plain_text,
@@ -28,6 +28,8 @@ def send_email(message) -> str:
body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else ""
)
subject = render_merge_tags(subject, contact)
body = render_merge_tags(body, contact)
site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
+3
View File
@@ -5,6 +5,8 @@ import logging
import requests
from django.conf import settings
from messaging.services import render_merge_tags
logger = logging.getLogger(__name__)
@@ -21,6 +23,7 @@ def send_sms(message) -> str:
body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else ""
)
body = render_merge_tags(body, contact)
payload = {
"api_key": api_key,
+54
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from datetime import datetime
from typing import TYPE_CHECKING
@@ -25,6 +26,49 @@ AUDIENCE_CHANNEL = {
UNSUB_SALT = "monica-site-unsubscribe"
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
_MERGE_TAG_RE = re.compile(
r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}"
r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}",
re.IGNORECASE,
)
REMOVABLE_MESSAGE_STATUSES = frozenset(
{
Message.Status.DRAFT,
Message.Status.SCHEDULED,
Message.Status.FAILED,
}
)
def render_merge_tags(text: str, contact: Contact | None) -> str:
"""Replace personalization tags with contact field values."""
if not text:
return text or ""
first = (getattr(contact, "first_name", None) or "").strip() if contact else ""
last = (getattr(contact, "last_name", None) or "").strip() if contact else ""
email = (getattr(contact, "email", None) or "").strip() if contact else ""
phone = (getattr(contact, "phone", None) or "").strip() if contact else ""
full = f"{first} {last}".strip()
values = {
"first_name": first,
"last_name": last,
"email": email,
"phone": phone,
"full_name": full,
}
def _replace(match: re.Match[str]) -> str:
key = (match.group(1) or match.group(2) or "").lower()
return values.get(key, "")
return _MERGE_TAG_RE.sub(_replace, text)
def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES
def contact_may_receive(contact: Contact, channel: str) -> bool:
if Suppression.objects.filter(
@@ -443,6 +487,16 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
if not body.strip():
raise ValueError("Campaign has no body.")
# Preview merge tags using first recipient when available.
sample = (
campaign.messages.select_related("contact")
.order_by("created_at")
.first()
)
sample_contact = sample.contact if sample else None
subject = render_merge_tags(subject, sample_contact)
body = render_merge_tags(body, sample_contact)
notice = (
"This is a test send from the Monica portal. "
"Recipient list was not notified."
@@ -39,7 +39,11 @@
{% csrf_token %}
<button class="btn btn-primary" type="submit">Send now to recipients</button>
<p class="hint-block" style="margin-top:8px">
{% if campaign.channel == "postcard" %}
Enqueues draft / scheduled / failed messages via PCM Integrations.
{% else %}
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
{% endif %}
</p>
</form>
{% endif %}
@@ -60,6 +64,7 @@
<div class="label">Delivered</div>
<div class="value" data-stat="delivered">{{ stats.delivered }}</div>
</div>
{% if campaign.channel == "email" %}
<div class="stat-card">
<div class="label">Opens</div>
<div class="value" data-stat="opens">{{ stats.opens }}</div>
@@ -68,6 +73,7 @@
<div class="label">Clicks</div>
<div class="value" data-stat="clicks">{{ stats.clicks }}</div>
</div>
{% endif %}
<div class="stat-card">
<div class="label">Bounced / failed</div>
<div class="value" data-stat="failed">{{ stats.failed }}</div>
@@ -82,21 +88,32 @@
<div class="panel">
<div class="panel-h"><h2>Engagement</h2></div>
<div class="panel-b">
{% if campaign.channel == "email" %}
<p class="hint-block" style="margin-top:0">
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
</p>
{% elif campaign.channel == "sms" %}
<p class="hint-block" style="margin-top:0">
Delivery status updates from SMTP2GO SMS webhooks.
</p>
{% else %}
<p class="hint-block" style="margin-top:0">
Postcard status updates from PCM Integrations webhooks.
</p>
{% endif %}
<div class="chart-placeholder" aria-hidden="true">
<div class="bar" style="height:55%"></div>
<div class="bar" style="height:70%"></div>
<div class="bar" style="height:40%"></div>
<div class="bar" style="height:30%"></div>
<div class="bar" style="height:20%"></div>
</div> </div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recent SMTP2GO events</h2></div>
<div class="panel-h"><h2>{{ events_title }}</h2></div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead>
@@ -108,7 +125,7 @@
<td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="3" class="empty-state">No webhook events yet. SMTP2GO must POST opens/clicks to <code>/portal/messaging/webhooks/email/</code> (see messaging README). SMTP2GOs own “Clicked” feed does not fill this table by itself.</td></tr>
<tr><td colspan="3" class="empty-state">{{ events_empty|safe }}</td></tr>
{% endfor %}
</tbody>
</table>
@@ -116,15 +133,23 @@
</div>
</div>
<div class="panel">
<div class="panel-h"><h2>Recipients</h2></div>
<div class="panel" id="recipients-panel" data-page="{{ page_obj.number }}">
<div class="panel-h" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<h2 style="margin:0">Recipients</h2>
<span class="muted" style="font-size:13px">
{{ page_obj.paginator.count }} total
{% if page_obj.paginator.num_pages > 1 %}
· page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
{% endif %}
</span>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th></tr>
<tr><th>Contact</th><th>Status</th><th>Provider id</th><th>Error</th><th></th></tr>
</thead>
<tbody id="recipients-body">
{% for message in messages %}
{% for message in recipient_messages %}
<tr data-message-id="{{ message.pk }}">
<td>
<div>{{ message.contact }}</div>
@@ -135,24 +160,56 @@
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td>
<td class="muted">{{ message.provider_message_id|default:"—" }}</td>
<td class="muted">{{ message.error|truncatechars:60|default:"—" }}</td>
<td style="white-space:nowrap;text-align:right">
{% if message.can_remove %}
<form method="post"
action="{% url 'messaging:campaign_message_remove' campaign.pk message.pk %}"
style="display:inline"
onsubmit="return confirm('Remove this recipient from the campaign?');">
{% csrf_token %}
<input type="hidden" name="page" value="{{ page_obj.number }}">
<button class="btn btn-ghost btn-sm" type="submit">Remove</button>
</form>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>
<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<div class="panel-b" style="display:flex;gap:8px;align-items:center;justify-content:flex-end;border-top:1px solid var(--monica-border)">
{% if page_obj.has_previous %}
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.previous_page_number }}">← Prev</a>
{% endif %}
<span class="muted" style="font-size:13px">Page {{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a class="btn btn-ghost btn-sm" href="?page={{ page_obj.next_page_number }}">Next →</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
var url = "{% url 'messaging:campaign_status_json' campaign.pk %}";
var panel = document.getElementById("recipients-panel");
var page = (panel && panel.getAttribute("data-page")) || "1";
var removeBase = "{% url 'messaging:campaign_message_remove' campaign.pk '00000000-0000-0000-0000-000000000000' %}";
var csrfToken = "{{ csrf_token }}";
var url = "{% url 'messaging:campaign_status_json' campaign.pk %}?page=" + encodeURIComponent(page);
function esc(s) {
return String(s || "").replace(/[&<>"']/g, function (c) {
return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
});
}
function removeUrl(id) {
return removeBase.replace("00000000-0000-0000-0000-000000000000", id);
}
function apply(data) {
var badge = document.getElementById("campaign-status-badge");
if (badge) {
@@ -167,17 +224,25 @@
var body = document.getElementById("recipients-body");
if (body && data.messages) {
if (!data.messages.length) {
body.innerHTML = '<tr><td colspan="4" class="empty-state">No messages on this campaign.</td></tr>';
body.innerHTML = '<tr><td colspan="5" class="empty-state">No messages on this campaign.</td></tr>';
} else {
body.innerHTML = data.messages.map(function (m) {
var dest = m.destination
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>'
: '';
var action = m.can_remove
? '<form method="post" action="' + esc(removeUrl(m.id)) + '" style="display:inline" ' +
'onsubmit="return confirm(\'Remove this recipient from the campaign?\');">' +
'<input type="hidden" name="csrfmiddlewaretoken" value="' + esc(csrfToken) + '">' +
'<input type="hidden" name="page" value="' + esc(page) + '">' +
'<button class="btn btn-ghost btn-sm" type="submit">Remove</button></form>'
: '<span class="muted">—</span>';
return "<tr data-message-id=\"" + esc(m.id) + "\">" +
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></td>" +
"<td class=\"muted\">" + esc(m.provider_message_id || "—") + "</td>" +
"<td class=\"muted\">" + esc(m.error || "—") + "</td></tr>";
"<td class=\"muted\">" + esc(m.error || "—") + "</td>" +
"<td style=\"white-space:nowrap;text-align:right\">" + action + "</td></tr>";
}).join("");
}
}
@@ -77,14 +77,14 @@
<label>Body</label>
<div id="email-editor"></div>
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea>
<div class="hint">Bold, fonts, sizes, links, images · merge tags: {first_name}, {last_name}</div>
<div class="hint">Bold, fonts, sizes, links, images · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
</div>
<div class="field" id="sms-body-wrap" hidden>
<label for="id_body_sms">Body</label>
<textarea id="id_body_sms" style="min-height:140px"
placeholder="Hi {first_name}, …"
placeholder="Hi {% templatetag openvariable %}first_name{% templatetag closevariable %}, …"
oninput="syncSmsBody()">{{ form_data.body }}</textarea>
<div class="hint">Plain text for SMS · keep it short</div>
<div class="hint">Plain text for SMS · keep it short · merge tags: <code>{% templatetag openvariable %}first_name{% templatetag closevariable %}</code>, <code>{% templatetag openvariable %}last_name{% templatetag closevariable %}</code></div>
</div>
<div class="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0">
+141
View File
@@ -140,6 +140,10 @@ class PortalConsentToggleTests(TestCase):
response = self.client.post(
url,
{
"first_name": "Sam",
"last_name": "",
"email": "sam@example.com",
"phone": "",
"notes": "updated",
"consent_sms": "1",
"consent_postcard": "1",
@@ -155,6 +159,32 @@ class PortalConsentToggleTests(TestCase):
)
self.assertEqual(email_consent.reason, "portal_manual")
def test_portal_can_update_profile(self):
url = reverse("contacts:detail", kwargs={"pk": self.contact.pk})
response = self.client.post(
url,
{
"first_name": "Samantha",
"last_name": "Lee",
"email": "sam.lee@example.com",
"phone": "6305550100",
"address_line1": "10 Main St",
"address_city": "Naperville",
"address_state": "IL",
"address_zip": "60540",
"notes": "VIP",
"consent_email": "1",
},
)
self.assertEqual(response.status_code, 302)
self.contact.refresh_from_db()
self.assertEqual(self.contact.first_name, "Samantha")
self.assertEqual(self.contact.last_name, "Lee")
self.assertEqual(self.contact.email, "sam.lee@example.com")
self.assertEqual(self.contact.phone, "6305550100")
self.assertEqual(self.contact.postal_address.get("line1"), "10 Main St")
self.assertEqual(self.contact.notes, "VIP")
class CampaignDraftSaveTests(TestCase):
def setUp(self):
@@ -899,3 +929,114 @@ class StoredFileUploadTests(TestCase):
self.assertEqual(fetch.status_code, 200)
self.assertEqual(fetch["Content-Type"], "image/png")
self.assertEqual(b"".join(fetch.streaming_content), png)
class MergeTagTests(TestCase):
def test_render_merge_tags_double_and_single_braces(self):
from messaging.services import render_merge_tags
contact = Contact.objects.create(
email="pat@example.com",
first_name="Pat",
last_name="Lee",
phone="6305551212",
)
text = "Hi {{first_name}} {{last_name}} / {first_name} {email}"
out = render_merge_tags(text, contact)
self.assertEqual(out, "Hi Pat Lee / Pat pat@example.com")
def test_email_send_substitutes_merge_tags(self):
from django.core import mail
from messaging.providers.email.smtp2go import send_email
from messaging.services import create_campaign_draft, set_channel_consent
User = get_user_model()
user = User.objects.create_user(username="merge", password="x")
contact = Contact.objects.create(
email="pat@example.com", first_name="Pat", last_name="Lee"
)
set_channel_consent(contact, Channel.EMAIL, opted_in=True, reason="test")
campaign = create_campaign_draft(
name="Merge",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hello {{first_name}}",
body="<p>Dear {{first_name}} {{last_name}}</p>",
created_by=user,
)
send_email(campaign.messages.get())
self.assertEqual(mail.outbox[0].subject, "Hello Pat")
html = mail.outbox[0].alternatives[0][0]
self.assertIn("Dear Pat Lee", html)
self.assertNotIn("{{first_name}}", html)
class CampaignRecipientTableTests(TestCase):
def setUp(self):
from messaging.services import create_campaign_draft, set_channel_consent
User = get_user_model()
self.user = User.objects.create_user(
username="recip", password="test-pass-123"
)
self.client = Client()
self.client.login(username="recip", password="test-pass-123")
self.contacts = []
for i in range(3):
c = Contact.objects.create(
email=f"c{i}@example.com", first_name=f"Name{i}"
)
set_channel_consent(c, Channel.EMAIL, opted_in=True, reason="test")
self.contacts.append(c)
self.campaign = create_campaign_draft(
name="Recipients",
audience=Campaign.Audience.EMAIL_OPT_IN,
subject="Hi",
body="Body",
created_by=self.user,
)
def test_detail_does_not_flash_message_objects(self):
url = reverse(
"messaging:campaign_detail", kwargs={"pk": self.campaign.pk}
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "email →")
self.assertContains(response, "Recipients")
self.assertContains(response, "Remove")
def test_remove_recipient(self):
msg = self.campaign.messages.first()
url = reverse(
"messaging:campaign_message_remove",
kwargs={"pk": self.campaign.pk, "message_id": msg.pk},
)
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(self.campaign.messages.count(), 2)
def test_postcard_shows_pcm_events_title(self):
from messaging.services import create_campaign_draft
Contact.objects.create(
email="pc@example.com",
postal_address=Contact.make_postal_address(line1="1 Main"),
)
campaign = create_campaign_draft(
name="Cards",
audience=Campaign.Audience.POSTCARD_OPT_IN,
body="Postcard mailing",
created_by=self.user,
template=MessageTemplate.objects.create(
name="Design",
channel=Channel.POSTCARD,
body="",
postcard_front={"design_id": "99"},
),
)
url = reverse("messaging:campaign_detail", kwargs={"pk": campaign.pk})
response = self.client.get(url)
self.assertContains(response, "Recent PCM Integrations events")
self.assertNotContains(response, "Recent SMTP2GO events")
+5
View File
@@ -18,6 +18,11 @@ urlpatterns = [
views.campaign_test_send,
name="campaign_test_send",
),
path(
"campaigns/<uuid:pk>/messages/<uuid:message_id>/remove/",
views.campaign_message_remove,
name="campaign_message_remove",
),
path(
"campaigns/upload-image/",
views.campaign_image_upload,
+96 -10
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.core.validators import validate_email
from django.http import FileResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
@@ -15,7 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from contacts.models import Channel
from messaging.models import Campaign, MessageTemplate, ProviderEvent, StoredFile
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent, StoredFile
from messaging.providers.postcard.pcm import (
PCM_SIZE_CHOICES,
PcmApiError,
@@ -27,12 +28,16 @@ from messaging.providers.postcard.pcm import (
from messaging.services import (
create_campaign_draft,
enqueue_campaign_send,
message_is_removable,
opted_in_contacts,
parse_scheduled_for,
record_sms_stop,
send_campaign_test_email,
)
from messaging.webhooks import (
PROVIDER_EMAIL,
PROVIDER_PCM,
PROVIDER_SMS,
campaign_engagement_stats,
is_inbound_sms_stop,
parse_webhook_payload,
@@ -43,6 +48,8 @@ from messaging.webhooks import (
logger = logging.getLogger(__name__)
RECIPIENTS_PER_PAGE = 50
_ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"}
)
@@ -227,20 +234,59 @@ def _message_destination(message) -> str:
return ""
def _campaign_report(campaign: Campaign) -> dict:
messages_qs = list(campaign.messages.select_related("contact").all()[:200])
for msg in messages_qs:
def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]:
"""Return (provider codes, panel title, empty-state hint) for campaign channel."""
if campaign.channel == Channel.POSTCARD:
return (
[PROVIDER_PCM],
"Recent PCM Integrations events",
"No PCM webhook events yet. PCM must POST to "
"<code>/portal/messaging/webhooks/postcard/</code>.",
)
if campaign.channel == Channel.SMS:
return (
[PROVIDER_SMS],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST SMS events to "
"<code>/portal/messaging/webhooks/sms/</code>.",
)
return (
[PROVIDER_EMAIL],
"Recent SMTP2GO events",
"No webhook events yet. SMTP2GO must POST opens/clicks to "
"<code>/portal/messaging/webhooks/email/</code> "
"(see messaging README). SMTP2GOs own “Clicked” feed does not fill "
"this table by itself.",
)
def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
qs = campaign.messages.select_related("contact").order_by(
"contact__first_name", "contact__last_name", "created_at"
)
paginator = Paginator(qs, RECIPIENTS_PER_PAGE)
page_obj = paginator.get_page(page)
recipient_messages = list(page_obj.object_list)
for msg in recipient_messages:
msg.destination = _message_destination(msg)
msg.can_remove = message_is_removable(msg)
stats = campaign_engagement_stats(campaign)
providers, events_title, events_empty = _events_provider_filter(campaign)
recent_events = (
ProviderEvent.objects.filter(message__campaign=campaign)
ProviderEvent.objects.filter(
message__campaign=campaign,
provider__in=providers,
)
.select_related("message", "message__contact")
.order_by("-created_at")[:25]
)
return {
"messages": messages_qs,
"recipient_messages": recipient_messages,
"page_obj": page_obj,
"stats": stats,
"recent_events": recent_events,
"events_title": events_title,
"events_empty": events_empty,
}
@@ -467,15 +513,22 @@ def campaign_list(request):
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
ctx = _campaign_report(campaign)
try:
page = max(1, int(request.GET.get("page") or 1))
except (TypeError, ValueError):
page = 1
ctx = _campaign_report(campaign, page=page)
return render(
request,
"messaging/campaign_detail.html",
{
"campaign": campaign,
"messages": ctx["messages"],
"recipient_messages": ctx["recipient_messages"],
"page_obj": ctx["page_obj"],
"stats": ctx["stats"],
"recent_events": ctx["recent_events"],
"events_title": ctx["events_title"],
"events_empty": ctx["events_empty"],
"can_send": campaign.status
in {
Campaign.Status.DRAFT,
@@ -499,12 +552,19 @@ def campaign_status_json(request, pk):
refresh_campaign_status(campaign)
campaign.refresh_from_db()
ctx = _campaign_report(campaign)
try:
page = max(1, int(request.GET.get("page") or 1))
except (TypeError, ValueError):
page = 1
ctx = _campaign_report(campaign, page=page)
page_obj = ctx["page_obj"]
return JsonResponse(
{
"status": campaign.status,
"status_display": campaign.get_status_display(),
"stats": ctx["stats"],
"page": page_obj.number,
"num_pages": page_obj.paginator.num_pages,
"messages": [
{
"id": str(m.pk),
@@ -514,8 +574,9 @@ def campaign_status_json(request, pk):
"status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "",
"error": (m.error or "")[:120],
"can_remove": bool(getattr(m, "can_remove", False)),
}
for m in ctx["messages"]
for m in ctx["recipient_messages"]
],
"events": [
{
@@ -529,6 +590,31 @@ def campaign_status_json(request, pk):
)
@login_required
@require_POST
def campaign_message_remove(request, pk, message_id):
"""Drop a draft/scheduled/failed recipient from the campaign."""
campaign = get_object_or_404(Campaign, pk=pk)
message = get_object_or_404(Message, pk=message_id, campaign=campaign)
if not message_is_removable(message):
messages.error(
request,
"Only draft, scheduled, or failed recipients can be removed.",
)
return redirect("messaging:campaign_detail", pk=campaign.pk)
label = str(message.contact)
message.delete()
messages.success(request, f"Removed {label} from this campaign.")
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
if page and page.isdigit() and int(page) > 1:
return redirect(
f"{reverse('messaging:campaign_detail', kwargs={'pk': campaign.pk})}"
f"?page={page}"
)
return redirect("messaging:campaign_detail", pk=campaign.pk)
@login_required
@require_POST
def campaign_send(request, pk):