diff --git a/.env.example b/.env.example index 90c0745..f45ea0f 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,11 @@ PCM_RETURN_ADDRESS= # PCM_RETURN_CITY= # PCM_RETURN_STATE= # PCM_RETURN_ZIP= +# Fallback per-piece dollars when PCM design payload has no pricing[]. +# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89 +# PCM_POSTCARD_PRICE_STANDARD=0.55 +# Occupant list type for carrier-route counts (GET /list/types). +# PCM_LIST_TYPE=IRL # Social # LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env). diff --git a/.env.prod.example b/.env.prod.example index 688f35b..bcb58c5 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -68,6 +68,9 @@ PCM_WEBHOOK_SECRETS=replace-me,replace-me-2 # 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"}' +# PCM_POSTCARD_PRICE_STANDARD=0.55 +# PCM_POSTCARD_PRICE_FIRSTCLASS=0.89 +# PCM_LIST_TYPE=IRL # LOB_API_KEY= # only if POSTCARD_PROVIDER=lob # CLICK2MAIL_API_KEY= # POSTGRID_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index d029c45..e35b812 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,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:-} @@ -101,6 +104,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/messaging/README.md b/site/messaging/README.md index f21506a..5982ae6 100644 --- a/site/messaging/README.md +++ b/site/messaging/README.md @@ -110,6 +110,22 @@ 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. + +| Extra env | Purpose | +|-----------|---------| +| `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing | +| `PCM_POSTCARD_PRICE_STANDARD` | Fallback $/piece for Standard | +| `PCM_LIST_TYPE` | PCM list type key (default `IRL`) | + ### Campaign completion email When a campaign reaches **completed** (email, SMS, or postcard), one summary email diff --git a/site/messaging/migrations/0007_carrier_route_audience.py b/site/messaging/migrations/0007_carrier_route_audience.py new file mode 100644 index 0000000..957f459 --- /dev/null +++ b/site/messaging/migrations/0007_carrier_route_audience.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("messaging", "0006_storedfile_social_kinds"), + ] + + 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=[ + ("email_opt_in", "Mailing list · email opt-in"), + ("sms_opt_in", "Mailing list · SMS opt-in"), + ("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="messages", + to="contacts.contact", + ), + ), + ] diff --git a/site/messaging/models.py b/site/messaging/models.py index 6f12f94..dc99398 100644 --- a/site/messaging/models.py +++ b/site/messaging/models.py @@ -29,6 +29,7 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel): EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in" SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in" 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) @@ -59,6 +60,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel): body_override = models.TextField(blank=True) # Set when realtor 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"] @@ -84,7 +87,7 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel): Campaign, on_delete=models.CASCADE, related_name="messages" ) contact = models.ForeignKey( - Contact, on_delete=models.CASCADE, related_name="messages" + Contact, null=True, blank=True, on_delete=models.CASCADE, related_name="messages" ) channel = models.CharField(max_length=16, choices=Channel.choices) status = models.CharField( @@ -101,7 +104,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/messaging/providers/postcard/pcm.py b/site/messaging/providers/postcard/pcm.py index 097258b..b91541c 100644 --- a/site/messaging/providers/postcard/pcm.py +++ b/site/messaging/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/messaging/services.py b/site/messaging/services.py index 187729a..1605e4c 100644 --- a/site/messaging/services.py +++ b/site/messaging/services.py @@ -25,6 +25,7 @@ AUDIENCE_CHANNEL = { Campaign.Audience.EMAIL_OPT_IN: Channel.EMAIL, Campaign.Audience.SMS_OPT_IN: Channel.SMS, Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD, + Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD, } UNSUB_SALT = "monica-site-unsubscribe" @@ -381,6 +382,216 @@ 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 messaging.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 messaging.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, + ) + + template_id = str(payload.get("template_id") or "").strip() + design_id = payload.get("design_id") + size = str(payload.get("size") or "").strip() + if template_id and not design_id: + # Views may pass a compose value t: / d:; ignore if unresolved. + pass + 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: + # PCM may return a single total without per-route breakdown. + 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 opted_in_contacts(channel: str) -> QuerySet[Contact]: """Contacts opted in for channel and not actively suppressed.""" suppressed = Suppression.objects.filter( @@ -435,9 +646,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, @@ -448,6 +661,7 @@ def create_campaign_draft( body_override=body, created_by=created_by, template=template, + route_targeting=targeting, ) if channel in (Channel.EMAIL, Channel.SMS): body = ensure_campaign_utm_link( @@ -460,6 +674,16 @@ def create_campaign_draft( if body != campaign.body_override: campaign.body_override = body campaign.save(update_fields=["body_override", "updated_at"]) + 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/messaging/tasks.py b/site/messaging/tasks.py index 61a9af4..cf5f9e7 100644 --- a/site/messaging/tasks.py +++ b/site/messaging/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/messaging/templates/messaging/_postcard_route_picker.html b/site/messaging/templates/messaging/_postcard_route_picker.html new file mode 100644 index 0000000..8c78e6b --- /dev/null +++ b/site/messaging/templates/messaging/_postcard_route_picker.html @@ -0,0 +1,50 @@ + + + diff --git a/site/messaging/templates/messaging/campaign_detail.html b/site/messaging/templates/messaging/campaign_detail.html index 21f5667..5aa4010 100644 --- a/site/messaging/templates/messaging/campaign_detail.html +++ b/site/messaging/templates/messaging/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/messaging/templates/messaging/campaign_list.html b/site/messaging/templates/messaging/campaign_list.html index f781fb8..20db5b1 100644 --- a/site/messaging/templates/messaging/campaign_list.html +++ b/site/messaging/templates/messaging/campaign_list.html @@ -129,6 +129,7 @@
Date & time · leave blank to keep as unscheduled draft
+ {% include "messaging/_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 "messaging/_utm_link_panel.html" with utm_live=True %} @@ -184,6 +185,7 @@ +