Files
web_django_template/site/directmail/services.py
T
westfarn f2eb4b7152 Add PCM carrier-route postcard pick-and-send (#12)
## Summary
Closes #11.
Mirrors ai_ml_operations/monica_site#15.

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

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

Reviewed-on: #12
2026-09-21 13:29:53 -07:00

579 lines
19 KiB
Python

"""Campaign draft/send helpers for direct mail."""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from django.urls import reverse
from django.utils import timezone
from contacts.consent import ( # noqa: F401 — re-export for tests + providers
channel_preferences,
contact_may_receive,
make_unsubscribe_token,
one_click_unsubscribe_url,
opted_in_contacts,
parse_unsubscribe_token,
preferences_url,
process_unsubscribe_token,
record_sms_stop,
set_channel_consent,
set_channel_preferences,
unsubscribe_all,
)
from contacts.models import Channel, Contact
from core.scheduling import parse_scheduled_for
from directmail.models import Campaign, Message, MessageTemplate
if TYPE_CHECKING:
from django.contrib.auth.models import AbstractBaseUser
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).
_MERGE_TAG_RE = re.compile(
r"\{\{\s*(first_name|last_name|email|phone|full_name)\s*\}\}"
r"|\{\s*(first_name|last_name|email|phone|full_name)\s*\}",
re.IGNORECASE,
)
REMOVABLE_MESSAGE_STATUSES = frozenset(
{
Message.Status.DRAFT,
Message.Status.SCHEDULED,
Message.Status.FAILED,
}
)
def render_merge_tags(text: str, contact: Contact | None) -> str:
"""Replace personalization tags with contact field values."""
if not text:
return text or ""
first = (getattr(contact, "first_name", None) or "").strip() if contact else ""
last = (getattr(contact, "last_name", None) or "").strip() if contact else ""
email = (getattr(contact, "email", None) or "").strip() if contact else ""
phone = (getattr(contact, "phone", None) or "").strip() if contact else ""
full = f"{first} {last}".strip()
values = {
"first_name": first,
"last_name": last,
"email": email,
"phone": phone,
"full_name": full,
}
def _replace(match: re.Match[str]) -> str:
key = (match.group(1) or match.group(2) or "").lower()
return values.get(key, "")
return _MERGE_TAG_RE.sub(_replace, text)
def message_is_removable(message: Message) -> bool:
return message.status in REMOVABLE_MESSAGE_STATUSES
def channel_for_audience(audience: str) -> str:
try:
return AUDIENCE_CHANNEL[audience]
except KeyError as 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 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,
audience: str,
subject: str = "",
body: str = "",
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,
audience=audience,
status=Campaign.Status.DRAFT,
scheduled_for=scheduled_for,
subject_override=subject,
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(
[
Message(
campaign=campaign,
contact=contact,
channel=channel,
status=Message.Status.DRAFT,
scheduled_for=scheduled_for,
body_snapshot=body,
)
for contact in contacts
]
)
return campaign
def campaign_notify_recipient(campaign: Campaign) -> str:
"""Email address for the realtor summary (created_by, else CONTACT_EMAIL)."""
from django.conf import settings
user = campaign.created_by
if user is not None:
email = (getattr(user, "email", None) or "").strip()
if email:
return email
return (settings.CONTACT_EMAIL or "").strip()
def send_campaign_completion_notify(campaign: Campaign) -> bool:
"""
One-shot summary email when a campaign finishes sending.
Returns True if mail was sent (or already sent earlier).
"""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.db.models import Count, Q
from django.template.loader import get_template
from django.urls import reverse
from public.email_branding import email_brand_context
campaign.refresh_from_db()
if campaign.notify_sent_at:
return True
if campaign.status != Campaign.Status.COMPLETED:
return False
to_email = campaign_notify_recipient(campaign)
if not to_email:
return False
# Claim the notify slot atomically so concurrent refresh calls only send once.
now = timezone.now()
claimed = Campaign.objects.filter(
pk=campaign.pk,
status=Campaign.Status.COMPLETED,
notify_sent_at__isnull=True,
).update(notify_sent_at=now)
if not claimed:
return True
campaign.notify_sent_at = now
counts = campaign.messages.aggregate(
sent=Count(
"id",
filter=Q(
status__in=[
Message.Status.SENT,
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
]
),
),
delivered=Count(
"id",
filter=Q(
status__in=[
Message.Status.DELIVERED,
Message.Status.OPENED,
Message.Status.CLICKED,
]
),
),
failed=Count(
"id",
filter=Q(
status__in=[
Message.Status.FAILED,
Message.Status.BOUNCED,
]
),
),
suppressed=Count("id", filter=Q(status=Message.Status.SUPPRESSED)),
total=Count("id"),
)
report_path = reverse("directmail:campaign_detail", kwargs={"pk": campaign.pk})
public = (settings.PUBLIC_SITE_URL or "").rstrip("/")
report_url = f"{public}{report_path}" if public else report_path
subject = f"Campaign sent: {campaign.name}"
ctx = email_brand_context(
subject=subject,
campaign_name=campaign.name,
channel_display=campaign.get_channel_display(),
total=counts["total"],
sent=counts["sent"],
delivered=counts["delivered"],
failed=counts["failed"],
suppressed=counts["suppressed"],
report_url=report_url,
)
text_content = get_template("emails/campaign_complete.txt").render(ctx)
html_content = get_template("emails/campaign_complete.html").render(ctx)
email = EmailMultiAlternatives(
subject=subject,
body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
email.attach_alternative(html_content, "text/html")
try:
email.send(fail_silently=False)
except Exception: # noqa: BLE001 — don't block completion on mail errors
import logging
logging.getLogger(__name__).exception(
"Campaign completion notify failed for %s", campaign.pk
)
Campaign.objects.filter(pk=campaign.pk).update(notify_sent_at=None)
campaign.notify_sent_at = None
return False
return True
def refresh_campaign_status(campaign: Campaign) -> Campaign:
"""Set campaign to completed when no messages remain pending."""
campaign.refresh_from_db()
pending = campaign.messages.filter(
status__in=[
Message.Status.DRAFT,
Message.Status.SCHEDULED,
Message.Status.QUEUED,
]
).exists()
if pending:
return campaign
if campaign.status == Campaign.Status.SENDING:
campaign.status = Campaign.Status.COMPLETED
campaign.save(update_fields=["status", "updated_at"])
send_campaign_completion_notify(campaign)
return campaign
def enqueue_campaign_send(campaign: Campaign) -> int:
"""
Queue draft/scheduled/failed messages for send.
Dev uses ImmediateBackend → each enqueue runs inline via SMTP/console.
"""
from directmail.tasks import send_campaign_message
sendable = list(
campaign.messages.filter(
status__in=[
Message.Status.DRAFT,
Message.Status.SCHEDULED,
Message.Status.FAILED,
]
)
)
if not sendable:
return 0
campaign.status = Campaign.Status.SENDING
campaign.save(update_fields=["status", "updated_at"])
enqueued = 0
for message in sendable:
message.status = Message.Status.QUEUED
message.save(update_fields=["status", "updated_at"])
try:
send_campaign_message.enqueue(message_id=str(message.pk))
except Exception: # noqa: BLE001 — task already persisted FAILED
pass
enqueued += 1
refresh_campaign_status(campaign)
return enqueued
def send_campaign_test_email(campaign: Campaign, to_email: str) -> None:
"""Send one preview copy to ``to_email`` without touching recipient rows."""
from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from public.email_branding import (
campaign_body_to_email_html,
campaign_body_to_plain_text,
email_brand_context,
)
if campaign.channel != Channel.EMAIL:
raise ValueError("Test send is only available for email campaigns.")
subject = campaign.subject_override or (
campaign.template.subject if campaign.template_id else f"Message from {settings.SITE_NAME}"
)
body = campaign.body_override or (
campaign.template.body if campaign.template_id else ""
)
if not subject.strip():
raise ValueError("Campaign has no subject.")
if not body.strip():
raise ValueError("Campaign has no body.")
# Preview merge tags using first recipient when available.
sample = (
campaign.messages.select_related("contact")
.order_by("created_at")
.first()
)
sample_contact = sample.contact if sample else None
subject = render_merge_tags(subject, sample_contact)
body = render_merge_tags(body, sample_contact)
notice = (
"This is a test send from the portal. "
"Recipient list was not notified."
)
ctx = email_brand_context(
title=f"[TEST] {subject}",
content=f"{campaign_body_to_plain_text(body)}\n\n{notice}",
content_html=(
f"{campaign_body_to_email_html(body)}"
f'<p style="margin:24px 0 0;color:#6b7280;font-size:13px;">{notice}</p>'
),
)
text_content = get_template("emails/marketing_email.txt").render(ctx)
html_content = get_template("emails/marketing_email.html").render(ctx)
email = EmailMultiAlternatives(
subject=f"[TEST] {subject}",
body=text_content,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[to_email],
)
email.attach_alternative(html_content, "text/html")
email.send(fail_silently=False)