Template
Add PCM carrier-route postcard pick-and-send (#11)
## Summary Closes #11. - Mirror Monica: compose can use the postcard mailing list or 1–2 USPS carrier routes, with estimated cost shown. - Place selected routes through PCM list-count orders instead of importing occupant addresses.
This commit is contained in:
1 parent
f3e34f5099
commit
3a1ef797b8
17 files changed
+1192
-27
No files matched your search
@@ -62,6 +62,9 @@ Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail
|
||||
| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) |
|
||||
| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) |
|
||||
| `PCM_RETURN_ADDRESS` | JSON return address on orders |
|
||||
| `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing |
|
||||
| `PCM_POSTCARD_PRICE_STANDARD` | Fallback $/piece for Standard |
|
||||
| `PCM_LIST_TYPE` | PCM purchased-list type (default `IRL`) |
|
||||
| `POSTCARD_PROVIDER` | `pcm` (default) |
|
||||
|
||||
Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived
|
||||
@@ -93,6 +96,16 @@ HMAC-SHA256 of body) matching **any** listed secret.
|
||||
Correlation: we send `extRefNbr=<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.
|
||||
|
||||
### Campaign completion email
|
||||
|
||||
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("directmail", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="campaign",
|
||||
name="route_targeting",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="campaign",
|
||||
name="audience",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("postcard_opt_in", "Mailing list · postcard opt-in"),
|
||||
("carrier_route", "USPS carrier routes"),
|
||||
],
|
||||
default="",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="message",
|
||||
name="contact",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="directmail_messages",
|
||||
to="contacts.contact",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -27,6 +27,7 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
|
||||
class Audience(models.TextChoices):
|
||||
POSTCARD_OPT_IN = "postcard_opt_in", "Mailing list · postcard opt-in"
|
||||
CARRIER_ROUTE = "carrier_route", "USPS carrier routes"
|
||||
|
||||
name = models.CharField(max_length=120)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
@@ -58,6 +59,8 @@ class Campaign(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
body_override = models.TextField(blank=True)
|
||||
# Set when completion summary email is sent (campaign COMPLETED).
|
||||
notify_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
# PCM purchased-list targeting (ZIP/CRRT codes, listCountID, quote).
|
||||
route_targeting = models.JSONField(default=dict, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
@@ -83,7 +86,11 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
Campaign, on_delete=models.CASCADE, related_name="messages"
|
||||
)
|
||||
contact = models.ForeignKey(
|
||||
Contact, on_delete=models.CASCADE, related_name="directmail_messages"
|
||||
Contact,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="directmail_messages",
|
||||
)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices)
|
||||
status = models.CharField(
|
||||
@@ -100,7 +107,8 @@ class Message(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.channel} → {self.contact} ({self.status})"
|
||||
who = self.contact or "carrier-route mailing"
|
||||
return f"{self.channel} → {who} ({self.status})"
|
||||
|
||||
|
||||
class ProviderEvent(TimeStampedModel):
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ if TYPE_CHECKING:
|
||||
|
||||
AUDIENCE_CHANNEL = {
|
||||
Campaign.Audience.POSTCARD_OPT_IN: Channel.POSTCARD,
|
||||
Campaign.Audience.CARRIER_ROUTE: Channel.POSTCARD,
|
||||
}
|
||||
|
||||
# {{first_name}} preferred; {first_name} also accepted (composer hint legacy).
|
||||
@@ -84,6 +85,211 @@ def channel_for_audience(audience: str) -> str:
|
||||
raise ValueError(f"Unknown audience: {audience}") from exc
|
||||
|
||||
|
||||
def parse_route_targeting(raw: str | dict | None) -> dict:
|
||||
"""Parse compose JSON for a carrier-route campaign."""
|
||||
from directmail.providers.postcard.pcm import MAX_SELECTED_ROUTES
|
||||
|
||||
data: dict = {}
|
||||
if isinstance(raw, dict):
|
||||
data = raw
|
||||
elif isinstance(raw, str) and raw.strip():
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Carrier route selection is invalid JSON.") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Carrier route selection must be an object.")
|
||||
data = parsed
|
||||
codes = [
|
||||
str(code).strip()
|
||||
for code in (data.get("selected_codes") or data.get("routes") or [])
|
||||
if str(code).strip()
|
||||
]
|
||||
# Allow routes as list of objects from the quote endpoint.
|
||||
if not codes and isinstance(data.get("routes"), list):
|
||||
codes = [
|
||||
str(row.get("code") or "").strip()
|
||||
for row in data["routes"]
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
codes = [c for c in codes if c]
|
||||
codes = list(dict.fromkeys(codes))
|
||||
if not codes:
|
||||
raise ValueError("Select one or two USPS carrier routes.")
|
||||
if len(codes) > MAX_SELECTED_ROUTES:
|
||||
raise ValueError("Pick at most two carrier routes.")
|
||||
try:
|
||||
list_count_id = int(data.get("list_count_id") or 0)
|
||||
record_count = int(data.get("record_count") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Route quote is missing list count.") from exc
|
||||
if list_count_id <= 0 or record_count <= 0:
|
||||
raise ValueError("Find routes and select them before saving (need a PCM quote).")
|
||||
estimated = data.get("estimated") if isinstance(data.get("estimated"), dict) else {}
|
||||
route_rows = data.get("route_rows") if isinstance(data.get("route_rows"), list) else []
|
||||
return {
|
||||
"selected_codes": codes,
|
||||
"list_count_id": list_count_id,
|
||||
"record_count": record_count,
|
||||
"mail_class": str(data.get("mail_class") or "Standard"),
|
||||
"list_type": str(data.get("list_type") or "IRL"),
|
||||
"zip": str(data.get("zip") or ""),
|
||||
"estimated": estimated,
|
||||
"route_rows": route_rows,
|
||||
}
|
||||
|
||||
|
||||
def money_total(per_piece: float | None, quantity: int) -> float | None:
|
||||
if per_piece is None:
|
||||
return None
|
||||
return round(float(per_piece) * max(int(quantity or 0), 0), 2)
|
||||
|
||||
|
||||
def postcard_quote(*, action: str, payload: dict) -> dict:
|
||||
"""Search routes or price a mailing-list / selected-route postcard."""
|
||||
from directmail.providers.postcard.pcm import (
|
||||
MAX_SELECTED_ROUTES,
|
||||
design_pricing,
|
||||
extract_crrt_rows,
|
||||
list_count_carrier_route,
|
||||
list_count_radius,
|
||||
list_count_zipcode,
|
||||
mail_class_for_size,
|
||||
pick_unit_price,
|
||||
)
|
||||
|
||||
design_id = payload.get("design_id")
|
||||
size = str(payload.get("size") or "").strip()
|
||||
try:
|
||||
design_id_int = int(design_id) if design_id not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
design_id_int = None
|
||||
|
||||
requested_class = str(payload.get("mail_class") or "").strip()
|
||||
mail_class = requested_class or mail_class_for_size(size)
|
||||
if mail_class not in {"FirstClass", "Standard"}:
|
||||
mail_class = mail_class_for_size(size)
|
||||
pricing = design_pricing(design_id_int)
|
||||
|
||||
def _price(qty: int) -> tuple[float | None, str]:
|
||||
return pick_unit_price(pricing, mail_class=mail_class, quantity=qty)
|
||||
|
||||
action = (action or "mailing_list").strip()
|
||||
if action == "mailing_list":
|
||||
count = opted_in_contacts(Channel.POSTCARD).count()
|
||||
per_piece, source = _price(count)
|
||||
return {
|
||||
"action": action,
|
||||
"mail_class": mail_class,
|
||||
"count": count,
|
||||
"per_piece": per_piece,
|
||||
"total": money_total(per_piece, count),
|
||||
"price_source": source,
|
||||
}
|
||||
|
||||
if action == "search":
|
||||
zip_code = str(payload.get("zip") or "").strip()
|
||||
address = str(payload.get("address") or "").strip()
|
||||
city = str(payload.get("city") or "").strip()
|
||||
state = str(payload.get("state") or "").strip()
|
||||
try:
|
||||
miles = float(payload.get("radius") or 0)
|
||||
except (TypeError, ValueError):
|
||||
miles = 0
|
||||
if address and city and state and zip_code and miles > 0:
|
||||
data = list_count_radius(
|
||||
address=address,
|
||||
city=city,
|
||||
state=state,
|
||||
zip_code=zip_code,
|
||||
miles=miles,
|
||||
)
|
||||
elif zip_code:
|
||||
data = list_count_zipcode(zip_codes=[zip_code])
|
||||
else:
|
||||
raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
|
||||
rows = extract_crrt_rows(data)
|
||||
priced = []
|
||||
for row in rows:
|
||||
per_piece, source = _price(row["total"])
|
||||
priced.append(
|
||||
{
|
||||
**row,
|
||||
"per_piece": per_piece,
|
||||
"cost": money_total(per_piece, row["total"]),
|
||||
"price_source": source,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"action": action,
|
||||
"mail_class": mail_class,
|
||||
"zip": zip_code,
|
||||
"routes": priced,
|
||||
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
|
||||
}
|
||||
|
||||
if action == "select":
|
||||
codes = [
|
||||
str(c).strip()
|
||||
for c in (payload.get("routes") or [])
|
||||
if str(c).strip()
|
||||
]
|
||||
codes = list(dict.fromkeys(codes))
|
||||
if not codes:
|
||||
raise ValueError("Select at least one carrier route.")
|
||||
if len(codes) > MAX_SELECTED_ROUTES:
|
||||
raise ValueError("Pick at most two carrier routes.")
|
||||
data = list_count_carrier_route(carrier_routes=codes)
|
||||
rows = extract_crrt_rows(data)
|
||||
if not rows:
|
||||
try:
|
||||
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
record_count = 0
|
||||
rows = [{"code": code, "text": code, "total": 0} for code in codes]
|
||||
if record_count and len(rows) == 1:
|
||||
rows[0]["total"] = record_count
|
||||
record_count = int(data.get("recordCount") or data.get("record_count") or 0)
|
||||
if not record_count:
|
||||
record_count = sum(int(r.get("total") or 0) for r in rows)
|
||||
per_piece, source = _price(record_count)
|
||||
priced = []
|
||||
for row in rows:
|
||||
total = int(row.get("total") or 0)
|
||||
priced.append(
|
||||
{
|
||||
**row,
|
||||
"per_piece": per_piece,
|
||||
"cost": money_total(per_piece, total),
|
||||
"price_source": source,
|
||||
}
|
||||
)
|
||||
list_count_id = data.get("listCountID") or data.get("list_count_id")
|
||||
return {
|
||||
"action": action,
|
||||
"mail_class": mail_class,
|
||||
"selected_codes": codes,
|
||||
"routes": priced,
|
||||
"route_rows": priced,
|
||||
"list_count_id": list_count_id,
|
||||
"record_count": record_count,
|
||||
"per_piece": per_piece,
|
||||
"total": money_total(per_piece, record_count),
|
||||
"price_source": source,
|
||||
"estimated": {
|
||||
"per_piece": per_piece,
|
||||
"pieces": record_count,
|
||||
"total": money_total(per_piece, record_count),
|
||||
"currency": "USD",
|
||||
"source": source,
|
||||
},
|
||||
}
|
||||
|
||||
raise ValueError(f"Unknown quote action: {action}")
|
||||
|
||||
|
||||
def create_campaign_draft(
|
||||
*,
|
||||
name: str,
|
||||
@@ -93,9 +299,11 @@ def create_campaign_draft(
|
||||
scheduled_for=None,
|
||||
created_by: AbstractBaseUser | None = None,
|
||||
template: MessageTemplate | None = None,
|
||||
route_targeting: dict | None = None,
|
||||
) -> Campaign:
|
||||
"""Persist a draft campaign and per-recipient Message stubs."""
|
||||
channel = channel_for_audience(audience)
|
||||
targeting = route_targeting if isinstance(route_targeting, dict) else {}
|
||||
campaign = Campaign.objects.create(
|
||||
name=name,
|
||||
channel=channel,
|
||||
@@ -106,7 +314,18 @@ def create_campaign_draft(
|
||||
body_override=body,
|
||||
created_by=created_by,
|
||||
template=template,
|
||||
route_targeting=targeting,
|
||||
)
|
||||
if audience == Campaign.Audience.CARRIER_ROUTE:
|
||||
Message.objects.create(
|
||||
campaign=campaign,
|
||||
contact=None,
|
||||
channel=channel,
|
||||
status=Message.Status.DRAFT,
|
||||
scheduled_for=scheduled_for,
|
||||
body_snapshot=body,
|
||||
)
|
||||
return campaign
|
||||
contacts = list(opted_in_contacts(channel))
|
||||
Message.objects.bulk_create(
|
||||
[
|
||||
|
||||
@@ -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 'directmail: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 & time · leave blank to keep as unscheduled draft</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "directmail/_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 "core/_utm_link_panel.html" with utm_live=True utm_medium="postcard" %}
|
||||
<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 }}";
|
||||
@@ -219,7 +221,7 @@
|
||||
var subject = (document.getElementById('id_subject') || {}).value || '';
|
||||
var audience = (document.getElementById('id_audience') || {}).value || '';
|
||||
var isEmail = audience === 'email_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;
|
||||
@@ -257,7 +259,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');
|
||||
@@ -290,6 +292,9 @@
|
||||
});
|
||||
var panel = document.getElementById('utm-link-panel');
|
||||
if (panel && panel._utmApply) panel._utmApply();
|
||||
if (window.MonicaPostcardRoutes && window.MonicaPostcardRoutes.syncVisibility) {
|
||||
window.MonicaPostcardRoutes.syncVisibility();
|
||||
}
|
||||
syncCampaignPreview();
|
||||
};
|
||||
|
||||
@@ -353,7 +358,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 : '';
|
||||
}
|
||||
@@ -377,6 +382,10 @@
|
||||
if (tmplSelect) tmplSelect.addEventListener('change', syncCampaignPreview);
|
||||
|
||||
initQuill();
|
||||
window.initPostcardRoutes({
|
||||
quoteUrl: "{{ postcard_quote_url|escapejs }}",
|
||||
csrfToken: csrfToken
|
||||
});
|
||||
window.CampaignUtm.bindPanel(document.getElementById('utm-link-panel'), {
|
||||
getName: function () {
|
||||
return (document.getElementById('id_name') || {}).value || '';
|
||||
|
||||
@@ -305,6 +305,208 @@ class PostcardUtmPanelTests(TestCase):
|
||||
self.assertContains(response, "data-utm-qr")
|
||||
self.assertContains(response, reverse("campaign_short_link"))
|
||||
|
||||
|
||||
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 directmail.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):
|
||||
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(
|
||||
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
|
||||
):
|
||||
with patch(
|
||||
"directmail.providers.postcard.pcm.list_count_zipcode",
|
||||
return_value=payload,
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("directmail: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):
|
||||
import json
|
||||
|
||||
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(
|
||||
"directmail.providers.postcard.pcm.design_pricing", return_value=[]
|
||||
):
|
||||
with patch(
|
||||
"directmail.providers.postcard.pcm.list_count_carrier_route",
|
||||
return_value=select_payload,
|
||||
):
|
||||
quoted = self.client.post(
|
||||
reverse("directmail: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(
|
||||
"directmail.views._fetch_pcm_designs",
|
||||
return_value=(
|
||||
[{"design_id": "42", "name": "Spring card", "size": "68"}],
|
||||
"",
|
||||
),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("directmail: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 directmail.providers.postcard import pcm as pcm_mod
|
||||
from directmail.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("directmail.providers.postcard.pcm.login", return_value="tok"):
|
||||
with patch(
|
||||
"directmail.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):
|
||||
with patch(
|
||||
"directmail.views._fetch_pcm_designs",
|
||||
return_value=(
|
||||
[{"design_id": "42", "name": "Spring card", "size": "46"}],
|
||||
"",
|
||||
),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("directmail: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)
|
||||
|
||||
def test_postcard_does_not_inject_into_body(self):
|
||||
campaign = create_campaign_draft(
|
||||
name="March mailer",
|
||||
|
||||
@@ -6,6 +6,11 @@ app_name = "directmail"
|
||||
|
||||
urlpatterns = [
|
||||
path("campaigns/", views.campaign_list, name="campaign_list"),
|
||||
path(
|
||||
"campaigns/postcard-quote/",
|
||||
views.postcard_quote,
|
||||
name="postcard_quote",
|
||||
),
|
||||
path("campaigns/<uuid:pk>/", views.campaign_detail, name="campaign_detail"),
|
||||
path(
|
||||
"campaigns/<uuid:pk>/status.json",
|
||||
|
||||
+92
-12
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
@@ -25,6 +26,7 @@ from directmail.providers.postcard.pcm import (
|
||||
design_id_from_template,
|
||||
get_design_embed_url,
|
||||
list_designs,
|
||||
mail_class_for_size,
|
||||
)
|
||||
from contacts.consent import opted_in_contacts
|
||||
from core.campaign_utm import utm_panel_context
|
||||
@@ -33,6 +35,8 @@ from directmail.services import (
|
||||
create_campaign_draft,
|
||||
enqueue_campaign_send,
|
||||
message_is_removable,
|
||||
parse_route_targeting,
|
||||
postcard_quote as build_postcard_quote,
|
||||
)
|
||||
from directmail.webhooks import (
|
||||
PROVIDER_EMAIL,
|
||||
@@ -68,6 +72,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
|
||||
|
||||
|
||||
@@ -224,10 +234,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 ""
|
||||
|
||||
@@ -423,6 +437,7 @@ def campaign_list(request):
|
||||
"audience": Campaign.Audience.POSTCARD_OPT_IN,
|
||||
"scheduled_for": "",
|
||||
"template_id": "",
|
||||
"route_targeting": "",
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
@@ -432,6 +447,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(
|
||||
{
|
||||
@@ -441,10 +457,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)
|
||||
|
||||
@@ -459,6 +477,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 = {}
|
||||
|
||||
scheduled_for = None
|
||||
try:
|
||||
@@ -475,14 +505,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("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
campaigns = Campaign.objects.all()[:100]
|
||||
@@ -497,10 +538,46 @@ def campaign_list(request):
|
||||
"form_errors": form_errors,
|
||||
"image_upload_url": reverse("directmail:campaign_image_upload"),
|
||||
**utm_panel_context(live=True, medium=Channel.POSTCARD),
|
||||
"postcard_quote_url": reverse("directmail: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
|
||||
def campaign_detail(request, pk):
|
||||
campaign = get_object_or_404(Campaign, pk=pk)
|
||||
@@ -536,6 +613,7 @@ def campaign_detail(request, pk):
|
||||
"suppressed",
|
||||
}
|
||||
).exists(),
|
||||
"route_targeting": campaign.route_targeting or {},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -566,7 +644,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(),
|
||||
@@ -579,7 +659,7 @@ 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"]
|
||||
@@ -601,7 +681,7 @@ def campaign_message_remove(request, pk, message_id):
|
||||
)
|
||||
return redirect("directmail:campaign_detail", pk=campaign.pk)
|
||||
|
||||
label = str(message.contact)
|
||||
label = str(message.contact) if message.contact_id else "carrier-route mailing"
|
||||
message.delete()
|
||||
messages.success(request, f"Removed {label} from this campaign.")
|
||||
page = (request.POST.get("page") or request.GET.get("page") or "").strip()
|
||||
|
||||
Reference in new issue
Block a user