Require a complete PCM return address before placing orders.
Empty PCM_RETURN_ADDRESS was sending SITE_NAME as firstName with blank street fields, which PCM rejects with 400; validate early and wire PCM env through compose.
This commit is contained in:
+3
-2
@@ -57,8 +57,9 @@ PCM_API_SECRET=
|
||||
# Paste all of them comma-separated (also accepts single PCM_WEBHOOK_SECRET=).
|
||||
PCM_WEBHOOK_SECRETS=
|
||||
# PCM_WEBHOOK_SECRET=
|
||||
# Return address on every postcard order (JSON). Example:
|
||||
# PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"}
|
||||
# Required return address on every postcard order.
|
||||
# Quote the JSON (compose/shell break on bare {…, …}).
|
||||
# PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"}'
|
||||
PCM_RETURN_ADDRESS=
|
||||
# Or set fields individually if JSON is empty:
|
||||
# PCM_RETURN_LINE1=
|
||||
|
||||
+2
-2
@@ -66,8 +66,8 @@ PCM_API_SECRET=replace-me
|
||||
PCM_WEBHOOK_SECRETS=replace-me,replace-me-2
|
||||
# PCM_WEBHOOK_SECRET=replace-me # optional single-secret alias
|
||||
|
||||
# JSON return address for postcard orders
|
||||
PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"}
|
||||
# JSON return address for postcard orders (quote so compose keeps commas)
|
||||
PCM_RETURN_ADDRESS='{"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"}'
|
||||
# LOB_API_KEY= # only if POSTCARD_PROVIDER=lob
|
||||
# CLICK2MAIL_API_KEY=
|
||||
# POSTGRID_API_KEY=
|
||||
|
||||
@@ -45,6 +45,19 @@ services:
|
||||
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
||||
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
|
||||
POSTCARD_PROVIDER: ${POSTCARD_PROVIDER:-pcm}
|
||||
PCM_API_KEY: ${PCM_API_KEY:-}
|
||||
PCM_API_SECRET: ${PCM_API_SECRET:-}
|
||||
PCM_CHILD_REF_NBR: ${PCM_CHILD_REF_NBR:-}
|
||||
PCM_WEBHOOK_SECRETS: ${PCM_WEBHOOK_SECRETS:-}
|
||||
PCM_WEBHOOK_SECRET: ${PCM_WEBHOOK_SECRET:-}
|
||||
# Quote JSON in `.env`: PCM_RETURN_ADDRESS='{"firstName":"…",...}'
|
||||
PCM_RETURN_ADDRESS: ${PCM_RETURN_ADDRESS:-}
|
||||
PCM_RETURN_LINE1: ${PCM_RETURN_LINE1:-}
|
||||
PCM_RETURN_LINE2: ${PCM_RETURN_LINE2:-}
|
||||
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
|
||||
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
|
||||
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -70,6 +83,18 @@ services:
|
||||
EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.console.EmailBackend}
|
||||
SMTP2GO_SMS_API_KEY: ${SMTP2GO_SMS_API_KEY:-}
|
||||
SMTP2GO_WEBHOOK_SECRET: ${SMTP2GO_WEBHOOK_SECRET:-}
|
||||
POSTCARD_PROVIDER: ${POSTCARD_PROVIDER:-pcm}
|
||||
PCM_API_KEY: ${PCM_API_KEY:-}
|
||||
PCM_API_SECRET: ${PCM_API_SECRET:-}
|
||||
PCM_CHILD_REF_NBR: ${PCM_CHILD_REF_NBR:-}
|
||||
PCM_WEBHOOK_SECRETS: ${PCM_WEBHOOK_SECRETS:-}
|
||||
PCM_WEBHOOK_SECRET: ${PCM_WEBHOOK_SECRET:-}
|
||||
PCM_RETURN_ADDRESS: ${PCM_RETURN_ADDRESS:-}
|
||||
PCM_RETURN_LINE1: ${PCM_RETURN_LINE1:-}
|
||||
PCM_RETURN_LINE2: ${PCM_RETURN_LINE2:-}
|
||||
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
|
||||
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
|
||||
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -193,38 +193,82 @@ def pcm_request(
|
||||
|
||||
|
||||
def return_address_from_settings() -> dict[str, str]:
|
||||
"""Build PCM returnAddress from PCM_RETURN_ADDRESS JSON or CONTACT_* vars."""
|
||||
"""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:
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
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")
|
||||
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 ""),
|
||||
}
|
||||
data = parsed
|
||||
|
||||
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 ""),
|
||||
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 {}
|
||||
|
||||
@@ -1079,6 +1079,34 @@ class PcmAuthTests(TestCase):
|
||||
pcm_mod.login(force=True)
|
||||
self.assertIn("PCM_API_SECRET", str(ctx.exception))
|
||||
|
||||
def test_return_address_requires_street_fields(self):
|
||||
from messaging.providers.postcard import pcm as pcm_mod
|
||||
|
||||
with self.settings(
|
||||
SITE_NAME="Monica Dhillon",
|
||||
PCM_RETURN_ADDRESS="",
|
||||
PCM_RETURN_LINE1="",
|
||||
PCM_RETURN_CITY="",
|
||||
PCM_RETURN_STATE="",
|
||||
PCM_RETURN_ZIP="",
|
||||
):
|
||||
with self.assertRaises(pcm_mod.PcmApiError) as ctx:
|
||||
pcm_mod.return_address_from_settings()
|
||||
self.assertIn("PCM return address incomplete", str(ctx.exception))
|
||||
|
||||
def test_return_address_from_json(self):
|
||||
from messaging.providers.postcard import pcm as pcm_mod
|
||||
|
||||
with self.settings(
|
||||
PCM_RETURN_ADDRESS=(
|
||||
'{"firstName":"Mo","lastName":"D","address":"1 Main",'
|
||||
'"city":"Naperville","state":"IL","zipCode":"60540"}'
|
||||
),
|
||||
):
|
||||
addr = pcm_mod.return_address_from_settings()
|
||||
self.assertEqual(addr["address"], "1 Main")
|
||||
self.assertEqual(addr["zipCode"], "60540")
|
||||
|
||||
|
||||
class PostcardAddressDefaultConsentTests(TestCase):
|
||||
def test_address_without_consent_is_postcard_eligible(self):
|
||||
|
||||
Reference in New Issue
Block a user