Add PCM carrier-route postcard pick-and-send (#12)

## Summary
Closes #11.
Mirrors ai_ml_operations/monica_site#15.

- Direct-mail compose can target the postcard mailing list **or** find and select one or two USPS carrier routes by ZIP (optional radius).
- Estimated cost is shown per route and for the selection (PCM design pricing, else `PCM_POSTCARD_PRICE_*` fallback).
- Selected routes order through PCM `POST /order/postcard/with-list-count` — occupant addresses are not imported into Contacts.
- Not official EDDM postage; PCM FirstClass/Standard purchased occupant lists on carrier routes.

## Test plan
- [ ] Campaign compose: mailing-list audience still quotes list size × per-piece cost.
- [ ] Switch to USPS carrier routes, search a ZIP, pick 1–2 routes, confirm household count + estimated cost.
- [ ] Saving without a selection is rejected; saving with a quote creates one contact-less Message and shows the route panel on the report.
- [ ] Send places `/order/postcard/with-list-count` (not per-recipient `/order/postcard`).
- [ ] `uv run python manage.py test directmail.tests.CarrierRoutePostcardTests directmail.tests.PostcardDesignPickTests`

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
westfarn committed 2026-09-21 13:29:53 -07:00
1 parent f3e34f5099
commit f2eb4b7152
17 files changed
+1192 -27

No files matched your search

+92 -12
View File
@@ -1,6 +1,7 @@
import hashlib
import hmac
import io
import json
import logging
from django.conf import settings
@@ -25,6 +26,7 @@ from directmail.providers.postcard.pcm import (
design_id_from_template,
get_design_embed_url,
list_designs,
mail_class_for_size,
)
from contacts.consent import opted_in_contacts
from core.campaign_utm import utm_panel_context
@@ -33,6 +35,8 @@ from directmail.services import (
create_campaign_draft,
enqueue_campaign_send,
message_is_removable,
parse_route_targeting,
postcard_quote as build_postcard_quote,
)
from directmail.webhooks import (
PROVIDER_EMAIL,
@@ -68,6 +72,12 @@ def _audience_choices() -> list[tuple[str, str]]:
choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})")
)
choices.append(
(
Campaign.Audience.CARRIER_ROUTE,
"USPS carrier routes (find by ZIP)",
)
)
return choices
@@ -224,10 +234,14 @@ def _message_destination(message) -> str:
contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL:
return (contact.email or "").strip()
return (contact.email or "").strip() if contact else ""
if channel == Channel.SMS:
return (contact.phone or "").strip()
return (contact.phone or "").strip() if contact else ""
if channel == Channel.POSTCARD:
if not contact:
targeting = getattr(message.campaign, "route_targeting", None) or {}
codes = targeting.get("selected_codes") or []
return ", ".join(str(c) for c in codes) or "Carrier routes"
return _format_postal_address(contact.postal_address)
return ""
@@ -423,6 +437,7 @@ def campaign_list(request):
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"scheduled_for": "",
"template_id": "",
"route_targeting": "",
}
if request.method == "POST":
@@ -432,6 +447,7 @@ def campaign_list(request):
audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip()
route_raw = request.POST.get("route_targeting") or ""
form_data.update(
{
@@ -441,10 +457,12 @@ def campaign_list(request):
"audience": audience,
"scheduled_for": scheduled_raw,
"template_id": template_id,
"route_targeting": route_raw,
}
)
template = None
targeting: dict = {}
if template_id:
template = _resolve_postcard_template(template_id)
@@ -459,6 +477,18 @@ def campaign_list(request):
)
if not body:
body = "Postcard mailing"
elif audience == Campaign.Audience.CARRIER_ROUTE:
if not template or template.channel != Channel.POSTCARD:
form_errors.append(
"Choose a postcard design (create one under Postcard design)."
)
if not body:
body = "Carrier route postcard mailing"
try:
targeting = parse_route_targeting(route_raw)
except ValueError as exc:
form_errors.append(str(exc))
targeting = {}
scheduled_for = None
try:
@@ -475,14 +505,25 @@ def campaign_list(request):
scheduled_for=scheduled_for,
created_by=request.user,
template=template,
route_targeting=targeting,
)
recipient_count = campaign.messages.count()
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({recipient_count} recipient"
f"{'' if recipient_count == 1 else 's'}).",
)
if audience == Campaign.Audience.CARRIER_ROUTE:
pieces = targeting.get("record_count") or 0
total = (targeting.get("estimated") or {}).get("total")
cost_bit = f", est. ${total:.2f}" if isinstance(total, (int, float)) else ""
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({pieces} household{'' if pieces == 1 else 's'}{cost_bit}).",
)
else:
recipient_count = campaign.messages.count()
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({recipient_count} recipient"
f"{'' if recipient_count == 1 else 's'}).",
)
return redirect("directmail:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100]
@@ -497,10 +538,46 @@ def campaign_list(request):
"form_errors": form_errors,
"image_upload_url": reverse("directmail:campaign_image_upload"),
**utm_panel_context(live=True, medium=Channel.POSTCARD),
"postcard_quote_url": reverse("directmail:postcard_quote"),
"postcard_list_count": opted_in_contacts(Channel.POSTCARD).count(),
},
)
@login_required
@require_POST
def postcard_quote(request):
"""Search PCM carrier routes or price a postcard audience."""
try:
payload = json.loads(request.body.decode() or "{}")
except (json.JSONDecodeError, UnicodeDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
template_id = str(payload.get("template_id") or "").strip()
if template_id:
template = _resolve_postcard_template(template_id)
if template:
did = design_id_from_template(template)
if did and not payload.get("design_id"):
payload["design_id"] = did
front = template.postcard_front or {}
if not payload.get("size"):
payload["size"] = front.get("size") or ""
if not payload.get("mail_class") and front.get("mail_class"):
payload["mail_class"] = front.get("mail_class")
elif not payload.get("mail_class"):
payload["mail_class"] = mail_class_for_size(payload.get("size"))
action = str(payload.get("action") or "mailing_list").strip()
try:
result = build_postcard_quote(action=action, payload=payload)
except ValueError as exc:
return JsonResponse({"detail": str(exc)}, status=400)
except PcmApiError as exc:
return JsonResponse({"detail": str(exc)}, status=502)
return JsonResponse(result)
@login_required
def campaign_detail(request, pk):
campaign = get_object_or_404(Campaign, pk=pk)
@@ -536,6 +613,7 @@ def campaign_detail(request, pk):
"suppressed",
}
).exists(),
"route_targeting": campaign.route_targeting or {},
},
)
@@ -566,7 +644,9 @@ def campaign_status_json(request, pk):
"messages": [
{
"id": str(m.pk),
"contact": str(m.contact),
"contact": (
str(m.contact) if m.contact_id else "Carrier route mailing"
),
"destination": getattr(m, "destination", "") or "",
"status": m.status,
"status_display": m.get_status_display(),
@@ -579,7 +659,7 @@ def campaign_status_json(request, pk):
"events": [
{
"event_type": e.event_type,
"contact": str(e.message.contact) if e.message_id else "—",
"contact": str(e.message.contact) if e.message_id and e.message.contact_id else "—",
"created_at": e.created_at.isoformat(),
}
for e in ctx["recent_events"]
@@ -601,7 +681,7 @@ def campaign_message_remove(request, pk, message_id):
)
return redirect("directmail:campaign_detail", pk=campaign.pk)
label = str(message.contact)
label = str(message.contact) if message.contact_id else "carrier-route mailing"
message.delete()
messages.success(request, f"Removed {label} from this campaign.")
page = (request.POST.get("page") or request.GET.get("page") or "").strip()