Plain POST /order 404s at PCM; product-specific path is required for sandbox and production sends.
379 lines
12 KiB
Python
379 lines
12 KiB
Python
"""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")
|