Add Django site, Docker packaging, and beta/prod Gitea deploys.
Deploy Beta / unit-tests (push) Successful in 9s
Deploy Beta / docker (push) Successful in 17s
Deploy Beta / deploy-beta (push) Successful in 2m31s

Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
2026-08-08 07:32:55 -05:00
parent 7dca98bbf6
commit 1f7d78de64
204 changed files with 21662 additions and 70 deletions
+260
View File
@@ -0,0 +1,260 @@
"""PCM Integrations (DirectMail API v3) postcard adapter."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
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"
# 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."""
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")
return {
"Accept": "application/json",
"Authorization": f"Bearer {key}",
"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,
)
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],
}
data = pcm_request("POST", "/order", 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")