From 59bc0c315150f6e83519073e71e4e491ad7444d8 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 21 Sep 2026 15:27:06 -0500 Subject: [PATCH] Add PCM carrier-route postcard pick-and-send (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #11. - Mirror Monica: compose can use the postcard mailing list or 1–2 USPS carrier routes, with estimated cost shown. - Place selected routes through PCM list-count orders instead of importing occupant addresses. --- .env.example | 4 + .env.prod.example | 3 + docker-compose.yml | 6 + site/client_site/settings/base.py | 3 + site/client_site/static/js/postcard-routes.js | 250 +++++++++++++++++ site/directmail/README.md | 13 + .../migrations/0002_carrier_route_audience.py | 39 +++ site/directmail/models.py | 12 +- site/directmail/providers/postcard/pcm.py | 253 +++++++++++++++++- site/directmail/services.py | 219 +++++++++++++++ site/directmail/tasks.py | 4 +- .../directmail/_postcard_route_picker.html | 50 ++++ .../templates/directmail/campaign_detail.html | 37 ++- .../templates/directmail/campaign_list.html | 15 +- site/directmail/tests.py | 202 ++++++++++++++ site/directmail/urls.py | 5 + site/directmail/views.py | 104 ++++++- 17 files changed, 1192 insertions(+), 27 deletions(-) create mode 100644 site/client_site/static/js/postcard-routes.js create mode 100644 site/directmail/migrations/0002_carrier_route_audience.py create mode 100644 site/directmail/templates/directmail/_postcard_route_picker.html diff --git a/.env.example b/.env.example index d889881..99a481f 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,10 @@ PCM_WEBHOOK_SECRETS= # Quote JSON (compose/shell break on bare {…, …}). # PCM_RETURN_ADDRESS='{"firstName":"Jane","lastName":"Doe","address":"123 Main","city":"Town","state":"IL","zipCode":"60189"}' PCM_RETURN_ADDRESS= +# Fallback $/piece when PCM design has no pricing[]; used by compose cost quotes. +# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89 +# PCM_POSTCARD_PRICE_STANDARD=0.55 +# PCM_LIST_TYPE=IRL # Stripe — required when FEATURE_PAYMENTS=true STRIPE_SECRET_KEY= diff --git a/.env.prod.example b/.env.prod.example index 5e51741..cb93418 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -60,6 +60,9 @@ PCM_API_KEY= PCM_API_SECRET= PCM_WEBHOOK_SECRETS= PCM_RETURN_ADDRESS= +# PCM_POSTCARD_PRICE_STANDARD=0.55 +# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89 +# PCM_LIST_TYPE=IRL # Required when FEATURE_PAYMENTS=true (also requires FEATURE_EMAIL_SMS=true) STRIPE_SECRET_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 8e29046..593b2b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,6 +68,9 @@ services: PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} + PCM_POSTCARD_PRICE_FIRSTCLASS: ${PCM_POSTCARD_PRICE_FIRSTCLASS:-} + PCM_POSTCARD_PRICE_STANDARD: ${PCM_POSTCARD_PRICE_STANDARD:-} + PCM_LIST_TYPE: ${PCM_LIST_TYPE:-IRL} META_APP_ID: ${META_APP_ID:-} META_APP_SECRET: ${META_APP_SECRET:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} @@ -111,6 +114,9 @@ services: PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} + PCM_POSTCARD_PRICE_FIRSTCLASS: ${PCM_POSTCARD_PRICE_FIRSTCLASS:-} + PCM_POSTCARD_PRICE_STANDARD: ${PCM_POSTCARD_PRICE_STANDARD:-} + PCM_LIST_TYPE: ${PCM_LIST_TYPE:-IRL} META_APP_ID: ${META_APP_ID:-} META_APP_SECRET: ${META_APP_SECRET:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} diff --git a/site/client_site/settings/base.py b/site/client_site/settings/base.py index ca089e7..b77752c 100644 --- a/site/client_site/settings/base.py +++ b/site/client_site/settings/base.py @@ -349,6 +349,9 @@ PCM_RETURN_LINE2 = env("PCM_RETURN_LINE2", "") PCM_RETURN_CITY = env("PCM_RETURN_CITY", "") PCM_RETURN_STATE = env("PCM_RETURN_STATE", "") PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "") +PCM_POSTCARD_PRICE_FIRSTCLASS = env("PCM_POSTCARD_PRICE_FIRSTCLASS", "") +PCM_POSTCARD_PRICE_STANDARD = env("PCM_POSTCARD_PRICE_STANDARD", "") +PCM_LIST_TYPE = env("PCM_LIST_TYPE", "IRL") # Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid) LOB_API_KEY = env("LOB_API_KEY", "") CLICK2MAIL_API_KEY = env("CLICK2MAIL_API_KEY", "") diff --git a/site/client_site/static/js/postcard-routes.js b/site/client_site/static/js/postcard-routes.js new file mode 100644 index 0000000..0732482 --- /dev/null +++ b/site/client_site/static/js/postcard-routes.js @@ -0,0 +1,250 @@ +(function () { + function money(n) { + if (n === null || n === undefined || n === "") return "—"; + var v = Number(n); + if (Number.isNaN(v)) return "—"; + return "$" + v.toFixed(2); + } + + function initPostcardRoutes(opts) { + var form = document.getElementById("campaign-compose"); + if (!form) return; + var audience = document.getElementById("id_audience"); + var picker = document.getElementById("postcard-route-picker"); + var listCost = document.getElementById("postcard-list-cost"); + var hidden = document.getElementById("id_route_targeting"); + var findBtn = document.getElementById("route-find-btn"); + var status = document.getElementById("route-status"); + var tableWrap = document.getElementById("route-table-wrap"); + var tbody = document.getElementById("route-table-body"); + var selectedBox = document.getElementById("route-selected-cost"); + var quoteUrl = opts.quoteUrl; + var csrfToken = opts.csrfToken; + var maxRoutes = 2; + var routes = []; + + function isPostcardAudience() { + var v = (audience && audience.value) || ""; + return v === "postcard_opt_in" || v === "carrier_route"; + } + + function isRouteAudience() { + return ((audience && audience.value) || "") === "carrier_route"; + } + + function templateId() { + return (document.getElementById("id_template_id") || {}).value || ""; + } + + function setStatus(text, isError) { + if (!status) return; + status.textContent = text || ""; + status.classList.toggle("error", !!isError); + } + + function selectedCodes() { + return Array.prototype.map.call( + form.querySelectorAll('input[name="route_code"]:checked'), + function (el) { return el.value; } + ); + } + + function postQuote(body) { + return fetch(quoteUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + Accept: "application/json" + }, + credentials: "same-origin", + body: JSON.stringify(body) + }).then(function (res) { + return res.json().then(function (json) { + if (!res.ok) throw new Error(json.detail || "Quote failed"); + return json; + }); + }); + } + + function renderListCost(data) { + if (!listCost) return; + if (!isPostcardAudience() || isRouteAudience()) { + listCost.hidden = true; + return; + } + listCost.hidden = false; + var src = data.price_source === "fallback" ? " (env fallback rate)" : ""; + if (data.per_piece == null) { + listCost.innerHTML = + "Estimated cost: " + data.count + + " household" + (data.count === 1 ? "" : "s") + + " · per-piece price unavailable. Set PCM_POSTCARD_PRICE_STANDARD / FIRSTCLASS."; + return; + } + listCost.innerHTML = + "Estimated cost: " + data.count + + " × " + money(data.per_piece) + " = " + money(data.total) + + " (" + data.mail_class + src + ")"; + } + + function refreshListCost() { + if (!isPostcardAudience() || isRouteAudience()) { + if (listCost) listCost.hidden = true; + return; + } + postQuote({ action: "mailing_list", template_id: templateId() }) + .then(renderListCost) + .catch(function (err) { + if (listCost) { + listCost.hidden = false; + listCost.textContent = err.message || "Could not load mailing-list cost."; + } + }); + } + + function writeHidden(payload) { + if (hidden) hidden.value = payload ? JSON.stringify(payload) : ""; + } + + function renderSelected(data) { + if (!selectedBox) return; + if (!data) { + selectedBox.hidden = true; + writeHidden(""); + return; + } + selectedBox.hidden = false; + var src = data.price_source === "fallback" ? " using env fallback rate" : ""; + var missing = data.per_piece == null + ? " Per-piece price unavailable — set PCM_POSTCARD_PRICE_* before sending." + : ""; + selectedBox.innerHTML = + "Selected: " + (data.record_count || 0) + + " household" + ((data.record_count || 0) === 1 ? "" : "s") + + " on " + (data.selected_codes || []).join(", ") + + " · " + (data.mail_class || "") + + " · est. " + money(data.total) + src + "." + missing; + writeHidden({ + selected_codes: data.selected_codes, + list_count_id: data.list_count_id, + record_count: data.record_count, + mail_class: data.mail_class, + estimated: data.estimated, + route_rows: data.route_rows || data.routes + }); + } + + function recount() { + var codes = selectedCodes(); + if (!codes.length) { + renderSelected(null); + setStatus("Select one or two routes."); + return; + } + setStatus("Updating quote…"); + postQuote({ + action: "select", + template_id: templateId(), + routes: codes + }).then(function (data) { + setStatus(""); + renderSelected(data); + }).catch(function (err) { + renderSelected(null); + setStatus(err.message || "Could not quote selected routes.", true); + }); + } + + function renderRoutes(list) { + routes = list || []; + if (!tbody || !tableWrap) return; + if (!routes.length) { + tableWrap.hidden = true; + tbody.innerHTML = ""; + return; + } + tableWrap.hidden = false; + tbody.innerHTML = routes.map(function (row) { + return "" + + "" + + "" + String(row.text || "") + "" + + "" + String(row.total) + "" + + "" + money(row.cost) + ""; + }).join(""); + tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) { + box.addEventListener("change", function () { + var checked = selectedCodes(); + if (checked.length > maxRoutes) { + box.checked = false; + setStatus("Pick at most two routes.", true); + return; + } + recount(); + }); + }); + } + + function findRoutes() { + var zip = (document.getElementById("route-zip") || {}).value || ""; + var address = (document.getElementById("route-address") || {}).value || ""; + var city = (document.getElementById("route-city") || {}).value || ""; + var state = (document.getElementById("route-state") || {}).value || ""; + var radius = (document.getElementById("route-radius") || {}).value || ""; + setStatus("Looking up routes…"); + tableWrap.hidden = true; + renderSelected(null); + postQuote({ + action: "search", + template_id: templateId(), + zip: zip, + address: address, + city: city, + state: state, + radius: radius + }).then(function (data) { + renderRoutes(data.routes || []); + if (!(data.routes || []).length) { + setStatus("No carrier routes for that search.", true); + } else { + setStatus("Select one or two routes. Cost uses " + (data.mail_class || "Standard") + "."); + } + }).catch(function (err) { + renderRoutes([]); + setStatus(err.message || "Route lookup failed.", true); + }); + } + + function syncVisibility() { + if (picker) picker.hidden = !isRouteAudience(); + if (!isRouteAudience()) { + writeHidden(""); + if (selectedBox) selectedBox.hidden = true; + } + refreshListCost(); + } + + if (findBtn) findBtn.addEventListener("click", function (e) { + e.preventDefault(); + findRoutes(); + }); + var tmpl = document.getElementById("id_template_id"); + if (tmpl) tmpl.addEventListener("change", function () { + syncVisibility(); + if (isRouteAudience() && selectedCodes().length) recount(); + }); + if (audience) audience.addEventListener("change", syncVisibility); + syncVisibility(); + + window.MonicaPostcardRoutes = { + isPostcardAudience: isPostcardAudience, + isRouteAudience: isRouteAudience, + syncVisibility: syncVisibility + }; + } + + window.initPostcardRoutes = initPostcardRoutes; +})(); diff --git a/site/directmail/README.md b/site/directmail/README.md index bd34eac..68fbedc 100644 --- a/site/directmail/README.md +++ b/site/directmail/README.md @@ -62,6 +62,9 @@ Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail | `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) | | `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) | | `PCM_RETURN_ADDRESS` | JSON return address on orders | +| `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing | +| `PCM_POSTCARD_PRICE_STANDARD` | Fallback $/piece for Standard | +| `PCM_LIST_TYPE` | PCM purchased-list type (default `IRL`) | | `POSTCARD_PROVIDER` | `pcm` (default) | Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived @@ -93,6 +96,16 @@ HMAC-SHA256 of body) matching **any** listed secret. Correlation: we send `extRefNbr=` on each recipient; webhooks should echo that (or `orderID`, matched to `Message.provider_message_id`). +### Carrier-route (neighborhood) mailings + +Postcard compose can target **mailing-list contacts** or **USPS carrier routes**. +Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with +`breakdownType=ZipCRRT`, then recounts selected routes via +`POST /list/count/carrier-route` and sends +`POST /order/postcard/with-list-count`. Estimated cost uses design `pricing[]` +or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default +purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage. + ### Campaign completion email When a campaign reaches **completed** (email, SMS, or postcard), one summary email diff --git a/site/directmail/migrations/0002_carrier_route_audience.py b/site/directmail/migrations/0002_carrier_route_audience.py new file mode 100644 index 0000000..327e6c4 --- /dev/null +++ b/site/directmail/migrations/0002_carrier_route_audience.py @@ -0,0 +1,39 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("directmail", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="campaign", + name="route_targeting", + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name="campaign", + name="audience", + field=models.CharField( + blank=True, + choices=[ + ("postcard_opt_in", "Mailing list · postcard opt-in"), + ("carrier_route", "USPS carrier routes"), + ], + default="", + max_length=32, + ), + ), + migrations.AlterField( + model_name="message", + name="contact", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="directmail_messages", + to="contacts.contact", + ), + ), + ] diff --git a/site/directmail/models.py b/site/directmail/models.py index 524f181..8b4a2d7 100644 --- a/site/directmail/models.py +++ b/site/directmail/models.py @@ -27,6 +27,7 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel): class Audience(models.TextChoices): POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in" + CARRIER_ROUTE = "carrier_route", "USPS carrier routes" name = models.CharField(max_length=120) channel = models.CharField(max_length=16, choices=Channel.choices) @@ -58,6 +59,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel): body_override = models.TextField(blank=True) # Set when completion summary email is sent (campaign COMPLETED). notify_sent_at = models.DateTimeField(null=True, blank=True) + # PCM purchased-list targeting (ZIP/CRRT codes, listCountID, quote). + route_targeting = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] @@ -83,7 +86,11 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel): Campaign, on_delete=models.CASCADE, related_name="messages" ) contact = models.ForeignKey( - Contact, on_delete=models.CASCADE, related_name="directmail_messages" + Contact, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="directmail_messages", ) channel = models.CharField(max_length=16, choices=Channel.choices) status = models.CharField( @@ -100,7 +107,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel): ordering = ["-created_at"] def __str__(self) -> str: - return f"{self.channel} → {self.contact} ({self.status})" + who = self.contact or "carrier-route mailing" + return f"{self.channel} → {who} ({self.status})" class ProviderEvent(TimeStampedModel): diff --git a/site/directmail/providers/postcard/pcm.py b/site/directmail/providers/postcard/pcm.py index f27fe2a..203bda6 100644 --- a/site/directmail/providers/postcard/pcm.py +++ b/site/directmail/providers/postcard/pcm.py @@ -338,6 +338,210 @@ def get_order(order_id: int | str) -> dict[str, Any]: 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, @@ -358,13 +562,32 @@ def place_postcard_order( 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) - 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_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: @@ -396,6 +619,24 @@ class PcmProvider: "(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) ) diff --git a/site/directmail/services.py b/site/directmail/services.py index 17b9831..6d1aec3 100644 --- a/site/directmail/services.py +++ b/site/directmail/services.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: AUDIENCE_CHANNEL = { Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD, + Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD, } # {{first_name}} preferred; {first_name} also accepted (composer hint legacy). @@ -84,6 +85,211 @@ def channel_for_audience(audience: str) -> str: raise ValueError(f"Unknown audience: {audience}") from exc +def parse_route_targeting(raw: str | dict | None) -> dict: + """Parse compose JSON for a carrier-route campaign.""" + from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES + + data: dict = {} + if isinstance(raw, dict): + data = raw + elif isinstance(raw, str) and raw.strip(): + import json + + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError("Carrier route selection is invalid JSON.") from exc + if not isinstance(parsed, dict): + raise ValueError("Carrier route selection must be an object.") + data = parsed + codes = [ + str(code).strip() + for code in (data.get("selected_codes") or data.get("routes") or []) + if str(code).strip() + ] + # Allow routes as list of objects from the quote endpoint. + if not codes and isinstance(data.get("routes"), list): + codes = [ + str(row.get("code") or "").strip() + for row in data["routes"] + if isinstance(row, dict) + ] + codes = [c for c in codes if c] + codes = list(dict.fromkeys(codes)) + if not codes: + raise ValueError("Select one or two USPS carrier routes.") + if len(codes) > MAX_SELECTED_ROUTES: + raise ValueError("Pick at most two carrier routes.") + try: + list_count_id = int(data.get("list_count_id") or 0) + record_count = int(data.get("record_count") or 0) + except (TypeError, ValueError) as exc: + raise ValueError("Route quote is missing list count.") from exc + if list_count_id <= 0 or record_count <= 0: + raise ValueError("Find routes and select them before saving (need a PCM quote).") + estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {} + route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else [] + return { + "selected_codes": codes, + "list_count_id": list_count_id, + "record_count": record_count, + "mail_class": str(data.get("mail_class") or "Standard"), + "list_type": str(data.get("list_type") or "IRL"), + "zip": str(data.get("zip") or ""), + "estimated": estimated, + "route_rows": route_rows, + } + + +def money_total(per_piece: float | None, quantity: int) -> float | None: + if per_piece is None: + return None + return round(float(per_piece) * max(int(quantity or 0), 0), 2) + + +def postcard_quote(*, action: str, payload: dict) -> dict: + """Search routes or price a mailing-list / selected-route postcard.""" + from directmail.providers.postcard.pcm import ( + MAX_SELECTED_ROUTES, + design_pricing, + extract_crrt_rows, + list_count_carrier_route, + list_count_radius, + list_count_zipcode, + mail_class_for_size, + pick_unit_price, + ) + + design_id = payload.get("design_id") + size = str(payload.get("size") or "").strip() + try: + design_id_int = int(design_id) if design_id not in (None, "") else None + except (TypeError, ValueError): + design_id_int = None + + requested_class = str(payload.get("mail_class") or "").strip() + mail_class = requested_class or mail_class_for_size(size) + if mail_class not in {"FirstClass", "Standard"}: + mail_class = mail_class_for_size(size) + pricing = design_pricing(design_id_int) + + def _price(qty: int) -> tuple[float | None, str]: + return pick_unit_price(pricing, mail_class=mail_class, quantity=qty) + + action = (action or "mailing_list").strip() + if action == "mailing_list": + count = opted_in_contacts(Channel.POSTCARD).count() + per_piece, source = _price(count) + return { + "action": action, + "mail_class": mail_class, + "count": count, + "per_piece": per_piece, + "total": money_total(per_piece, count), + "price_source": source, + } + + if action == "search": + zip_code = str(payload.get("zip") or "").strip() + address = str(payload.get("address") or "").strip() + city = str(payload.get("city") or "").strip() + state = str(payload.get("state") or "").strip() + try: + miles = float(payload.get("radius") or 0) + except (TypeError, ValueError): + miles = 0 + if address and city and state and zip_code and miles > 0: + data = list_count_radius( + address=address, + city=city, + state=state, + zip_code=zip_code, + miles=miles, + ) + elif zip_code: + data = list_count_zipcode(zip_codes=[zip_code]) + else: + raise ValueError("Enter a ZIP code (or address + ZIP + radius).") + rows = extract_crrt_rows(data) + priced = [] + for row in rows: + per_piece, source = _price(row["total"]) + priced.append( + { + **row, + "per_piece": per_piece, + "cost": money_total(per_piece, row["total"]), + "price_source": source, + } + ) + return { + "action": action, + "mail_class": mail_class, + "zip": zip_code, + "routes": priced, + "discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"), + } + + if action == "select": + codes = [ + str(c).strip() + for c in (payload.get("routes") or []) + if str(c).strip() + ] + codes = list(dict.fromkeys(codes)) + if not codes: + raise ValueError("Select at least one carrier route.") + if len(codes) > MAX_SELECTED_ROUTES: + raise ValueError("Pick at most two carrier routes.") + data = list_count_carrier_route(carrier_routes=codes) + rows = extract_crrt_rows(data) + if not rows: + try: + record_count = int(data.get("recordCount") or data.get("record_count") or 0) + except (TypeError, ValueError): + record_count = 0 + rows = [{"code": code, "text": code, "total": 0} for code in codes] + if record_count and len(rows) == 1: + rows[0]["total"] = record_count + record_count = int(data.get("recordCount") or data.get("record_count") or 0) + if not record_count: + record_count = sum(int(r.get("total") or 0) for r in rows) + per_piece, source = _price(record_count) + priced = [] + for row in rows: + total = int(row.get("total") or 0) + priced.append( + { + **row, + "per_piece": per_piece, + "cost": money_total(per_piece, total), + "price_source": source, + } + ) + list_count_id = data.get("listCountID") or data.get("list_count_id") + return { + "action": action, + "mail_class": mail_class, + "selected_codes": codes, + "routes": priced, + "route_rows": priced, + "list_count_id": list_count_id, + "record_count": record_count, + "per_piece": per_piece, + "total": money_total(per_piece, record_count), + "price_source": source, + "estimated": { + "per_piece": per_piece, + "pieces": record_count, + "total": money_total(per_piece, record_count), + "currency": "USD", + "source": source, + }, + } + + raise ValueError(f"Unknown quote action: {action}") + + def create_campaign_draft( *, name: str, @@ -93,9 +299,11 @@ def create_campaign_draft( scheduled_for=None, created_by: AbstractBaseUser | None = None, template: MessageTemplate | None = None, + route_targeting: dict | None = None, ) -> Campaign: """Persist a draft campaign and per-recipient Message stubs.""" channel = channel_for_audience(audience) + targeting = route_targeting if isinstance(route_targeting, dict) else {} campaign = Campaign.objects.create( name=name, channel=channel, @@ -106,7 +314,18 @@ def create_campaign_draft( body_override=body, created_by=created_by, template=template, + route_targeting=targeting, ) + if audience == Campaign.Audience.CARRIER_ROUTE: + Message.objects.create( + campaign=campaign, + contact=None, + channel=channel, + status=Message.Status.DRAFT, + scheduled_for=scheduled_for, + body_snapshot=body, + ) + return campaign contacts = list(opted_in_contacts(channel)) Message.objects.bulk_create( [ diff --git a/site/directmail/tasks.py b/site/directmail/tasks.py index 82c29ff..0720ce6 100644 --- a/site/directmail/tasks.py +++ b/site/directmail/tasks.py @@ -18,7 +18,9 @@ def send_campaign_message(message_id: str) -> None: campaign = message.campaign - if not contact_may_receive(message.contact, message.channel): + if message.contact_id is None: + pass + elif not contact_may_receive(message.contact, message.channel): message.status = Message.Status.SUPPRESSED message.error = "Contact opted out or suppressed" message.save(update_fields=["status", "error", "updated_at"]) diff --git a/site/directmail/templates/directmail/_postcard_route_picker.html b/site/directmail/templates/directmail/_postcard_route_picker.html new file mode 100644 index 0000000..8c78e6b --- /dev/null +++ b/site/directmail/templates/directmail/_postcard_route_picker.html @@ -0,0 +1,50 @@ + + + diff --git a/site/directmail/templates/directmail/campaign_detail.html b/site/directmail/templates/directmail/campaign_detail.html index 1da1715..759559f 100644 --- a/site/directmail/templates/directmail/campaign_detail.html +++ b/site/directmail/templates/directmail/campaign_detail.html @@ -36,11 +36,13 @@ {% endif %} {% if can_send %}
+ onsubmit="return confirm('{% if campaign.audience == "carrier_route" %}Place this PCM carrier-route postcard order now?{% else %}Send this campaign to all remaining recipients now?{% endif %}');"> {% csrf_token %} - +

- {% if campaign.channel == "postcard" %} + {% if campaign.audience == "carrier_route" %} + Places one PCM list-count order for the selected USPS routes (not per contact). + {% elif campaign.channel == "postcard" %} Enqueues draft / scheduled / failed messages via PCM Integrations. {% else %} Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline). @@ -52,6 +54,35 @@ {% endif %} +{% if campaign.audience == "carrier_route" %} +

