"""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")