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
+77 -40
View File
@@ -60,6 +60,9 @@ def find_matching_contact(
email: str = "", email: str = "",
phone: str = "", phone: str = "",
postal_address: dict | None = None, postal_address: dict | None = None,
match_email: bool = True,
match_phone: bool = True,
match_address: bool = True,
) -> tuple[Contact | None, str]: ) -> tuple[Contact | None, str]:
""" """
Resolve an existing contact. Resolve an existing contact.
@@ -68,11 +71,12 @@ def find_matching_contact(
Returns (contact, reason) where reason is email|phone|address|"". Returns (contact, reason) where reason is email|phone|address|"".
""" """
email_norm = (email or "").strip().lower() email_norm = (email or "").strip().lower()
if email_norm: if match_email and email_norm:
hit = Contact.objects.filter(email__iexact=email_norm).first() hit = Contact.objects.filter(email__iexact=email_norm).first()
if hit: if hit:
return hit, "email" return hit, "email"
if match_phone:
phone_digits = normalize_phone_digits(phone) phone_digits = normalize_phone_digits(phone)
if len(phone_digits) >= _PHONE_MIN_DIGITS: if len(phone_digits) >= _PHONE_MIN_DIGITS:
tail = phone_digits[-10:] tail = phone_digits[-10:]
@@ -81,7 +85,7 @@ def find_matching_contact(
if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail: if len(other) >= _PHONE_MIN_DIGITS and other[-10:] == tail:
return Contact.objects.get(pk=row.pk), "phone" return Contact.objects.get(pk=row.pk), "phone"
if Contact.postal_address_has_content(postal_address): if match_address and Contact.postal_address_has_content(postal_address):
line1 = (postal_address.get("line1") or "").strip() line1 = (postal_address.get("line1") or "").strip()
zip_code = (postal_address.get("zip") or "").strip() zip_code = (postal_address.get("zip") or "").strip()
candidates = Contact.objects.exclude(postal_address={}) candidates = Contact.objects.exclude(postal_address={})
@@ -110,45 +114,18 @@ def _merge_notes(existing: str, addition: str) -> str:
return f"{existing}\n{addition}".strip() return f"{existing}\n{addition}".strip()
def upsert_contact( def _apply_contact_fields(
existing: Contact,
*, *,
email: str, email_norm: str,
first_name: str = "", first_name: str,
last_name: str = "", last_name: str,
phone: str = "", phone: str,
postal_address: dict | None = None, postal: dict,
source: str = Contact.Source.CONTACT_FORM, has_postal: bool,
notes_append: str = "", source: str,
) -> tuple[Contact, bool, str]: notes_append: str,
""" ) -> None:
Find or create a contact, merging on email / phone / address.
Returns (contact, created, match_reason).
When merged onto a different email, the submitted email is noted in ``notes``.
"""
email_norm = (email or "").strip().lower()
phone = (phone or "").strip()
postal = postal_address if isinstance(postal_address, dict) else {}
has_postal = Contact.postal_address_has_content(postal)
existing, reason = find_matching_contact(
email=email_norm,
phone=phone,
postal_address=postal if has_postal else None,
)
if existing is None:
contact = Contact.objects.create(
email=email_norm or None,
first_name=(first_name or "").strip(),
last_name=(last_name or "").strip(),
phone=phone,
postal_address=postal if has_postal else {},
source=source,
notes=(notes_append or "").strip(),
)
return contact, True, ""
changed: list[str] = [] changed: list[str] = []
if first_name and first_name.strip(): if first_name and first_name.strip():
existing.first_name = first_name.strip() existing.first_name = first_name.strip()
@@ -199,4 +176,64 @@ def upsert_contact(
fields = sorted(set(changed) | {"updated_at"}) fields = sorted(set(changed) | {"updated_at"})
existing.save(update_fields=fields) existing.save(update_fields=fields)
def upsert_contact(
*,
email: str,
first_name: str = "",
last_name: str = "",
phone: str = "",
postal_address: dict | None = None,
source: str = Contact.Source.CONTACT_FORM,
notes_append: str = "",
merge_phone_address: bool = True,
merge_into: Contact | None = None,
) -> tuple[Contact, bool, str]:
"""
Find or create a contact, merging on email / phone / address.
Returns (contact, created, match_reason).
``merge_phone_address=False`` only merges on exact email.
``merge_into`` forces merge into that contact row.
"""
email_norm = (email or "").strip().lower()
phone = (phone or "").strip()
postal = postal_address if isinstance(postal_address, dict) else {}
has_postal = Contact.postal_address_has_content(postal)
if merge_into is not None:
existing, reason = merge_into, "manual"
else:
existing, reason = find_matching_contact(
email=email_norm,
phone=phone,
postal_address=postal if has_postal else None,
match_phone=merge_phone_address,
match_address=merge_phone_address,
)
if existing is None:
contact = Contact.objects.create(
email=email_norm or None,
first_name=(first_name or "").strip(),
last_name=(last_name or "").strip(),
phone=phone,
postal_address=postal if has_postal else {},
source=source,
notes=(notes_append or "").strip(),
)
return contact, True, ""
_apply_contact_fields(
existing,
email_norm=email_norm,
first_name=first_name,
last_name=last_name,
phone=phone,
postal=postal,
has_postal=has_postal,
source=source,
notes_append=notes_append,
)
return existing, False, reason return existing, False, reason
+78 -3
View File
@@ -4,10 +4,32 @@
{% block topbar_title %}New contact{% endblock %} {% block topbar_title %}New contact{% endblock %}
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}"> <link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
<style>
.portal-modal-backdrop {
position: fixed; inset: 0; background: rgba(17, 24, 39, 0.45);
display: flex; align-items: center; justify-content: center;
z-index: 1000; padding: 16px;
}
.portal-modal {
background: #fff; border: 1px solid var(--monica-border);
max-width: 480px; width: 100%; padding: 20px 22px;
box-shadow: 0 12px 40px rgba(0,0,0,0.18);
}
.portal-modal h3 { margin: 0 0 8px; font-size: 18px; }
.portal-modal p { margin: 0 0 12px; font-size: 14px; color: var(--monica-muted); line-height: 1.45; }
.portal-modal .match-card {
background: #f8fafc; border: 1px solid var(--monica-border);
padding: 12px; margin: 0 0 16px; font-size: 14px;
}
.portal-modal .match-card strong { display: block; margin-bottom: 4px; }
.portal-modal-actions { display: flex; gap: 8px; flex-wrap: wrap; }
</style>
{% endblock %} {% endblock %}
{% block portal_content %} {% block portal_content %}
<form method="post"> <form method="post" id="contact-create-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="resolve_match" id="id_resolve_match" value="">
<input type="hidden" name="match_id" id="id_match_id" value="{% if match_prompt %}{{ match_prompt.contact.pk }}{% endif %}">
<div class="split"> <div class="split">
<div class="panel"> <div class="panel">
<div class="panel-h"><h2>Profile</h2></div> <div class="panel-h"><h2>Profile</h2></div>
@@ -87,14 +109,67 @@
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if form.consent_postcard %}checked{% endif %}> Postcard mailings</label> <label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if form.consent_postcard %}checked{% endif %}> Postcard mailings</label>
</div> </div>
<p class="hint-block" style="margin-top:16px"> <p class="hint-block" style="margin-top:16px">
Matches existing contacts by email, phone, or address when possible. Same email always updates that contact. Same phone or address asks whether to update or create new.
Postcard defaults on when an address is saved.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</form> </form>
{% if match_prompt %}
<div class="portal-modal-backdrop" id="match-modal" role="dialog" aria-modal="true" aria-labelledby="match-modal-title">
<div class="portal-modal">
<h3 id="match-modal-title">Possible duplicate</h3>
<p>
An existing contact shares this {{ match_prompt.reason_label }}.
Update that record, or create a separate contact anyway?
</p>
<div class="match-card">
<strong>{{ match_prompt.contact }}</strong>
{% if match_prompt.contact.email %}<div>{{ match_prompt.contact.email }}</div>{% endif %}
{% if match_prompt.contact.phone %}<div>{{ match_prompt.contact.phone }}</div>{% endif %}
{% if match_prompt.contact.postal_address.line1 %}
<div class="muted">
{{ match_prompt.contact.postal_address.line1 }}{% if match_prompt.contact.postal_address.city %}, {{ match_prompt.contact.postal_address.city }}{% endif %}
</div>
{% endif %}
<div style="margin-top:8px">
<a href="{% url 'contacts:detail' match_prompt.contact.pk %}" target="_blank" rel="noopener">Open existing contact</a>
</div>
</div>
<div class="portal-modal-actions">
<button class="btn btn-primary" type="button" id="match-update">Update existing</button>
<button class="btn btn-ghost" type="button" id="match-create">Create new contact</button>
<button class="btn btn-ghost" type="button" id="match-cancel">Cancel</button>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="{% static 'js/address-autocomplete.js' %}"></script> <script src="{% static 'js/address-autocomplete.js' %}"></script>
{% if match_prompt %}
<script>
(function () {
var form = document.getElementById('contact-create-form');
var resolve = document.getElementById('id_resolve_match');
var modal = document.getElementById('match-modal');
function submitWith(choice) {
if (resolve) resolve.value = choice;
if (modal) modal.hidden = true;
form.submit();
}
document.getElementById('match-update')?.addEventListener('click', function () {
submitWith('update');
});
document.getElementById('match-create')?.addEventListener('click', function () {
submitWith('create');
});
document.getElementById('match-cancel')?.addEventListener('click', function () {
if (resolve) resolve.value = '';
if (modal) modal.remove();
});
})();
</script>
{% endif %}
{% endblock %} {% endblock %}
+16 -4
View File
@@ -13,12 +13,24 @@
<div class="panel-h"><h2>Profile</h2></div> <div class="panel-h"><h2>Profile</h2></div>
<div class="panel-b form-grid"> <div class="panel-b form-grid">
<div class="form-grid cols-2"> <div class="form-grid cols-2">
<div class="field"><label>First name</label><input value="{{ contact.first_name }}" readonly></div> <div class="field">
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div> <label for="id_first_name">First name</label>
<input id="id_first_name" name="first_name" value="{{ contact.first_name }}" required>
</div>
<div class="field">
<label for="id_last_name">Last name</label>
<input id="id_last_name" name="last_name" value="{{ contact.last_name }}">
</div>
</div> </div>
<div class="form-grid cols-2"> <div class="form-grid cols-2">
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div> <div class="field">
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div> <label for="id_email">Email</label>
<input id="id_email" name="email" type="email" value="{{ contact.email }}" required>
</div>
<div class="field">
<label for="id_phone">Phone</label>
<input id="id_phone" name="phone" value="{{ contact.phone }}">
</div>
</div> </div>
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div> <div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
+67
View File
@@ -1,3 +1,4 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -152,3 +153,69 @@ class ContactFormMergeTests(TestCase):
self.assertEqual(lead.contact_id, self.existing.pk) self.assertEqual(lead.contact_id, self.existing.pk)
self.assertIn("alt@example.com", lead.message) self.assertIn("alt@example.com", lead.message)
self.assertIn("merged by phone", lead.message) self.assertIn("merged by phone", lead.message)
class PortalCreateMatchPromptTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="adder", password="test-pass-123"
)
self.client = Client()
self.client.login(username="adder", password="test-pass-123")
self.existing = Contact.objects.create(
email="primary@example.com",
phone="6301112222",
first_name="Alex",
)
def test_phone_match_shows_prompt(self):
url = reverse("contacts:create")
response = self.client.post(
url,
{
"first_name": "Alex",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"consent_email": "1",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Possible duplicate")
self.assertContains(response, "Update existing")
self.assertEqual(Contact.objects.count(), 1)
def test_choose_create_new_keeps_both(self):
url = reverse("contacts:create")
response = self.client.post(
url,
{
"first_name": "Alex",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"consent_email": "1",
"resolve_match": "create",
"match_id": str(self.existing.pk),
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(Contact.objects.count(), 2)
def test_choose_update_merges(self):
url = reverse("contacts:create")
response = self.client.post(
url,
{
"first_name": "Alexander",
"email": "alt@example.com",
"phone": "(630) 111-2222",
"consent_email": "1",
"resolve_match": "update",
"match_id": str(self.existing.pk),
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(Contact.objects.count(), 1)
self.existing.refresh_from_db()
self.assertEqual(self.existing.first_name, "Alexander")
self.assertIn("alt@example.com", self.existing.notes)
+106 -7
View File
@@ -9,7 +9,7 @@ from django.views.decorators.http import require_GET, require_http_methods
from contacts.models import Channel, ConsentRecord, Contact from contacts.models import Channel, ConsentRecord, Contact
from contacts.nominatim import NominatimError, suggest_addresses from contacts.nominatim import NominatimError, suggest_addresses
from contacts.services import upsert_contact from contacts.services import find_matching_contact, upsert_contact
from messaging.services import channel_preferences, set_channel_preferences from messaging.services import channel_preferences, set_channel_preferences
@@ -70,6 +70,7 @@ def contact_create(request):
"consent_sms": False, "consent_sms": False,
"consent_postcard": True, "consent_postcard": True,
} }
match_prompt = None
if request.method == "POST": if request.method == "POST":
for key in list(form.keys()): for key in list(form.keys()):
if key.startswith("consent_"): if key.startswith("consent_"):
@@ -77,6 +78,8 @@ def contact_create(request):
else: else:
form[key] = (request.POST.get(key) or "").strip() form[key] = (request.POST.get(key) or "").strip()
email = form["email"].lower() email = form["email"].lower()
resolve = (request.POST.get("resolve_match") or "").strip()
match_id = (request.POST.get("match_id") or "").strip()
errors: list[str] = [] errors: list[str] = []
if not form["first_name"]: if not form["first_name"]:
errors.append("First name is required.") errors.append("First name is required.")
@@ -92,10 +95,41 @@ def contact_create(request):
if form["consent_sms"] and not form["phone"]: if form["consent_sms"] and not form["phone"]:
errors.append("Phone is required for SMS consent.") errors.append("Phone is required for SMS consent.")
if form["consent_postcard"] and not has_postal: if form["consent_postcard"] and not has_postal:
# Soft: allow save but clear postcard consent if no address
form["consent_postcard"] = False form["consent_postcard"] = False
if not errors: if not errors:
contact, created, reason = upsert_contact( existing, reason = find_matching_contact(
email=email,
phone=form["phone"],
postal_address=postal if has_postal else None,
)
# Phone/address collision (different email): ask user unless they chose.
if (
existing
and reason in {"phone", "address"}
and resolve not in {"update", "create"}
):
match_prompt = {
"contact": existing,
"reason": reason,
"reason_label": "phone number"
if reason == "phone"
else "mailing address",
}
else:
merge_into = None
merge_phone_address = True
if resolve == "update" and match_id:
merge_into = Contact.objects.filter(pk=match_id).first()
if merge_into is None:
errors.append("Matched contact no longer exists.")
elif resolve == "create":
merge_phone_address = False
elif reason == "email" and existing:
merge_into = existing
if not errors:
contact, created, used_reason = upsert_contact(
email=email, email=email,
first_name=form["first_name"], first_name=form["first_name"],
last_name=form["last_name"], last_name=form["last_name"],
@@ -103,6 +137,8 @@ def contact_create(request):
postal_address=postal if has_postal else None, postal_address=postal if has_postal else None,
source=Contact.Source.MANUAL, source=Contact.Source.MANUAL,
notes_append=form["notes"], notes_append=form["notes"],
merge_phone_address=merge_phone_address,
merge_into=merge_into,
) )
set_channel_preferences( set_channel_preferences(
contact, contact,
@@ -113,13 +149,23 @@ def contact_create(request):
}, },
reason="portal_manual", reason="portal_manual",
) )
verb = "Added" if created else f"Updated (matched by {reason or 'email'})" if created:
verb = "Added"
elif used_reason == "email":
verb = "Updated (same email)"
elif resolve == "update":
verb = f"Updated (matched by {reason or used_reason})"
else:
verb = f"Updated (matched by {used_reason or 'email'})"
messages.success(request, f"{verb} {contact}.") messages.success(request, f"{verb} {contact}.")
return redirect("contacts:detail", pk=contact.pk) return redirect("contacts:detail", pk=contact.pk)
for err in errors: for err in errors:
messages.error(request, err) messages.error(request, err)
return render(request, "contacts/create.html", {"form": form}) return render(
request,
"contacts/create.html",
{"form": form, "match_prompt": match_prompt},
)
@login_required @login_required
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@@ -128,9 +174,62 @@ def contact_detail(request, pk):
Contact.objects.prefetch_related("consents"), pk=pk Contact.objects.prefetch_related("consents"), pk=pk
) )
if request.method == "POST": if request.method == "POST":
first_name = (request.POST.get("first_name") or "").strip()
last_name = (request.POST.get("last_name") or "").strip()
email = (request.POST.get("email") or "").strip().lower()
phone = (request.POST.get("phone") or "").strip()
errors: list[str] = []
if not first_name:
errors.append("First name is required.")
if not email:
errors.append("Email is required.")
else:
try:
validate_email(email)
except ValidationError:
errors.append("Enter a valid email address.")
else:
taken = (
Contact.objects.filter(email__iexact=email)
.exclude(pk=contact.pk)
.exists()
)
if taken:
errors.append("Another contact already uses that email.")
if errors:
for err in errors:
messages.error(request, err)
prefs = _consent_flags(contact)
# Reflect submitted values so the user can fix them.
contact.first_name = first_name
contact.last_name = last_name
contact.email = email
contact.phone = phone
contact.postal_address = _postal_from_post(request.POST) contact.postal_address = _postal_from_post(request.POST)
contact.notes = (request.POST.get("notes") or "").strip() contact.notes = (request.POST.get("notes") or "").strip()
contact.save(update_fields=["postal_address", "notes", "updated_at"]) return render(
request,
"contacts/detail.html",
{"contact": contact, "prefs": prefs},
)
contact.first_name = first_name
contact.last_name = last_name
contact.email = email
contact.phone = phone
contact.postal_address = _postal_from_post(request.POST)
contact.notes = (request.POST.get("notes") or "").strip()
contact.save(
update_fields=[
"first_name",
"last_name",
"email",
"phone",
"postal_address",
"notes",
"updated_at",
]
)
set_channel_preferences( set_channel_preferences(
contact, contact,
{ {
+3 -1
View File
@@ -5,7 +5,7 @@ from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template from django.template.loader import get_template
from contacts.models import Channel 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 ( from public.email_branding import (
campaign_body_to_email_html, campaign_body_to_email_html,
campaign_body_to_plain_text, campaign_body_to_plain_text,
@@ -28,6 +28,8 @@ def send_email(message) -> str:
body = message.body_snapshot or campaign.body_override or ( body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else "" 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("/") site = (settings.PUBLIC_SITE_URL or "").rstrip("/")
prefs_path = preferences_url(str(contact.pk), Channel.EMAIL) prefs_path = preferences_url(str(contact.pk), Channel.EMAIL)
+3
View File
@@ -5,6 +5,8 @@ import logging
import requests import requests
from django.conf import settings from django.conf import settings
from messaging.services import render_merge_tags
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -21,6 +23,7 @@ def send_sms(message) -> str:
body = message.body_snapshot or campaign.body_override or ( body = message.body_snapshot or campaign.body_override or (
campaign.template.body if campaign.template else "" campaign.template.body if campaign.template else ""
) )
body = render_merge_tags(body, contact)
payload = { payload = {
"api_key": api_key, "api_key": api_key,
+54
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -25,6 +26,49 @@ AUDIENCE_CHANNEL = {
UNSUB_SALT = "monica-site-unsubscribe" UNSUB_SALT = "monica-site-unsubscribe"
UNSUB_MAX_AGE = 60 * 60 * 24 * 365 # 1 year 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: def contact_may_receive(contact: Contact, channel: str) -> bool:
if Suppression.objects.filter( if Suppression.objects.filter(
@@ -443,6 +487,16 @@ def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
if not body.strip(): if not body.strip():
raise ValueError("Campaign has no body.") 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 = ( notice = (
"This is a test send from the Monica portal. " "This is a test send from the Monica portal. "
"Recipient list was not notified." "Recipient list was not notified."
@@ -39,7 +39,11 @@
{% csrf_token %} {% csrf_token %}
<button class="btn btn-primary" type="submit">Send now to recipients</button> <button class="btn btn-primary" type="submit">Send now to recipients</button>
<p class="hint-block" style="margin-top:8px"> <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). Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
{% endif %}
</p> </p>
</form> </form>
{% endif %} {% endif %}
@@ -60,6 +64,7 @@
<div class="label">Delivered</div> <div class="label">Delivered</div>
<div class="value" data-stat="delivered">{{ stats.delivered }}</div> <div class="value" data-stat="delivered">{{ stats.delivered }}</div>
</div> </div>
{% if campaign.channel == "email" %}
<div class="stat-card"> <div class="stat-card">
<div class="label">Opens</div> <div class="label">Opens</div>
<div class="value" data-stat="opens">{{ stats.opens }}</div> <div class="value" data-stat="opens">{{ stats.opens }}</div>
@@ -68,6 +73,7 @@
<div class="label">Clicks</div> <div class="label">Clicks</div>
<div class="value" data-stat="clicks">{{ stats.clicks }}</div> <div class="value" data-stat="clicks">{{ stats.clicks }}</div>
</div> </div>
{% endif %}
<div class="stat-card"> <div class="stat-card">
<div class="label">Bounced / failed</div> <div class="label">Bounced / failed</div>
<div class="value" data-stat="failed">{{ stats.failed }}</div> <div class="value" data-stat="failed">{{ stats.failed }}</div>
@@ -82,21 +88,32 @@
<div class="panel"> <div class="panel">
<div class="panel-h"><h2>Engagement</h2></div> <div class="panel-h"><h2>Engagement</h2></div>
<div class="panel-b"> <div class="panel-b">
{% if campaign.channel == "email" %}
<p class="hint-block" style="margin-top:0"> <p class="hint-block" style="margin-top:0">
Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened · Unique recipients: <strong data-stat="opens">{{ stats.opens }}</strong> opened ·
<strong data-stat="clicks">{{ stats.clicks }}</strong> clicked <strong data-stat="clicks">{{ stats.clicks }}</strong> clicked
({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO). ({{ stats.open_events }} open events / {{ stats.click_events }} click events from SMTP2GO).
</p> </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="chart-placeholder" aria-hidden="true">
<div class="bar" style="height:55%"></div> <div class="bar" style="height:55%"></div>
<div class="bar" style="height:70%"></div> <div class="bar" style="height:70%"></div>
<div class="bar" style="height:40%"></div> <div class="bar" style="height:40%"></div>
<div class="bar" style="height:30%"></div> <div class="bar" style="height:30%"></div>
<div class="bar" style="height:20%"></div> <div class="bar" style="height:20%"></div>
</div> </div> </div>
</div>
</div> </div>
<div class="panel"> <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"> <div class="panel-b" style="padding:0">
<table class="table"> <table class="table">
<thead><tr><th>When</th><th>Event</th><th>Contact</th></tr></thead> <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> <td>{% if event.message %}{{ event.message.contact }}{% else %}—{% endif %}</td>
</tr> </tr>
{% empty %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -116,15 +133,23 @@
</div> </div>
</div> </div>
<div class="panel"> <div class="panel" id="recipients-panel" data-page="{{ page_obj.number }}">
<div class="panel-h"><h2>Recipients</h2></div> <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"> <div class="panel-b" style="padding:0">
<table class="table"> <table class="table">
<thead> <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> </thead>
<tbody id="recipients-body"> <tbody id="recipients-body">
{% for message in messages %} {% for message in recipient_messages %}
<tr data-message-id="{{ message.pk }}"> <tr data-message-id="{{ message.pk }}">
<td> <td>
<div>{{ message.contact }}</div> <div>{{ message.contact }}</div>
@@ -135,24 +160,56 @@
<td><span class="badge badge-{{ message.status }}">{{ message.get_status_display }}</span></td> <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.provider_message_id|default:"—" }}</td>
<td class="muted">{{ message.error|truncatechars:60|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> </tr>
{% empty %} {% 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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </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> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script> <script>
(function () { (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) { function esc(s) {
return String(s || "").replace(/[&<>"']/g, function (c) { return String(s || "").replace(/[&<>"']/g, function (c) {
return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]; return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
}); });
} }
function removeUrl(id) {
return removeBase.replace("00000000-0000-0000-0000-000000000000", id);
}
function apply(data) { function apply(data) {
var badge = document.getElementById("campaign-status-badge"); var badge = document.getElementById("campaign-status-badge");
if (badge) { if (badge) {
@@ -167,17 +224,25 @@
var body = document.getElementById("recipients-body"); var body = document.getElementById("recipients-body");
if (body && data.messages) { if (body && data.messages) {
if (!data.messages.length) { 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 { } else {
body.innerHTML = data.messages.map(function (m) { body.innerHTML = data.messages.map(function (m) {
var dest = m.destination var dest = m.destination
? '<div class="muted" style="font-size:12px;margin-top:2px">' + esc(m.destination) + '</div>' ? '<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) + "\">" + return "<tr data-message-id=\"" + esc(m.id) + "\">" +
"<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" + "<td><div>" + esc(m.contact) + "</div>" + dest + "</td>" +
"<td><span class=\"badge badge-" + esc(m.status) + "\">" + esc(m.status_display) + "</span></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.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(""); }).join("");
} }
} }
@@ -77,14 +77,14 @@
<label>Body</label> <label>Body</label>
<div id="email-editor"></div> <div id="email-editor"></div>
<textarea id="id_body" name="body" hidden>{{ form_data.body }}</textarea> <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>
<div class="field" id="sms-body-wrap" hidden> <div class="field" id="sms-body-wrap" hidden>
<label for="id_body_sms">Body</label> <label for="id_body_sms">Body</label>
<textarea id="id_body_sms" style="min-height:140px" <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> 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>
<div class="field" id="postcard-body-hint" hidden> <div class="field" id="postcard-body-hint" hidden>
<p class="hint-block" style="margin:0"> <p class="hint-block" style="margin:0">
+141
View File
@@ -140,6 +140,10 @@ class PortalConsentToggleTests(TestCase):
response = self.client.post( response = self.client.post(
url, url,
{ {
"first_name": "Sam",
"last_name": "",
"email": "sam@example.com",
"phone": "",
"notes": "updated", "notes": "updated",
"consent_sms": "1", "consent_sms": "1",
"consent_postcard": "1", "consent_postcard": "1",
@@ -155,6 +159,32 @@ class PortalConsentToggleTests(TestCase):
) )
self.assertEqual(email_consent.reason, "portal_manual") 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): class CampaignDraftSaveTests(TestCase):
def setUp(self): def setUp(self):
@@ -899,3 +929,114 @@ class StoredFileUploadTests(TestCase):
self.assertEqual(fetch.status_code, 200) self.assertEqual(fetch.status_code, 200)
self.assertEqual(fetch["Content-Type"], "image/png") self.assertEqual(fetch["Content-Type"], "image/png")
self.assertEqual(b"".join(fetch.streaming_content), 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, views.campaign_test_send,
name="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( path(
"campaigns/upload-image/", "campaigns/upload-image/",
views.campaign_image_upload, 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 import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.core.validators import validate_email from django.core.validators import validate_email
from django.http import FileResponse, HttpResponseForbidden, JsonResponse from django.http import FileResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render 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 django.views.decorators.http import require_GET, require_http_methods, require_POST
from contacts.models import Channel 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 ( from messaging.providers.postcard.pcm import (
PCM_SIZE_CHOICES, PCM_SIZE_CHOICES,
PcmApiError, PcmApiError,
@@ -27,12 +28,16 @@ from messaging.providers.postcard.pcm import (
from messaging.services import ( from messaging.services import (
create_campaign_draft, create_campaign_draft,
enqueue_campaign_send, enqueue_campaign_send,
message_is_removable,
opted_in_contacts, opted_in_contacts,
parse_scheduled_for, parse_scheduled_for,
record_sms_stop, record_sms_stop,
send_campaign_test_email, send_campaign_test_email,
) )
from messaging.webhooks import ( from messaging.webhooks import (
PROVIDER_EMAIL,
PROVIDER_PCM,
PROVIDER_SMS,
campaign_engagement_stats, campaign_engagement_stats,
is_inbound_sms_stop, is_inbound_sms_stop,
parse_webhook_payload, parse_webhook_payload,
@@ -43,6 +48,8 @@ from messaging.webhooks import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
RECIPIENTS_PER_PAGE = 50
_ALLOWED_IMAGE_TYPES = frozenset( _ALLOWED_IMAGE_TYPES = frozenset(
{"image/jpeg", "image/png", "image/gif", "image/webp"} {"image/jpeg", "image/png", "image/gif", "image/webp"}
) )
@@ -227,20 +234,59 @@ def _message_destination(message) -> str:
return "" return ""
def _campaign_report(campaign: Campaign) -> dict: def _events_provider_filter(campaign: Campaign) -> tuple[list[str], str, str]:
messages_qs = list(campaign.messages.select_related("contact").all()[:200]) """Return (provider codes, panel title, empty-state hint) for campaign channel."""
for msg in messages_qs: 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.destination = _message_destination(msg)
msg.can_remove = message_is_removable(msg)
stats = campaign_engagement_stats(campaign) stats = campaign_engagement_stats(campaign)
providers, events_title, events_empty = _events_provider_filter(campaign)
recent_events = ( recent_events = (
ProviderEvent.objects.filter(message__campaign=campaign) ProviderEvent.objects.filter(
message__campaign=campaign,
provider__in=providers,
)
.select_related("message", "message__contact") .select_related("message", "message__contact")
.order_by("-created_at")[:25] .order_by("-created_at")[:25]
) )
return { return {
"messages": messages_qs, "recipient_messages": recipient_messages,
"page_obj": page_obj,
"stats": stats, "stats": stats,
"recent_events": recent_events, "recent_events": recent_events,
"events_title": events_title,
"events_empty": events_empty,
} }
@@ -467,15 +513,22 @@ def campaign_list(request):
@login_required @login_required
def campaign_detail(request, pk): def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=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( return render(
request, request,
"messaging/campaign_detail.html", "messaging/campaign_detail.html",
{ {
"campaign": campaign, "campaign": campaign,
"messages": ctx["messages"], "recipient_messages": ctx["recipient_messages"],
"page_obj": ctx["page_obj"],
"stats": ctx["stats"], "stats": ctx["stats"],
"recent_events": ctx["recent_events"], "recent_events": ctx["recent_events"],
"events_title": ctx["events_title"],
"events_empty": ctx["events_empty"],
"can_send": campaign.status "can_send": campaign.status
in { in {
Campaign.Status.DRAFT, Campaign.Status.DRAFT,
@@ -499,12 +552,19 @@ def campaign_status_json(request, pk):
refresh_campaign_status(campaign) refresh_campaign_status(campaign)
campaign.refresh_from_db() 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( return JsonResponse(
{ {
"status": campaign.status, "status": campaign.status,
"status_display": campaign.get_status_display(), "status_display": campaign.get_status_display(),
"stats": ctx["stats"], "stats": ctx["stats"],
"page": page_obj.number,
"num_pages": page_obj.paginator.num_pages,
"messages": [ "messages": [
{ {
"id": str(m.pk), "id": str(m.pk),
@@ -514,8 +574,9 @@ def campaign_status_json(request, pk):
"status_display": m.get_status_display(), "status_display": m.get_status_display(),
"provider_message_id": m.provider_message_id or "", "provider_message_id": m.provider_message_id or "",
"error": (m.error or "")[:120], "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": [ "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 @login_required
@require_POST @require_POST
def campaign_send(request, pk): def campaign_send(request, pk):