Add PCM carrier-route postcard pick-and-send (#16)
Deploy Beta / unit-tests (push) Successful in 24s
Deploy Beta / docker (push) Successful in 30s
Deploy Beta / deploy-beta (push) Successful in 7m22s

## Summary
Closes #15.
Mirrors westfarn/web_django_template#11.

- Compose can target the postcard mailing list **or** find and select one or two USPS carrier routes by ZIP (optional radius).
- Estimated cost is shown per route and for the selection (PCM design pricing, else `PCM_POSTCARD_PRICE_*` fallback).
- Selected routes order through PCM `POST /order/postcard/with-list-count` — occupant addresses are not imported into Contacts.
- Not official EDDM postage; PCM FirstClass/Standard purchased occupant lists on carrier routes.

## Test plan
- [ ] Campaign compose: mailing-list audience still quotes list size × per-piece cost.
- [ ] Switch to USPS carrier routes, search ZIP 60540 (mocked or live PCM), pick 1–2 routes, confirm household count + estimated cost.
- [ ] Saving without a selection is rejected; saving with a quote creates one contact-less Message and shows the route panel on the report.
- [ ] Send places `/order/postcard/with-list-count` (not per-recipient `/order/postcard`).
- [ ] `uv run python manage.py test messaging.tests.CarrierRoutePostcardTests messaging.tests.PostcardDesignPickTests`

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
westfarn committed 2026-09-21 13:29:20 -07:00
1 parent a867a57750
commit 045e956011
17 files changed
+1211 -28

No files matched your search

+16
View File
@@ -110,6 +110,22 @@ HMAC-SHA256 of body) matching **any** listed secret.
Correlation: we send `extRefNbr=<Message.uuid>` 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
@@ -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",
),
),
]
+6 -2
View File
@@ -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):
+247 -6
View File
@@ -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)
)
+224
View File
@@ -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:<uuid> / d:<id>; 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(
[
+3 -1
View File
@@ -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"])
@@ -0,0 +1,50 @@
<div class="field" id="postcard-list-cost" hidden></div>
<div id="postcard-route-picker" hidden>
<p class="hint-block">
PCM mails a purchased occupant list on the USPS carrier routes you pick
(First Class / Standard — not official EDDM postage). Cost is per piece
times households.
</p>
<div class="form-grid cols-2">
<div class="field">
<label for="route-zip">ZIP code</label>
<input id="route-zip" type="text" inputmode="numeric" maxlength="10"
placeholder="60540" autocomplete="postal-code">
</div>
<div class="field">
<label for="route-radius">Radius miles <span class="muted">(optional)</span></label>
<input id="route-radius" type="number" min="0" step="0.5" placeholder="1">
</div>
</div>
<div class="form-grid cols-2">
<div class="field">
<label for="route-address">Street <span class="muted">(optional, with radius)</span></label>
<input id="route-address" type="text" placeholder="1245 Butterfield Rd">
</div>
<div class="field">
<label for="route-city">City</label>
<input id="route-city" type="text" placeholder="Wheaton">
</div>
</div>
<div class="field" style="max-width:120px">
<label for="route-state">State</label>
<input id="route-state" type="text" maxlength="2" placeholder="IL">
</div>
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
<p class="hint" id="route-status" style="margin-top:8px"></p>
<div id="route-table-wrap" hidden style="margin-top:12px">
<table class="table">
<thead>
<tr>
<th>Route</th>
<th>Area</th>
<th>Households</th>
<th>Est. cost</th>
</tr>
</thead>
<tbody id="route-table-body"></tbody>
</table>
</div>
<p class="hint-block" id="route-selected-cost" hidden style="margin-top:12px"></p>
</div>
<input type="hidden" name="route_targeting" id="id_route_targeting" value="{{ form_data.route_targeting }}">
@@ -36,11 +36,13 @@
{% endif %}
{% if can_send %}
<form method="post" action="{% url 'messaging:campaign_send' campaign.pk %}"
onsubmit="return confirm('Send this campaign to all remaining recipients now?');">
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 %}
<button class="btn btn-primary" type="submit">Send now to recipients</button>
<button class="btn btn-primary" type="submit">{% if campaign.audience == "carrier_route" %}Place route order{% else %}Send now to recipients{% endif %}</button>
<p class="hint-block" style="margin-top:8px">
{% 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 @@
</div>
{% endif %}
{% if campaign.audience == "carrier_route" %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Carrier routes</h2></div>
<div class="panel-b">
{% with t=campaign.route_targeting %}
<p style="margin-top:0">
{{ 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 %}
</p>
<p class="muted" style="margin:0">
Routes:
{% for code in t.selected_codes %}
<code>{{ code }}</code>{% if not forloop.last %}, {% endif %}
{% empty %}
—
{% endfor %}
</p>
{% if t.estimated.source == "fallback" %}
<p class="hint-block">Cost uses env fallback rates (PCM did not return design pricing).</p>
{% endif %}
{% endwith %}
</div>
</div>
{% endif %}
<div class="panel" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b">
@@ -129,6 +129,7 @@
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div>
</div>
{% include "messaging/_postcard_route_picker.html" %}
<p class="hint-block">Saves a draft campaign and recipient stubs. Send from the campaign report when ready. You’ll get an email when the send finishes.</p>
{% include "messaging/_utm_link_panel.html" with utm_live=True %}
<button class="btn btn-primary" type="submit">Save draft</button>
@@ -184,6 +185,7 @@
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script src="{% static 'js/campaign-utm.js' %}"></script>
<script src="{% static 'js/postcard-routes.js' %}"></script>
<script>
(function () {
var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -201,7 +203,7 @@
function audienceChannel() {
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'sms_opt_in') return 'sms';
if (audience === 'postcard_opt_in') return 'postcard';
if (audience === 'postcard_opt_in' || audience === 'carrier_route') return 'postcard';
return 'email';
}
@@ -246,7 +248,7 @@
var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_opt_in';
var isPostcard = audience === 'postcard_opt_in';
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var body = '';
if (isEmail && quill) {
body = quill.root.innerHTML;
@@ -287,7 +289,7 @@
window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || '';
var isPostcard = audience === 'postcard_opt_in';
var isPostcard = audience === 'postcard_opt_in' || audience === 'carrier_route';
var isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field');
@@ -320,6 +322,9 @@
});
var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply();
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
window.MonicaPostcardRoutes.syncVisibility();
}
syncCampaignPreview();
};
@@ -386,7 +391,7 @@
var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill();
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value;
else if (audience === 'postcard_opt_in') {
else if (audience === 'postcard_opt_in' || audience === 'carrier_route') {
var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : '';
}
@@ -410,6 +415,10 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill();
window.initPostcardRoutes({
quoteUrl: "{{ postcard_quote_url|escapejs }}",
csrfToken: csrfToken
});
window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () {
return (document.getElementById('id_name') || {}).value || '';
+209
View File
@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from django.urls import reverse
import json
from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
@@ -1706,3 +1707,211 @@ class CampaignUtmLinkTests(TestCase):
self.assertTrue(data["shortened"])
self.assertEqual(data["display_url"], "https://piha.li/a3k9xm")
self.assertIn("utm_medium=sms", data["target_url"])
class CarrierRoutePostcardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="routes", password="test-pass-123"
)
self.client = Client()
self.client.login(username="routes", password="test-pass-123")
self.contact = Contact.objects.create(
email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"),
)
def test_pick_unit_price_uses_break_qty_and_fallback(self):
from messaging.providers.postcard import pcm as pcm_mod
price, source = pcm_mod.pick_unit_price(
[
{"mailClass": "Standard", "price": 0.8, "breakQty": 1},
{"mailClass": "Standard", "price": 0.5, "breakQty": 200},
],
mail_class="Standard",
quantity=250,
)
self.assertEqual(price, 0.5)
self.assertEqual(source, "pcm")
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.42"):
price, source = pcm_mod.pick_unit_price(
[], mail_class="Standard", quantity=10
)
self.assertEqual(price, 0.42)
self.assertEqual(source, "fallback")
def test_quote_search_returns_route_costs(self):
from unittest.mock import patch
payload = {
"listCountID": 11,
"recordCount": 300,
"breakdown": [
{
"breakdownType": "ZipCRRT",
"data": [
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
{"code": "60540:C002", "text": "NAPERVILLE", "total": 180},
],
}
],
}
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
with patch(
"messaging.providers.postcard.pcm.design_pricing", return_value=[]
):
with patch(
"messaging.providers.postcard.pcm.list_count_zipcode",
return_value=payload,
):
response = self.client.post(
reverse("messaging:postcard_quote"),
data='{"action":"search","zip":"60540","size":"68"}',
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["routes"]), 2)
self.assertEqual(data["routes"][0]["cost"], 60.0)
self.assertEqual(data["mail_class"], "Standard")
def test_quote_select_and_save_draft(self):
from unittest.mock import patch
select_payload = {
"listCountID": 99,
"recordCount": 300,
"breakdown": [
{
"breakdownType": "ZipCRRT",
"data": [
{"code": "60540:C001", "text": "A", "total": 120},
{"code": "60540:C002", "text": "B", "total": 180},
],
}
],
}
with self.settings(PCM_POSTCARD_PRICE_STANDARD="0.50"):
with patch(
"messaging.providers.postcard.pcm.design_pricing", return_value=[]
):
with patch(
"messaging.providers.postcard.pcm.list_count_carrier_route",
return_value=select_payload,
):
quoted = self.client.post(
reverse("messaging:postcard_quote"),
data=(
'{"action":"select","size":"68",'
'"routes":["60540:C001","60540:C002"]}'
),
content_type="application/json",
)
self.assertEqual(quoted.status_code, 200)
quote = quoted.json()
self.assertEqual(quote["list_count_id"], 99)
self.assertEqual(quote["total"], 150.0)
targeting = {
"selected_codes": quote["selected_codes"],
"list_count_id": quote["list_count_id"],
"record_count": quote["record_count"],
"mail_class": quote["mail_class"],
"estimated": quote["estimated"],
"route_rows": quote["routes"],
}
with patch(
"messaging.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "68"}],
"",
),
):
response = self.client.post(
reverse("messaging:campaign_list"),
{
"name": "Farm drop",
"audience": Campaign.Audience.CARRIER_ROUTE,
"template_id": "d:42",
"route_targeting": json.dumps(targeting),
},
)
self.assertEqual(response.status_code, 302)
campaign = Campaign.objects.get(name="Farm drop")
self.assertEqual(campaign.audience, Campaign.Audience.CARRIER_ROUTE)
self.assertEqual(campaign.route_targeting["list_count_id"], 99)
self.assertEqual(campaign.messages.count(), 1)
self.assertIsNone(campaign.messages.get().contact_id)
def test_send_uses_list_count_endpoint(self):
from unittest.mock import patch
from messaging.providers.postcard import pcm as pcm_mod
from messaging.services import enqueue_campaign_send
template = MessageTemplate.objects.create(
name="Route design",
channel=Channel.POSTCARD,
body="Postcard",
postcard_front={"design_id": 42, "size": "68", "provider": "pcm"},
)
campaign = create_campaign_draft(
name="Routes",
audience=Campaign.Audience.CARRIER_ROUTE,
body="note",
created_by=self.user,
template=template,
route_targeting={
"selected_codes": ["60540:C001"],
"list_count_id": 77,
"record_count": 50,
"mail_class": "Standard",
},
)
pcm_mod.clear_token_cache()
with self.settings(
PCM_API_KEY="key",
PCM_API_SECRET="secret",
PCM_RETURN_ADDRESS=(
'{"firstName":"Mo","lastName":"D","address":"1 Main",'
'"city":"Naperville","state":"IL","zipCode":"60540"}'
),
):
with patch("messaging.providers.postcard.pcm.login", return_value="tok"):
with patch(
"messaging.providers.postcard.pcm.pcm_request",
return_value={"orderID": 555},
) as req:
enqueue_campaign_send(campaign)
path = req.call_args.args[1]
self.assertEqual(path, "/order/postcard/with-list-count")
body = req.call_args.kwargs["json_body"]
self.assertEqual(body["listCountID"], 77)
self.assertEqual(body["recordCount"], 50)
campaign.refresh_from_db()
self.assertEqual(campaign.messages.get().provider_message_id, "555")
def test_mailing_list_postcard_still_creates_contacts(self):
from unittest.mock import patch
with patch(
"messaging.views._fetch_pcm_designs",
return_value=(
[{"design_id": "42", "name": "Spring card", "size": "46"}],
"",
),
):
response = self.client.post(
reverse("messaging:campaign_list"),
{
"name": "List mailer",
"audience": Campaign.Audience.POSTCARD_OPT_IN,
"template_id": "d:42",
},
)
self.assertEqual(response.status_code, 302)
campaign = Campaign.objects.get(name="List mailer")
self.assertEqual(campaign.messages.count(), 1)
self.assertEqual(campaign.messages.get().contact_id, self.contact.pk)
+5
View File
@@ -6,6 +6,11 @@ app_name = "messaging"
urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"),
path(
"campaigns/postcard-quote/",
views.postcard_quote,
name="postcard_quote",
),
path(
"campaigns/short-link/",
views.campaign_short_link,
+95 -12
View File
@@ -25,6 +25,7 @@ from messaging.providers.postcard.pcm import (
design_id_from_template,
get_design_embed_url,
list_designs,
mail_class_for_size,
)
from messaging.services import (
channel_for_audience,
@@ -33,7 +34,9 @@ from messaging.services import (
ensure_campaign_utm_link,
message_is_removable,
opted_in_contacts,
parse_route_targeting,
parse_scheduled_for,
postcard_quote as build_postcard_quote,
public_site_base_url,
public_site_link_label,
record_sms_stop,
@@ -76,6 +79,12 @@ def _audience_choices() -> list[tuple[str, str]]:
choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})")
)
choices.append(
(
Campaign.Audience.CARRIER_ROUTE,
"USPS carrier routes (find by ZIP)",
)
)
return choices
@@ -232,10 +241,14 @@ def _message_destination(message) -> str:
contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL:
return (contact.email or "").strip()
return (contact.email or "").strip() if contact else ""
if channel == Channel.SMS:
return (contact.phone or "").strip()
return (contact.phone or "").strip() if contact else ""
if channel == Channel.POSTCARD:
if not contact:
targeting = getattr(message.campaign, "route_targeting", None) or {}
codes = targeting.get("selected_codes") or []
return ", ".join(str(c) for c in codes) or "Carrier routes"
return _format_postal_address(contact.postal_address)
return ""
@@ -275,7 +288,7 @@ def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
recipient_messages = list(page_obj.object_list)
for msg in recipient_messages:
msg.destination = _message_destination(msg)
msg.can_remove = message_is_removable(msg)
msg.can_remove = message_is_removable(msg) and msg.contact_id is not None
stats = campaign_engagement_stats(campaign)
providers, events_title, events_empty = _events_provider_filter(campaign)
recent_events = (
@@ -431,6 +444,7 @@ def campaign_list(request):
"audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "",
"template_id": "",
"route_targeting": "",
}
if request.method == "POST":
@@ -440,6 +454,7 @@ def campaign_list(request):
audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip()
route_raw = request.POST.get("route_targeting") or ""
form_data.update(
{
@@ -449,10 +464,12 @@ def campaign_list(request):
"audience": audience,
"scheduled_for": scheduled_raw,
"template_id": template_id,
"route_targeting": route_raw,
}
)
template = None
targeting: dict = {}
if template_id:
template = _resolve_postcard_template(template_id)
@@ -467,6 +484,18 @@ def campaign_list(request):
)
if not body:
body = "Postcard mailing"
elif audience == Campaign.Audience.CARRIER_ROUTE:
if not template or template.channel != Channel.POSTCARD:
form_errors.append(
"Choose a postcard design (create one under Postcard design)."
)
if not body:
body = "Carrier route postcard mailing"
try:
targeting = parse_route_targeting(route_raw)
except ValueError as exc:
form_errors.append(str(exc))
targeting = {}
else:
if audience in Campaign.Audience.values:
channel = channel_for_audience(audience)
@@ -501,14 +530,25 @@ def campaign_list(request):
scheduled_for=scheduled_for,
created_by=request.user,
template=template,
route_targeting=targeting,
)
recipient_count = campaign.messages.count()
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({recipient_count} recipient"
f"{'' if recipient_count == 1 else 's'}).",
)
if audience == Campaign.Audience.CARRIER_ROUTE:
pieces = targeting.get("record_count") or 0
total = (targeting.get("estimated") or {}).get("total")
cost_bit = f", est. ${total:.2f}" if isinstance(total, (int, float)) else ""
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({pieces} household{'' if pieces == 1 else 's'}{cost_bit}).",
)
else:
recipient_count = campaign.messages.count()
messages.success(
request,
f'Draft “{campaign.name}” saved '
f"({recipient_count} recipient"
f"{'' if recipient_count == 1 else 's'}).",
)
return redirect("messaging:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100]
@@ -526,10 +566,46 @@ def campaign_list(request):
"utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"),
"utm_url": "",
"postcard_quote_url": reverse("messaging:postcard_quote"),
"postcard_list_count": opted_in_contacts(Channel.POSTCARD).count(),
},
)
@login_required
@require_POST
def postcard_quote(request):
"""Search PCM carrier routes or price a postcard audience."""
try:
payload = json.loads(request.body.decode() or "{}")
except (json.JSONDecodeError, UnicodeDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
template_id = str(payload.get("template_id") or "").strip()
if template_id:
template = _resolve_postcard_template(template_id)
if template:
did = design_id_from_template(template)
if did and not payload.get("design_id"):
payload["design_id"] = did
front = template.postcard_front or {}
if not payload.get("size"):
payload["size"] = front.get("size") or ""
if not payload.get("mail_class") and front.get("mail_class"):
payload["mail_class"] = front.get("mail_class")
elif not payload.get("mail_class"):
payload["mail_class"] = mail_class_for_size(payload.get("size"))
action = str(payload.get("action") or "mailing_list").strip()
try:
result = build_postcard_quote(action=action, payload=payload)
except ValueError as exc:
return JsonResponse({"detail": str(exc)}, status=400)
except PcmApiError as exc:
return JsonResponse({"detail": str(exc)}, status=502)
return JsonResponse(result)
@login_required
@require_POST
def campaign_short_link(request):
@@ -622,6 +698,7 @@ def campaign_detail(request, pk):
"suppressed",
}
).exists(),
"route_targeting": campaign.route_targeting or {},
},
)
@@ -652,7 +729,9 @@ def campaign_status_json(request, pk):
"messages": [
{
"id": str(m.pk),
"contact": str(m.contact),
"contact": (
str(m.contact) if m.contact_id else "Carrier route mailing"
),
"destination": getattr(m, "destination", "") or "",
"status": m.status,
"status_display": m.get_status_display(),
@@ -665,7 +744,11 @@ def campaign_status_json(request, pk):
"events": [
{
"event_type": e.event_type,
"contact": str(e.message.contact) if e.message_id else "—",
"contact": (
str(e.message.contact)
if e.message_id and e.message.contact_id
else "—"
),
"created_at": e.created_at.isoformat(),
}
for e in ctx["recent_events"]
+4
View File
@@ -302,6 +302,10 @@ 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", "")
# Fallback $/piece when PCM design pricing is missing.
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", "")
@@ -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 =
"<strong>Estimated cost:</strong> " + data.count +
" household" + (data.count === 1 ? "" : "s") +
" · per-piece price unavailable. Set PCM_POSTCARD_PRICE_STANDARD / FIRSTCLASS.";
return;
}
listCost.innerHTML =
"<strong>Estimated cost:</strong> " + 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 =
"<strong>Selected:</strong> " + (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 "<tr>" +
"<td><label class=\"check-row\" style=\"margin:0\">" +
"<input type=\"checkbox\" name=\"route_code\" value=\"" +
String(row.code).replace(/"/g, "&quot;") + "\"> " +
String(row.code) + "</label></td>" +
"<td>" + String(row.text || "") + "</td>" +
"<td>" + String(row.total) + "</td>" +
"<td>" + money(row.cost) + "</td></tr>";
}).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;
})();