Template
Add PCM carrier-route postcard pick-and-send (#11)
## Summary Closes #11. - Mirror Monica: compose can use the postcard mailing list or 1–2 USPS carrier routes, with estimated cost shown. - Place selected routes through PCM list-count orders instead of importing occupant addresses.
This commit is contained in:
1 parent
f3e34f5099
commit
3a1ef797b8
17 files changed
+1192
-27
No files matched your search
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
|
||||
|
||||
AUDIENCE_CHANNEL = {
|
||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||
Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
|
||||
}
|
||||
|
||||
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
|
||||
@@ -84,6 +85,211 @@ def channel_for_audience(audience: str) -> str:
|
||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||
|
||||
|
||||
def parse_route_targeting(raw: str | dict | None) -> dict:
|
||||
"""Parse compose JSON for a carrier-route campaign."""
|
||||
from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES
|
||||
|
||||
data: dict = {}
|
||||
if isinstance(raw, dict):
|
||||
data = raw
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Carrier route selection is invalid JSON.") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Carrier route selection must be an object.")
|
||||
data = parsed
|
||||
codes = [
|
||||
str(code).strip()
|
||||
for code in (data.get("selected_codes") or data.get("routes") or [])
|
||||
if str(code).strip()
|
||||
]
|
||||
# Allow routes as list of objects from the quote endpoint.
|
||||
if not codes and isinstance(data.get("routes"), list):
|
||||
codes = [
|
||||
str(row.get("code") or "").strip()
|
||||
for row in data["routes"]
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
codes = [c for c in codes if c]
|
||||
codes = list(dict.fromkeys(codes))
|
||||
if not codes:
|
||||
raise ValueError("Select one or two USPS carrier routes.")
|
||||
if len(codes) > MAX_SELECTED_ROUTES:
|
||||
raise ValueError("Pick at most two carrier routes.")
|
||||
try:
|
||||
list_count_id = int(data.get("list_count_id") or 0)
|
||||
record_count = int(data.get("record_count") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Route quote is missing list count.") from exc
|
||||
if list_count_id <= 0 or record_count <= 0:
|
||||
raise ValueError("Find routes and select them before saving (need a PCM quote).")
|
||||
estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {}
|
||||
route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else []
|
||||
return {
|
||||
"selected_codes": codes,
|
||||
"list_count_id": list_count_id,
|
||||
"record_count": record_count,
|
||||
"mail_class": str(data.get("mail_class") or "Standard"),
|
||||
"list_type": str(data.get("list_type") or "IRL"),
|
||||
"zip": str(data.get("zip") or ""),
|
||||
"estimated": estimated,
|
||||
"route_rows": route_rows,
|
||||
}
|
||||
|
||||
|
||||
def money_total(per_piece: float | None, quantity: int) -> float | None:
|
||||
if per_piece is None:
|
||||
return None
|
||||
return round(float(per_piece) * max(int(quantity or 0), 0), 2)
|
||||
|
||||
|
||||
def postcard_quote(*, action: str, payload: dict) -> dict:
|
||||
"""Search routes or price a mailing-list / selected-route postcard."""
|
||||
from directmail.providers.postcard.pcm import (
|
||||
MAX_SELECTED_ROUTES,
|
||||
design_pricing,
|
||||
extract_crrt_rows,
|
||||
list_count_carrier_route,
|
||||
list_count_radius,
|
||||
list_count_zipcode,
|
||||
mail_class_for_size,
|
||||
pick_unit_price,
|
||||
)
|
||||
|
||||
design_id = payload.get("design_id")
|
||||
size = str(payload.get("size") or "").strip()
|
||||
try:
|
||||
design_id_int = int(design_id) if design_id not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
design_id_int = None
|
||||
|
||||
requested_class = str(payload.get("mail_class") or "").strip()
|
||||
mail_class = requested_class or mail_class_for_size(size)
|
||||
if mail_class not in {"FirstClass", "Standard"}:
|
||||
mail_class = mail_class_for_size(size)
|
||||
pricing = design_pricing(design_id_int)
|
||||
|
||||
def _price(qty: int) -> tuple[float | None, str]:
|
||||
return pick_unit_price(pricing, mail_class=mail_class, quantity=qty)
|
||||
|
||||
action = (action or "mailing_list").strip()
|
||||
if action == "mailing_list":
|
||||
count = opted_in_contacts(Channel.POSTCARD).count()
|
||||
per_piece, source = _price(count)
|
||||
return {
|
||||
"action": action,
|
||||
"mail_class": mail_class,
|
||||
"count": count,
|
||||
"per_piece": per_piece,
|
||||
"total": money_total(per_piece, count),
|
||||
"price_source": source,
|
||||
}
|
||||
|
||||
if action == "search":
|
||||
zip_code = str(payload.get("zip") or "").strip()
|
||||
address = str(payload.get("address") or "").strip()
|
||||
city = str(payload.get("city") or "").strip()
|
||||
state = str(payload.get("state") or "").strip()
|
||||
try:
|
||||
miles = float(payload.get("radius") or 0)
|
||||
except (TypeError, ValueError):
|
||||
miles = 0
|
||||
if address and city and state and zip_code and miles > 0:
|
||||
data = list_count_radius(
|
||||
address=address,
|
||||
city=city,
|
||||
state=state,
|
||||
zip_code=zip_code,
|
||||
miles=miles,
|
||||
)
|
||||
elif zip_code:
|
||||
data = list_count_zipcode(zip_codes=[zip_code])
|
||||
else:
|
||||
raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
|
||||
rows = extract_crrt_rows(data)
|
||||
priced = []
|
||||
for row in rows:
|
||||
per_piece, source = _price(row["total"])
|
||||
priced.append(
|
||||
{
|
||||
**row,
|
||||
"per_piece": per_piece,
|
||||
"cost": money_total(per_piece, row["total"]),
|
||||
"price_source": source,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"action": action,
|
||||
"mail_class": mail_class,
|
||||
"zip": zip_code,
|
||||
"routes": priced,
|
||||
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
|
||||
}
|
||||
|
||||
if action == "select":
|
||||
codes = [
|
||||
str(c).strip()
|
||||
for c in (payload.get("routes") or [])
|
||||
if str(c).strip()
|
||||
]
|
||||
codes = list(dict.fromkeys(codes))
|
||||
if not codes:
|
||||
raise ValueError("Select at least one carrier route.")
|
||||
if len(codes) > MAX_SELECTED_ROUTES:
|
||||
raise ValueError("Pick at most two carrier routes.")
|
||||
data = list_count_carrier_route(carrier_routes=codes)
|
||||
rows = extract_crrt_rows(data)
|
||||
if not rows:
|
||||
try:
|
||||
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
record_count = 0
|
||||
rows = [{"code": code, "text": code, "total": 0} for code in codes]
|
||||
if record_count and len(rows) == 1:
|
||||
rows[0]["total"] = record_count
|
||||
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||
if not record_count:
|
||||
record_count = sum(int(r.get("total") or 0) for r in rows)
|
||||
per_piece, source = _price(record_count)
|
||||
priced = []
|
||||
for row in rows:
|
||||
total = int(row.get("total") or 0)
|
||||
priced.append(
|
||||
{
|
||||
**row,
|
||||
"per_piece": per_piece,
|
||||
"cost": money_total(per_piece, total),
|
||||
"price_source": source,
|
||||
}
|
||||
)
|
||||
list_count_id = data.get("listCountID") or data.get("list_count_id")
|
||||
return {
|
||||
"action": action,
|
||||
"mail_class": mail_class,
|
||||
"selected_codes": codes,
|
||||
"routes": priced,
|
||||
"route_rows": priced,
|
||||
"list_count_id": list_count_id,
|
||||
"record_count": record_count,
|
||||
"per_piece": per_piece,
|
||||
"total": money_total(per_piece, record_count),
|
||||
"price_source": source,
|
||||
"estimated": {
|
||||
"per_piece": per_piece,
|
||||
"pieces": record_count,
|
||||
"total": money_total(per_piece, record_count),
|
||||
"currency": "USD",
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
|
||||
raise ValueError(f"Unknown quote action: {action}")
|
||||
|
||||
|
||||
def create_campaign_draft(
|
||||
*,
|
||||
name: str,
|
||||
@@ -93,9 +299,11 @@ def create_campaign_draft(
|
||||
scheduled_for=None,
|
||||
created_by: AbstractBaseUser | None = None,
|
||||
template: MessageTemplate | None = None,
|
||||
route_targeting: dict | None = None,
|
||||
) -> Campaign:
|
||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||
channel = channel_for_audience(audience)
|
||||
targeting = route_targeting if isinstance(route_targeting, dict) else {}
|
||||
campaign = Campaign.objects.create(
|
||||
name=name,
|
||||
channel=channel,
|
||||
@@ -106,7 +314,18 @@ def create_campaign_draft(
|
||||
body_override=body,
|
||||
created_by=created_by,
|
||||
template=template,
|
||||
route_targeting=targeting,
|
||||
)
|
||||
if audience == Campaign.Audience.CARRIER_ROUTE:
|
||||
Message.objects.create(
|
||||
campaign=campaign,
|
||||
contact=None,
|
||||
channel=channel,
|
||||
status=Message.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
body_snapshot=body,
|
||||
)
|
||||
return campaign
|
||||
contacts = list(opted_in_contacts(channel))
|
||||
Message.objects.bulk_create(
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user