Template
## Summary Closes #13. - Mirror Monica: join USPS EDDM GIS stats onto the carrier-route picker (household size, median income, business count). - Add Open USPS EDDM map link next to Find routes. - GIS miss stays a dash and does not block Find routes. PCM households and cost unchanged. Monica: ai_ml_operations/monica_site#17 ## Test plan - [ ] Find routes for a ZIP; table shows Size / Income / Businesses columns - [ ] Missing GIS values render as — - [ ] Open USPS EDDM map opens eddm.usps.com - [ ] Quote and 1–2 route send still work - [ ] `CarrierRoutePostcardTests` pass Reviewed-on: #14
586 lines
19 KiB
Python
586 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,
|
|
)
|
|
from directmail.providers.postcard.usps_eddm import (
|
|
USPS_EDDM_MAP_URL,
|
|
enrich_crrt_rows,
|
|
)
|
|
|
|
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 = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code)
|
|
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,
|
|
"eddm_map_url": USPS_EDDM_MAP_URL,
|
|
"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
|
|
rows = enrich_crrt_rows(rows, zip_hint=codes[0] if codes else "")
|
|
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,
|
|
"eddm_map_url": USPS_EDDM_MAP_URL,
|
|
"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) |