Files
monica_site/site/messaging/providers/postcard/pcm.py
T
westfarn 7fb5317308
Deploy Beta / unit-tests (push) Successful in 12s
Deploy Beta / docker (push) Successful in 18s
Deploy Beta / deploy-beta (push) Successful in 1m38s
Fix PCM postcard orders to use POST /order/postcard.
Plain POST /order 404s at PCM; product-specific path is required for sandbox and production sends.
2026-08-10 14:47:10 -05:00

379 lines
12 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 or CONTACT_* vars."""
raw = (settings.PCM_RETURN_ADDRESS or "").strip()
if raw:
data = json.loads(raw)
if not isinstance(data, dict):
raise PcmApiError("PCM_RETURN_ADDRESS must be a JSON object")
return {
"company": str(data.get("company") or ""),
"firstName": str(data.get("firstName") or data.get("first_name") or ""),
"lastName": str(data.get("lastName") or data.get("last_name") or ""),
"address": str(data.get("address") or data.get("line1") or ""),
"address2": str(data.get("address2") or data.get("line2") or ""),
"city": str(data.get("city") or ""),
"state": str(data.get("state") or ""),
"zipCode": str(data.get("zipCode") or data.get("zip") or ""),
}
name = (settings.SITE_NAME or "").strip()
parts = name.split(None, 1)
first = parts[0] if parts else "Monica"
last = parts[1] if len(parts) > 1 else ""
return {
"company": "",
"firstName": first,
"lastName": last,
"address": str(getattr(settings, "PCM_RETURN_LINE1", "") or ""),
"address2": str(getattr(settings, "PCM_RETURN_LINE2", "") or ""),
"city": str(getattr(settings, "PCM_RETURN_CITY", "") or ""),
"state": str(getattr(settings, "PCM_RETURN_STATE", "") or ""),
"zipCode": str(getattr(settings, "PCM_RETURN_ZIP", "") or ""),
}
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 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")
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 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)"
)
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")