Add PCM carrier-route postcard pick-and-send (#15)
CI / test (pull_request) Successful in 24s

## Summary
Closes #15.
- Let compose target mailing-list contacts or 1–2 USPS carrier routes, with household count and estimated cost shown before save.
- Place selected routes through PCM list-count orders instead of importing occupant addresses into Contacts.
This commit is contained in:
westfarn committed 2026-09-21 15:26:32 -05:00
1 parent a867a57750
commit f7e3c05c59
17 files changed
+1211 -28

No files matched your search

+5
View File
@@ -67,6 +67,11 @@ PCM_RETURN_ADDRESS=
# PCM_RETURN_CITY= # PCM_RETURN_CITY=
# PCM_RETURN_STATE= # PCM_RETURN_STATE=
# PCM_RETURN_ZIP= # 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 # Social
# LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env). # LinkedIn / Instagram App ID + Secret: Portal → Social accounts (not env).
+3
View File
@@ -68,6 +68,9 @@ PCM_WEBHOOK_SECRETS=replace-me,replace-me-2
# JSON return address for postcard orders (quote so compose keeps commas) # 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_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 # LOB_API_KEY= # only if POSTCARD_PROVIDER=lob
# CLICK2MAIL_API_KEY= # CLICK2MAIL_API_KEY=
# POSTGRID_API_KEY= # POSTGRID_API_KEY=
+6
View File
@@ -58,6 +58,9 @@ services:
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} 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_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
@@ -101,6 +104,9 @@ services:
PCM_RETURN_CITY: ${PCM_RETURN_CITY:-} PCM_RETURN_CITY: ${PCM_RETURN_CITY:-}
PCM_RETURN_STATE: ${PCM_RETURN_STATE:-} PCM_RETURN_STATE: ${PCM_RETURN_STATE:-}
PCM_RETURN_ZIP: ${PCM_RETURN_ZIP:-} 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_ID: ${META_APP_ID:-}
META_APP_SECRET: ${META_APP_SECRET:-} META_APP_SECRET: ${META_APP_SECRET:-}
SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-} SOCIAL_TOKEN_ENCRYPTION_KEY: ${SOCIAL_TOKEN_ENCRYPTION_KEY:-}
+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 Correlation: we send `extRefNbr=<Message.uuid>` on each recipient; webhooks should
echo that (or `orderID`, matched to `Message.provider_message_id`). 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 ### Campaign completion email
When a campaign reaches **completed** (email, SMS, or postcard), one summary 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" EMAIL_OPT_IN = "email_opt_in", "Mailing list · email opt-in"
SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in" SMS_OPT_IN = "sms_opt_in", "Mailing list · SMS opt-in"
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard 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) name = models.CharField(max_length=120)
channel = models.CharField(max_length=16, choices=Channel.choices) channel = models.CharField(max_length=16, choices=Channel.choices)
@@ -59,6 +60,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
body_override = models.TextField(blank=True) body_override = models.TextField(blank=True)
# Set when realtor summary email is sent (campaign COMPLETED). # Set when realtor summary email is sent (campaign COMPLETED).
notify_sent_at = models.DateTimeField(null=True, blank=True) 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: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]
@@ -84,7 +87,7 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
Campaign, on_delete=models.CASCADE, related_name="messages" Campaign, on_delete=models.CASCADE, related_name="messages"
) )
contact = models.ForeignKey( 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) channel = models.CharField(max_length=16, choices=Channel.choices)
status = models.CharField( status = models.CharField(
@@ -101,7 +104,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
ordering = ["-created_at"] ordering = ["-created_at"]
def __str__(self) -> str: 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): 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 {} 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( def place_postcard_order(
*, *,
design_id: int, design_id: int,
@@ -358,13 +562,32 @@ def place_postcard_order(
data = pcm_request("POST", "/order/postcard", json_body=payload) data = pcm_request("POST", "/order/postcard", json_body=payload)
if not isinstance(data, dict): if not isinstance(data, dict):
raise PcmApiError("Unexpected order response from PCM") 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"]: def place_postcard_order_with_list_count(
order_id = data["results"][0].get("orderID") *,
if order_id is None: design_id: int,
raise PcmApiError(f"PCM order response missing orderID: {data!r}"[:400]) list_count_id: int,
return str(order_id) 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: def design_id_from_template(template) -> int | None:
@@ -396,6 +619,24 @@ class PcmProvider:
"(save a design from the postcard designer first)" "(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( recipient = contact_to_pcm_recipient(
message.contact, ext_ref=str(message.pk) 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.EMAIL_OPT_IN: Channel.EMAIL,
Campaign.Audience.SMS_OPT_IN: Channel.SMS, Campaign.Audience.SMS_OPT_IN: Channel.SMS,
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD, Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
} }
UNSUB_SALT = "monica-site-unsubscribe" UNSUB_SALT = "monica-site-unsubscribe"
@@ -381,6 +382,216 @@ def channel_for_audience(audience: str) -> str:
raise ValueError(f"Unknown audience: {audience}") from exc 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]: def opted_in_contacts(channel: str) -> QuerySet[Contact]:
"""Contacts opted in for channel and not actively suppressed.""" """Contacts opted in for channel and not actively suppressed."""
suppressed = Suppression.objects.filter( suppressed = Suppression.objects.filter(
@@ -435,9 +646,11 @@ def create_campaign_draft(
scheduled_for=None, scheduled_for=None,
created_by: AbstractBaseUser | None = None, created_by: AbstractBaseUser | None = None,
template: MessageTemplate | None = None, template: MessageTemplate | None = None,
route_targeting: dict | None = None,
) -> Campaign: ) -> Campaign:
"""Persist a draft campaign and per-recipient Message stubs.""" """Persist a draft campaign and per-recipient Message stubs."""
channel = channel_for_audience(audience) channel = channel_for_audience(audience)
targeting = route_targeting if isinstance(route_targeting, dict) else {}
campaign = Campaign.objects.create( campaign = Campaign.objects.create(
name=name, name=name,
channel=channel, channel=channel,
@@ -448,6 +661,7 @@ def create_campaign_draft(
body_override=body, body_override=body,
created_by=created_by, created_by=created_by,
template=template, template=template,
route_targeting=targeting,
) )
if channel in (Channel.EMAIL, Channel.SMS): if channel in (Channel.EMAIL, Channel.SMS):
body = ensure_campaign_utm_link( body = ensure_campaign_utm_link(
@@ -460,6 +674,16 @@ def create_campaign_draft(
if body != campaign.body_override: if body != campaign.body_override:
campaign.body_override = body campaign.body_override = body
campaign.save(update_fields=["body_override", "updated_at"]) 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)) contacts = list(opted_in_contacts(channel))
Message.objects.bulk_create( Message.objects.bulk_create(
[ [
+3 -1
View File
@@ -18,7 +18,9 @@ def send_campaign_message(message_id: str) -> None:
campaign = message.campaign 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.status = Message.Status.SUPPRESSED
message.error = "Contact opted out or suppressed" message.error = "Contact opted out or suppressed"
message.save(update_fields=["status", "error", "updated_at"]) 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 %} {% endif %}
{% if can_send %} {% if can_send %}
<form method="post" action="{% url 'messaging:campaign_send' campaign.pk %}" <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 %} {% 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"> <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. Enqueues draft / scheduled / failed messages via PCM Integrations.
{% else %} {% else %}
Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline). Enqueues draft / scheduled / failed messages via SMTP2GO (dev ImmediateBackend runs inline).
@@ -52,6 +54,35 @@
</div> </div>
{% endif %} {% 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" style="margin-bottom:16px">
<div class="panel-h"><h2>Tracked site link</h2></div> <div class="panel-h"><h2>Tracked site link</h2></div>
<div class="panel-b"> <div class="panel-b">
@@ -129,6 +129,7 @@
<div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div> <div class="hint">Date &amp; time · leave blank to keep as unscheduled draft</div>
</div> </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> <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 %} {% include "messaging/_utm_link_panel.html" with utm_live=True %}
<button class="btn btn-primary" type="submit">Save draft</button> <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/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="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/campaign-utm.js' %}"></script>
<script src="{% static 'js/postcard-routes.js' %}"></script>
<script> <script>
(function () { (function () {
var uploadUrl = "{{ image_upload_url|escapejs }}"; var uploadUrl = "{{ image_upload_url|escapejs }}";
@@ -201,7 +203,7 @@
function audienceChannel() { function audienceChannel() {
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'sms_opt_in') return 'sms'; 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'; return 'email';
} }
@@ -246,7 +248,7 @@
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
var isEmail = audience === 'email_opt_in'; var isEmail = audience === 'email_opt_in';
var isSms = audience === 'sms_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 = ''; var body = '';
if (isEmail && quill) { if (isEmail && quill) {
body = quill.root.innerHTML; body = quill.root.innerHTML;
@@ -287,7 +289,7 @@
window.syncComposeChannel = function () { window.syncComposeChannel = function () {
var audience = (document.getElementById('id_audience') || {}).value || ''; 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 isSms = audience === 'sms_opt_in';
var isEmail = audience === 'email_opt_in'; var isEmail = audience === 'email_opt_in';
var tmplField = document.getElementById('postcard-template-field'); var tmplField = document.getElementById('postcard-template-field');
@@ -320,6 +322,9 @@
}); });
var panel = document.getElementById('utm-link-panel'); var panel = document.getElementById('utm-link-panel');
if (panel && panel._utmApply) panel._utmApply(); if (panel && panel._utmApply) panel._utmApply();
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
window.MonicaPostcardRoutes.syncVisibility();
}
syncCampaignPreview(); syncCampaignPreview();
}; };
@@ -386,7 +391,7 @@
var audience = (document.getElementById('id_audience') || {}).value || ''; var audience = (document.getElementById('id_audience') || {}).value || '';
if (audience === 'email_opt_in') syncBodyFromQuill(); if (audience === 'email_opt_in') syncBodyFromQuill();
else if (audience === 'sms_opt_in' && smsField) bodyField.value = smsField.value; 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'); var pc = document.getElementById('id_body_pc');
bodyField.value = pc ? pc.value : ''; bodyField.value = pc ? pc.value : '';
} }
@@ -410,6 +415,10 @@
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview); if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
initQuill(); initQuill();
window.initPostcardRoutes({
quoteUrl: "{{ postcard_quote_url|escapejs }}",
csrfToken: csrfToken
});
window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), { window.MonicaUtm.bindPanel(document.getElementById('utm-link-panel'), {
getName: function () { getName: function () {
return (document.getElementById('id_name') || {}).value || ''; return (document.getElementById('id_name') || {}).value || '';
+209
View File
@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
import json
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent from messaging.models import Campaign, Message, MessageTemplate, ProviderEvent
@@ -1706,3 +1707,211 @@ class CampaignUtmLinkTests(TestCase):
self.assertTrue(data["shortened"]) self.assertTrue(data["shortened"])
self.assertEqual(data["display_url"], "https://piha.li/a3k9xm") self.assertEqual(data["display_url"], "https://piha.li/a3k9xm")
self.assertIn("utm_medium=sms", data["target_url"]) 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 = [ urlpatterns = [
path("campaigns/", views.campaign_list, name="campaign_list"), path("campaigns/", views.campaign_list, name="campaign_list"),
path(
"campaigns/postcard-quote/",
views.postcard_quote,
name="postcard_quote",
),
path( path(
"campaigns/short-link/", "campaigns/short-link/",
views.campaign_short_link, views.campaign_short_link,
+95 -12
View File
@@ -25,6 +25,7 @@ from messaging.providers.postcard.pcm import (
design_id_from_template, design_id_from_template,
get_design_embed_url, get_design_embed_url,
list_designs, list_designs,
mail_class_for_size,
) )
from messaging.services import ( from messaging.services import (
channel_for_audience, channel_for_audience,
@@ -33,7 +34,9 @@ from messaging.services import (
ensure_campaign_utm_link, ensure_campaign_utm_link,
message_is_removable, message_is_removable,
opted_in_contacts, opted_in_contacts,
parse_route_targeting,
parse_scheduled_for, parse_scheduled_for,
postcard_quote as build_postcard_quote,
public_site_base_url, public_site_base_url,
public_site_link_label, public_site_link_label,
record_sms_stop, record_sms_stop,
@@ -76,6 +79,12 @@ def _audience_choices() -> list[tuple[str, str]]:
choices.append( choices.append(
(value, f"Mailing list · {label} opt-in ({count} {noun})") (value, f"Mailing list · {label} opt-in ({count} {noun})")
) )
choices.append(
(
Campaign.Audience.CARRIER_ROUTE,
"USPS carrier routes (find by ZIP)",
)
)
return choices return choices
@@ -232,10 +241,14 @@ def _message_destination(message) -> str:
contact = message.contact contact = message.contact
channel = message.channel or (message.campaign.channel if message.campaign_id else "") channel = message.channel or (message.campaign.channel if message.campaign_id else "")
if channel == Channel.EMAIL: if channel == Channel.EMAIL:
return (contact.email or "").strip() return (contact.email or "").strip() if contact else ""
if channel == Channel.SMS: if channel == Channel.SMS:
return (contact.phone or "").strip() return (contact.phone or "").strip() if contact else ""
if channel == Channel.POSTCARD: 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 _format_postal_address(contact.postal_address)
return "" return ""
@@ -275,7 +288,7 @@ def _campaign_report(campaign: Campaign, *, page: int = 1) -> dict:
recipient_messages = list(page_obj.object_list) recipient_messages = list(page_obj.object_list)
for msg in recipient_messages: for msg in recipient_messages:
msg.destination = _message_destination(msg) 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) stats = campaign_engagement_stats(campaign)
providers, events_title, events_empty = _events_provider_filter(campaign) providers, events_title, events_empty = _events_provider_filter(campaign)
recent_events = ( recent_events = (
@@ -431,6 +444,7 @@ def campaign_list(request):
"audience": Campaign.Audience.EMAIL_OPT_IN, "audience": Campaign.Audience.EMAIL_OPT_IN,
"scheduled_for": "", "scheduled_for": "",
"template_id": "", "template_id": "",
"route_targeting": "",
} }
if request.method == "POST": if request.method == "POST":
@@ -440,6 +454,7 @@ def campaign_list(request):
audience = (request.POST.get("audience") or "").strip() audience = (request.POST.get("audience") or "").strip()
scheduled_raw = request.POST.get("scheduled_for") or "" scheduled_raw = request.POST.get("scheduled_for") or ""
template_id = (request.POST.get("template_id") or "").strip() template_id = (request.POST.get("template_id") or "").strip()
route_raw = request.POST.get("route_targeting") or ""
form_data.update( form_data.update(
{ {
@@ -449,10 +464,12 @@ def campaign_list(request):
"audience": audience, "audience": audience,
"scheduled_for": scheduled_raw, "scheduled_for": scheduled_raw,
"template_id": template_id, "template_id": template_id,
"route_targeting": route_raw,
} }
) )
template = None template = None
targeting: dict = {}
if template_id: if template_id:
template = _resolve_postcard_template(template_id) template = _resolve_postcard_template(template_id)
@@ -467,6 +484,18 @@ def campaign_list(request):
) )
if not body: if not body:
body = "Postcard mailing" 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: else:
if audience in Campaign.Audience.values: if audience in Campaign.Audience.values:
channel = channel_for_audience(audience) channel = channel_for_audience(audience)
@@ -501,14 +530,25 @@ def campaign_list(request):
scheduled_for=scheduled_for, scheduled_for=scheduled_for,
created_by=request.user, created_by=request.user,
template=template, template=template,
route_targeting=targeting,
) )
recipient_count = campaign.messages.count() if audience == Campaign.Audience.CARRIER_ROUTE:
messages.success( pieces = targeting.get("record_count") or 0
request, total = (targeting.get("estimated") or {}).get("total")
f'Draft “{campaign.name}” saved ' cost_bit = f", est. ${total:.2f}" if isinstance(total, (int, float)) else ""
f"({recipient_count} recipient" messages.success(
f"{'' if recipient_count == 1 else 's'}).", 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) return redirect("messaging:campaign_detail", pk=campaign.pk)
campaigns = Campaign.objects.all()[:100] campaigns = Campaign.objects.all()[:100]
@@ -526,10 +566,46 @@ def campaign_list(request):
"utm_link_label": public_site_link_label(), "utm_link_label": public_site_link_label(),
"utm_shorten_url": reverse("messaging:campaign_short_link"), "utm_shorten_url": reverse("messaging:campaign_short_link"),
"utm_url": "", "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 @login_required
@require_POST @require_POST
def campaign_short_link(request): def campaign_short_link(request):
@@ -622,6 +698,7 @@ def campaign_detail(request, pk):
"suppressed", "suppressed",
} }
).exists(), ).exists(),
"route_targeting": campaign.route_targeting or {},
}, },
) )
@@ -652,7 +729,9 @@ def campaign_status_json(request, pk):
"messages": [ "messages": [
{ {
"id": str(m.pk), "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 "", "destination": getattr(m, "destination", "") or "",
"status": m.status, "status": m.status,
"status_display": m.get_status_display(), "status_display": m.get_status_display(),
@@ -665,7 +744,11 @@ def campaign_status_json(request, pk):
"events": [ "events": [
{ {
"event_type": e.event_type, "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(), "created_at": e.created_at.isoformat(),
} }
for e in ctx["recent_events"] 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_CITY = env("PCM_RETURN_CITY", "")
PCM_RETURN_STATE = env("PCM_RETURN_STATE", "") PCM_RETURN_STATE = env("PCM_RETURN_STATE", "")
PCM_RETURN_ZIP = env("PCM_RETURN_ZIP", "") 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) # Legacy / alternate providers (POSTCARD_PROVIDER=lob|click2mail|postgrid)
LOB_API_KEY = env("LOB_API_KEY", "") LOB_API_KEY = env("LOB_API_KEY", "")
CLICK2MAIL_API_KEY = env("CLICK2MAIL_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;
})();