Files
monica_site/site/messaging/providers/postcard/pcm.py
T
westfarn 045e956011
Deploy Beta / unit-tests (push) Successful in 24s
Deploy Beta / docker (push) Successful in 30s
Deploy Beta / deploy-beta (push) Successful in 7m22s
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
2026-09-21 13:29:20 -07:00

664 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PCM Integrations (DirectMail API v3) postcard adapter."""
from __future__ import annotations
import json
import logging
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
import requests
from django.conf import settings
from messaging.providers.postcard import PostcardResult
logger = logging.getLogger(__name__)
PCM_API_BASE = "https://v3.pcmintegrations.com"
# Refresh a bit before PCM's expires timestamp.
_TOKEN_SKEW = timedelta(seconds=60)
# PCM size codes for custom designer designs.
PCM_SIZE_CHOICES = (
("46", "4.25 × 6"),
("68", "6 × 8.5"),
("69", "6 × 9"),
("611", "6 × 11"),
("811", "8.5 × 11"),
)
class PcmApiError(RuntimeError):
"""Raised when a PCM API call fails."""
_token_lock = threading.Lock()
_cached_token: str | None = None
_cached_expires: datetime | None = None
def _api_key() -> str:
return (settings.PCM_API_KEY or "").strip()
def _api_secret() -> str:
return (settings.PCM_API_SECRET or "").strip()
def _parse_expires(raw: Any) -> datetime:
"""Parse PCM expires timestamp; default to 55 minutes from now."""
if isinstance(raw, (int, float)):
# Unix seconds vs ms
ts = float(raw)
if ts > 1e12:
ts /= 1000.0
return datetime.fromtimestamp(ts, tz=timezone.utc)
if isinstance(raw, str) and raw.strip():
text = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(text)
except ValueError:
dt = None
if dt is not None:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
return datetime.now(timezone.utc) + timedelta(minutes=55)
def clear_token_cache() -> None:
"""Drop cached bearer token (tests / forced re-login)."""
global _cached_token, _cached_expires
with _token_lock:
_cached_token = None
_cached_expires = None
def login(*, force: bool = False) -> str:
"""POST /auth/login with apiKey + apiSecret → bearer token.
See https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in
"""
global _cached_token, _cached_expires
with _token_lock:
now = datetime.now(timezone.utc)
if (
not force
and _cached_token
and _cached_expires
and now < (_cached_expires - _TOKEN_SKEW)
):
return _cached_token
key = _api_key()
secret = _api_secret()
if not key or not secret:
raise PcmApiError(
"PCM_API_KEY and PCM_API_SECRET are required "
"(POST /auth/login, then use the returned token)"
)
body: dict[str, str] = {"apiKey": key, "apiSecret": secret}
child = (getattr(settings, "PCM_CHILD_REF_NBR", None) or "").strip()
if child:
body["childRefNbr"] = child
response = requests.post(
f"{PCM_API_BASE}/auth/login",
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
json=body,
timeout=30,
)
if response.status_code >= 400:
detail = (response.text or "")[:500]
raise PcmApiError(
f"PCM POST /auth/login → {response.status_code}: {detail}"
)
try:
data = response.json()
except ValueError as exc:
raise PcmApiError("PCM login returned non-JSON") from exc
if not isinstance(data, dict):
raise PcmApiError("PCM login returned unexpected payload")
token = str(data.get("token") or "").strip()
if not token:
raise PcmApiError("PCM login response missing token")
_cached_token = token
_cached_expires = _parse_expires(data.get("expires"))
logger.info(
"PCM login ok; token expires %s",
_cached_expires.isoformat(),
)
return token
def _headers(*, force_login: bool = False) -> dict[str, str]:
token = login(force=force_login)
return {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def pcm_request(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
timeout: int = 60,
) -> Any:
"""Call PCM v3 API. ``path`` is absolute under the API host (e.g. ``/design``)."""
url = f"{PCM_API_BASE}{path}"
response = requests.request(
method,
url,
headers=_headers(),
params=params,
json=json_body,
timeout=timeout,
)
# Expired/revoked session → login once and retry.
if response.status_code == 401:
clear_token_cache()
response = requests.request(
method,
url,
headers=_headers(force_login=True),
params=params,
json=json_body,
timeout=timeout,
)
if response.status_code >= 400:
detail = (response.text or "")[:500]
raise PcmApiError(
f"PCM {method} {path} → {response.status_code}: {detail}"
)
if not response.content:
return {}
try:
return response.json()
except ValueError:
return {"raw": response.text}
def return_address_from_settings() -> dict[str, str]:
"""Build PCM returnAddress from PCM_RETURN_ADDRESS JSON and/or PCM_RETURN_* vars.
PCM requires address/city/state (and zip) whenever firstName is set — an empty
street with SITE_NAME-derived firstName causes a 400 from POST /order/postcard.
"""
data: dict[str, Any] = {}
raw = (settings.PCM_RETURN_ADDRESS or "").strip()
if raw:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise PcmApiError(
"PCM_RETURN_ADDRESS must be valid JSON "
'(e.g. {"firstName":"…","lastName":"…","address":"…",'
'"city":"…","state":"IL","zipCode":"…"})'
) from exc
if not isinstance(parsed, dict):
raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
data = parsed
name = (settings.SITE_NAME or "").strip()
parts = name.split(None, 1)
site_first = parts[0] if parts else ""
site_last = parts[1] if len(parts) > 1 else ""
address = {
"company": str(data.get("company") or "").strip(),
"firstName": str(
data.get("firstName") or data.get("first_name") or site_first or ""
).strip(),
"lastName": str(
data.get("lastName") or data.get("last_name") or site_last or ""
).strip(),
"address": str(
data.get("address")
or data.get("line1")
or getattr(settings, "PCM_RETURN_LINE1", "")
or ""
).strip(),
# PCM treats blank address2 oddly; space matches recipient payload.
"address2": str(
data.get("address2")
or data.get("line2")
or getattr(settings, "PCM_RETURN_LINE2", "")
or ""
).strip()
or " ",
"city": str(
data.get("city") or getattr(settings, "PCM_RETURN_CITY", "") or ""
).strip(),
"state": str(
data.get("state") or getattr(settings, "PCM_RETURN_STATE", "") or ""
).strip(),
"zipCode": str(
data.get("zipCode")
or data.get("zip")
or getattr(settings, "PCM_RETURN_ZIP", "")
or ""
).strip(),
}
missing = [
field
for field in ("firstName", "address", "city", "state", "zipCode")
if not address.get(field, "").strip()
]
if missing:
raise PcmApiError(
"PCM return address incomplete (missing "
+ ", ".join(missing)
+ "). Set PCM_RETURN_ADDRESS JSON with firstName, lastName, "
"address, city, state, zipCode "
"(or PCM_RETURN_LINE1 / CITY / STATE / ZIP)."
)
return address
def contact_to_pcm_recipient(contact, *, ext_ref: str) -> dict[str, str]:
address = contact.postal_address or {}
line1 = (address.get("line1") or "").strip()
if not line1:
raise ValueError("Contact postal_address.line1 required for postcard")
first = (contact.first_name or "").strip()
last = (contact.last_name or "").strip()
if not first and not last:
# PCM requires name or company.
first = (contact.full_name or contact.email or "Resident").strip()
return {
"firstName": first,
"lastName": last,
"address": line1,
"address2": (address.get("line2") or "").strip() or " ",
"city": (address.get("city") or "").strip(),
"state": (address.get("state") or "").strip(),
"zipCode": (address.get("zip") or "").strip(),
"extRefNbr": ext_ref,
}
def list_designs(*, product_type: str = "postcard", page: int = 1, per_page: int = 50) -> list[dict]:
data = pcm_request(
"GET",
"/design",
params={
"productType": product_type,
"page": page,
"perPage": per_page,
},
)
if isinstance(data, dict):
results = data.get("results") or data.get("designs") or []
return results if isinstance(results, list) else []
return []
def create_custom_design(*, name: str, size: str) -> dict[str, Any]:
"""POST /design/custom → designID + embed url."""
return pcm_request(
"POST",
"/design/custom",
json_body={"name": name, "size": size},
)
def get_design_embed_url(design_id: int | str, *, duplicate: bool = False) -> str:
"""GET /design/{id}/edit?mode=embed → iframe URL."""
params: dict[str, Any] = {"mode": "embed"}
if duplicate:
params["duplicate"] = "true"
data = pcm_request("GET", f"/design/{design_id}/edit", params=params)
if not isinstance(data, dict):
raise PcmApiError("Unexpected embed response from PCM")
url = data.get("embed_url") or data.get("url") or ""
if not url:
raise PcmApiError("PCM did not return an embed URL")
return str(url)
def get_order(order_id: int | str) -> dict[str, Any]:
data = pcm_request("GET", f"/order/{order_id}")
return data if isinstance(data, dict) else {}
def get_design(design_id: int | str) -> dict[str, Any]:
data = pcm_request("GET", f"/design/{design_id}")
return data if isinstance(data, dict) else {}
FIRST_CLASS_ONLY_SIZES = frozenset({"46", "46S"})
MAX_SELECTED_ROUTES = 2
DEFAULT_LIST_TYPE = "IRL"
def mail_class_for_size(size: str | None) -> str:
key = str(size or "").strip()
if key in FIRST_CLASS_ONLY_SIZES:
return "FirstClass"
return "Standard"
def _env_unit_price(mail_class: str) -> float | None:
attr = (
"PCM_POSTCARD_PRICE_FIRSTCLASS"
if mail_class == "FirstClass"
else "PCM_POSTCARD_PRICE_STANDARD"
)
raw = str(getattr(settings, attr, "") or "").strip()
if not raw:
return None
try:
value = float(raw)
except ValueError:
return None
return value if value >= 0 else None
def pick_unit_price(
pricing: list[dict] | None,
*,
mail_class: str,
quantity: int,
) -> tuple[float | None, str]:
"""Return (dollars per piece, source). Source is pcm|fallback|missing."""
rows: list[dict] = []
for item in pricing or []:
if not isinstance(item, dict):
continue
if str(item.get("mailClass") or item.get("mail_class") or "") != mail_class:
continue
try:
price = float(item.get("price"))
break_qty = int(item.get("breakQty") or item.get("break_qty") or 0)
except (TypeError, ValueError):
continue
rows.append({"price": price, "breakQty": break_qty})
qty = max(int(quantity or 0), 0)
if rows:
eligible = [row for row in rows if qty >= row["breakQty"]]
chosen = max(eligible, key=lambda row: row["breakQty"]) if eligible else min(
rows, key=lambda row: row["breakQty"]
)
return chosen["price"], "pcm"
fallback = _env_unit_price(mail_class)
if fallback is not None:
return fallback, "fallback"
return None, "missing"
def design_pricing(design_id: int | None) -> list[dict]:
"""Load PCM per-piece pricing for a design (gallery payload preferred)."""
if not design_id:
return []
try:
design = get_design(design_id)
except PcmApiError:
design = {}
pricing = design.get("pricing") if isinstance(design, dict) else None
if isinstance(pricing, list) and pricing:
return [row for row in pricing if isinstance(row, dict)]
try:
for item in list_designs(product_type="postcard"):
if not isinstance(item, dict):
continue
did = item.get("designID") or item.get("design_id") or item.get("id")
if str(did) != str(design_id):
continue
extra = item.get("pricing")
if isinstance(extra, list):
return [row for row in extra if isinstance(row, dict)]
except PcmApiError:
logger.exception("PCM list_designs failed while loading pricing")
return []
def extract_crrt_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten ZipCRRT (or first) breakdown into {code, text, total} rows."""
breakdowns = payload.get("breakdown") if isinstance(payload, dict) else None
if not isinstance(breakdowns, list):
return []
preferred = None
fallback = None
for block in breakdowns:
if not isinstance(block, dict):
continue
kind = str(block.get("breakdownType") or "").upper().replace("_", "")
data = block.get("data") if isinstance(block.get("data"), list) else []
rows = []
for item in data:
if not isinstance(item, dict):
continue
code = str(item.get("code") or "").strip()
if not code:
continue
try:
total = int(item.get("total") or 0)
except (TypeError, ValueError):
total = 0
rows.append(
{
"code": code,
"text": str(item.get("text") or code).strip(),
"total": total,
}
)
if not rows:
continue
if kind in {"ZIPCRRT", "CRRT", "CARRIERROUTE"}:
preferred = rows
break
if fallback is None:
fallback = rows
return preferred or fallback or []
def _list_type() -> str:
return (getattr(settings, "PCM_LIST_TYPE", None) or DEFAULT_LIST_TYPE).strip() or DEFAULT_LIST_TYPE
def list_count_zipcode(*, zip_codes: list[str], list_type: str | None = None) -> dict[str, Any]:
data = pcm_request(
"POST",
"/list/count/zipcode",
json_body={
"listType": list_type or _list_type(),
"breakdownType": "ZipCRRT",
"zipCodes": zip_codes,
"demographics": [],
},
)
return data if isinstance(data, dict) else {}
def list_count_radius(
*,
address: str,
city: str,
state: str,
zip_code: str,
miles: float,
list_type: str | None = None,
) -> dict[str, Any]:
data = pcm_request(
"POST",
"/list/count/radius",
json_body={
"listType": list_type or _list_type(),
"breakdownType": "ZipCRRT",
"radius": {
"radius": miles,
"address": address,
"city": city,
"state": state,
"zip": zip_code,
},
"demographics": [],
},
)
return data if isinstance(data, dict) else {}
def list_count_carrier_route(
*,
carrier_routes: list[str],
list_type: str | None = None,
) -> dict[str, Any]:
data = pcm_request(
"POST",
"/list/count/carrier-route",
json_body={
"listType": list_type or _list_type(),
"breakdownType": "ZipCRRT",
"carrierRoutes": carrier_routes,
"demographics": [],
},
)
return data if isinstance(data, dict) else {}
def _order_id_from_response(data: dict[str, Any]) -> str:
order_id = data.get("orderID") or data.get("orderId") or data.get("id")
if order_id is None and isinstance(data.get("results"), list) and data["results"]:
order_id = data["results"][0].get("orderID")
if order_id is None:
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400])
return str(order_id)
def place_postcard_order(
*,
design_id: int,
recipient: dict[str, str],
ext_ref: str,
mail_class: str = "FirstClass",
) -> str:
"""Place a one-recipient postcard order; return PCM orderID as string."""
payload = {
"designID": design_id,
"mailClass": mail_class,
"extRefNbr": ext_ref,
"returnAddress": return_address_from_settings(),
"recipients": [recipient],
}
# Product-specific path — plain POST /order is 404 ("Cannot POST /order").
# Docs: Place Postcard Order → POST /order/postcard
data = pcm_request("POST", "/order/postcard", json_body=payload)
if not isinstance(data, dict):
raise PcmApiError("Unexpected order response from PCM")
return _order_id_from_response(data)
def place_postcard_order_with_list_count(
*,
design_id: int,
list_count_id: int,
record_count: int,
ext_ref: str,
mail_class: str = "Standard",
) -> str:
"""Place a postcard order against a PCM purchased list count."""
payload = {
"designID": design_id,
"mailClass": mail_class,
"extRefNbr": ext_ref,
"listCountID": list_count_id,
"recordCount": record_count,
"returnAddress": return_address_from_settings(),
}
data = pcm_request(
"POST", "/order/postcard/with-list-count", json_body=payload
)
if not isinstance(data, dict):
raise PcmApiError("Unexpected list-count order response from PCM")
return _order_id_from_response(data)
def design_id_from_template(template) -> int | None:
"""Read design_id from MessageTemplate.postcard_front JSON."""
if not template:
return None
front = template.postcard_front or {}
if not isinstance(front, dict):
return None
raw = front.get("design_id") or front.get("designID")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
@dataclass
class PcmProvider:
name: str = "pcm"
def send_postcard(self, message) -> PostcardResult:
template = message.campaign.template if message.campaign_id else None
design_id = design_id_from_template(template)
if not design_id:
raise ValueError(
"Postcard campaign template missing PCM design_id "
"(save a design from the postcard designer first)"
)
targeting = {}
if message.campaign_id and isinstance(
getattr(message.campaign, "route_targeting", None), dict
):
targeting = message.campaign.route_targeting
list_count_id = targeting.get("list_count_id")
record_count = targeting.get("record_count")
if list_count_id and record_count:
mail_class = str(targeting.get("mail_class") or "Standard")
order_id = place_postcard_order_with_list_count(
design_id=design_id,
list_count_id=int(list_count_id),
record_count=int(record_count),
ext_ref=str(message.pk),
mail_class=mail_class,
)
return PostcardResult(provider_id=order_id)
recipient = contact_to_pcm_recipient(
message.contact, ext_ref=str(message.pk)
)
mail_class = "FirstClass"
if template and isinstance(template.postcard_front, dict):
mail_class = (
template.postcard_front.get("mail_class") or mail_class
)
order_id = place_postcard_order(
design_id=design_id,
recipient=recipient,
ext_ref=str(message.pk),
mail_class=str(mail_class),
)
return PostcardResult(provider_id=order_id)
def get_status(self, provider_id: str) -> str:
try:
data = get_order(provider_id)
except PcmApiError:
logger.exception("PCM get_status failed for %s", provider_id)
return "unknown"
return str(data.get("status") or "unknown")