Improve campaign personalization and contact duplicate handling.
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:
+96
-10
@@ -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). SMTP2GO’s 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):
|
||||
|
||||
Reference in New Issue
Block a user