Add PCM carrier-route postcard pick-and-send (#16)
Deploy Beta / unit-tests (push) Successful in 24s
Deploy Beta / docker (push) Successful in 30s
Deploy Beta / deploy-beta (push) Successful in 7m22s

## Summary
Closes #15.
Mirrors westfarn/web_django_template#11.

- 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 ZIP 60540 (mocked or live PCM), 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 messaging.tests.CarrierRoutePostcardTests messaging.tests.PostcardDesignPickTests`

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
westfarn committed 2026-09-21 13:29:20 -07:00
1 parent a867a57750
commit 045e956011
17 files changed
+1211 -28

No files matched your search

+95 -12
View File
@@ -25,6 +25,7 @@ from messaging.providers.postcard.pcm import (
design_id_from_template,
get_design_embed_url,
list_designs,
mail_class_for_size,
)
from messaging.services import (
channel_for_audience,
@@ -33,7 +34,9 @@ from messaging.services import (
ensure_campaign_utm_link,
message_is_removable,
opted_in_contacts,
parse_route_targeting,
parse_scheduled_for,
postcard_quote as build_postcard_quote,
public_site_base_url,
public_site_link_label,
record_sms_stop,
@@ -76,6 +79,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
@@ -232,10 +241,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 ""
@@ -275,7 +288,7 @@ def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
recipient_messages = list(page_obj.object_list)
for msg in recipient_messages:
msg.destination = _message_destination(msg)
msg.can_remove = message_is_removable(msg)
msg.can_remove = message_is_removable(msg) and msg.contact_id is not None
stats = campaign_engagement_stats(campaign)
providers, events_title, events_empty = _events_provider_filter(campaign)
recent_events = (
@@ -431,6 +444,7 @@ def campaign_list(request):
"audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "",
"template_id": "",
"route_targeting": "",
}
if request.method == "POST":
@@ -440,6 +454,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(
{
@@ -449,10 +464,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)
@@ -467,6 +484,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 = {}
else:
if audience in Campaign.Audience.values:
channel = channel_for_audience(audience)
@@ -501,14 +530,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("messaging:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100]
@@ -526,10 +566,46 @@ def campaign_list(request):
"utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"),
"utm_url": "",
"postcard_quote_url": reverse("messaging: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
@require_POST
def campaign_short_link(request):
@@ -622,6 +698,7 @@ def campaign_detail(request, pk):
"suppressed",
}
).exists(),
"route_targeting": campaign.route_targeting or {},
},
)
@@ -652,7 +729,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(),
@@ -665,7 +744,11 @@ 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"]