+

Carrier routes

+
+ {% with t=campaign.route_targeting %} +

+ {{ t.record_count|default:0 }} household{{ t.record_count|default:0|pluralize }} + · {{ t.mail_class|default:"Standard" }} + {% if t.estimated.total != None %} + · est. ${{ t.estimated.total }} + {% if t.estimated.per_piece %} (${{ t.estimated.per_piece }} / piece){% endif %} + {% endif %} +

+

+ Routes: + {% for code in t.selected_codes %} + {{ code }}{% if not forloop.last %}, {% endif %} + {% empty %} + — + {% endfor %} +

+ {% if t.estimated.source == "fallback" %} +

Cost uses env fallback rates (PCM did not return design pricing).

+ {% endif %} + {% endwith %} +
+
+{% endif %} +

Tracked site link

diff --git a/site/directmail/templates/directmail/campaign_list.html b/site/directmail/templates/directmail/campaign_list.html index 94bfc86..b31d0ca 100644 --- a/site/directmail/templates/directmail/campaign_list.html +++ b/site/directmail/templates/directmail/campaign_list.html @@ -129,6 +129,7 @@
Date & time · leave blank to keep as unscheduled draft
+ {% include "directmail/_postcard_route_picker.html" %}

Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.

{% include "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %} @@ -184,6 +185,7 @@ +