Add PCM carrier-route postcard pick-and-send (#16)
## 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:
1 parent
a867a57750
commit
045e956011
17 files changed
+1211
-28
No files matched your search
+95
-12
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user