Template
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:
1 parent
f3e34f5099
commit
f2eb4b7152
17 files changed
+1192
-27
No files matched your search
+92
-12
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user