generated from westfarn/web_django_template
Initial commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""Pluggable postcard providers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostcardResult:
|
||||
provider_id: str
|
||||
|
||||
|
||||
class PostcardProvider(Protocol):
|
||||
name: str
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult: ...
|
||||
|
||||
def get_status(self, provider_id: str) -> str: ...
|
||||
|
||||
|
||||
def get_postcard_provider() -> PostcardProvider:
|
||||
name = (settings.POSTCARD_PROVIDER or "pcm").lower()
|
||||
if name == "pcm":
|
||||
from directmail.providers.postcard.pcm import PcmProvider
|
||||
|
||||
return PcmProvider()
|
||||
if name == "click2mail":
|
||||
from directmail.providers.postcard.click2mail import Click2MailProvider
|
||||
|
||||
return Click2MailProvider()
|
||||
if name == "postgrid":
|
||||
from directmail.providers.postcard.postgrid import PostGridProvider
|
||||
|
||||
return PostGridProvider()
|
||||
if name == "lob":
|
||||
from directmail.providers.postcard.lob import LobProvider
|
||||
|
||||
return LobProvider()
|
||||
from directmail.providers.postcard.pcm import PcmProvider
|
||||
|
||||
return PcmProvider()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Click2Mail postcard adapter (low-volume pay-per-piece option)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from directmail.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class Click2MailProvider:
|
||||
name: str = "click2mail"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
if not settings.CLICK2MAIL_API_KEY:
|
||||
raise RuntimeError("CLICK2MAIL_API_KEY is not configured")
|
||||
# Placeholder: wire full Click2Mail job API when account credentials are ready.
|
||||
raise NotImplementedError(
|
||||
"Click2Mail adapter stub — configure account then implement job submit"
|
||||
)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Lob postcard adapter (default)."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from directmail.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobProvider:
|
||||
name: str = "lob"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
api_key = settings.LOB_API_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("LOB_API_KEY is not configured")
|
||||
|
||||
contact = message.contact
|
||||
address = contact.postal_address or {}
|
||||
if not address.get("line1"):
|
||||
raise ValueError("Contact postal_address.line1 required for postcard")
|
||||
|
||||
# Minimal Lob create-postcard payload; artwork URLs come from template JSON.
|
||||
template = message.campaign.template
|
||||
front = (template.postcard_front if template else {}) or {}
|
||||
back = (template.postcard_back if template else {}) or {}
|
||||
|
||||
payload = {
|
||||
"description": f"campaign-{message.campaign_id}",
|
||||
"to": {
|
||||
"name": contact.full_name or contact.email or "Resident",
|
||||
"address_line1": address.get("line1", ""),
|
||||
"address_line2": address.get("line2", ""),
|
||||
"address_city": address.get("city", ""),
|
||||
"address_state": address.get("state", ""),
|
||||
"address_zip": address.get("zip", ""),
|
||||
"address_country": address.get("country", "US"),
|
||||
},
|
||||
"front": front.get("html") or front.get("url") or "<html></html>",
|
||||
"back": back.get("html") or back.get("url") or "<html></html>",
|
||||
}
|
||||
response = requests.post(
|
||||
"https://api.lob.com/v1/postcards",
|
||||
json=payload,
|
||||
auth=(api_key, ""),
|
||||
timeout=60,
|
||||
headers={"Idempotency-Key": str(message.pk)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return PostcardResult(provider_id=str(data.get("id") or message.pk))
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
api_key = settings.LOB_API_KEY
|
||||
if not api_key:
|
||||
return "unknown"
|
||||
response = requests.get(
|
||||
f"https://api.lob.com/v1/postcards/{provider_id}",
|
||||
auth=(api_key, ""),
|
||||
timeout=30,
|
||||
)
|
||||
if not response.ok:
|
||||
return "unknown"
|
||||
return str(response.json().get("status") or "unknown")
|
||||
@@ -0,0 +1,422 @@
|
||||
"""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 directmail.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 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")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""PostGrid postcard adapter."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from directmail.providers.postcard import PostcardResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostGridProvider:
|
||||
name: str = "postgrid"
|
||||
|
||||
def send_postcard(self, message) -> PostcardResult:
|
||||
if not settings.POSTGRID_API_KEY:
|
||||
raise RuntimeError("POSTGRID_API_KEY is not configured")
|
||||
raise NotImplementedError(
|
||||
"PostGrid adapter stub — configure account then implement send"
|
||||
)
|
||||
|
||||
def get_status(self, provider_id: str) -> str:
|
||||
return "unknown"
|
||||
Reference in New Issue
Block a user