Fix PCM auth: login for API token and multi webhook secrets.
PCM DirectMail v3 needs POST /auth/login (apiKey+apiSecret) before design/order calls; accept each subscription's copy-only signature secret via PCM_WEBHOOK_SECRETS.
This commit is contained in:
@@ -4,7 +4,9 @@ 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
|
||||
@@ -16,6 +18,9 @@ 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"),
|
||||
@@ -30,17 +35,117 @@ 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 _headers() -> dict[str, str]:
|
||||
key = _api_key()
|
||||
if not key:
|
||||
raise PcmApiError("PCM_API_KEY is not configured")
|
||||
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 {key}",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@@ -63,6 +168,17 @@ def pcm_request(
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user