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
+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):