Add EDDM size, income, businesses and USPS map link (#17)
CI / test (pull_request) Successful in 24s

## Summary
Closes #17.
- Join USPS EDDM GIS stats onto the carrier-route picker (household size, median income, business count) and link to the official EDDM map.
- GIS miss stays a dash and does not block Find routes; PCM households/cost unchanged.
This commit is contained in:
westfarn committed 2026-09-22 14:37:33 -05:00
1 parent 045e956011
commit 7691fcd618
6 files changed
+254 -2

No files matched your search

+5
View File
@@ -120,6 +120,11 @@ Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with
or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage. purchased list type is `IRL` (`PCM_LIST_TYPE`). This is not official EDDM postage.
Compose also joins USPS EDDM GIS stats (businesses; household size / median
income when USPS fills them) and links to
[the EDDM map](https://eddm.usps.com/eddm/select-routes.htm) for the official
route table.
| Extra env | Purpose | | Extra env | Purpose |
|-----------|---------| |-----------|---------|
| `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing | | `PCM_POSTCARD_PRICE_FIRSTCLASS` | Fallback $/piece if PCM omits FirstClass pricing |
@@ -0,0 +1,151 @@
"""USPS EDDM map link + public GIS stats for carrier-route compose."""
from __future__ import annotations
import logging
import time
from typing import Any
import requests
logger = logging.getLogger(__name__)
USPS_EDDM_MAP_URL = "https://eddm.usps.com/eddm/select-routes.htm"
USPS_EDDM_GIS_URL = (
"https://gis.usps.com/arcgis/rest/services/EDDM/selectZIP/GPServer/routes/execute"
)
_CACHE_TTL_SECONDS = 1800
_cache: dict[str, tuple[float, dict[str, dict[str, Any]]]] = {}
def zip5(raw: str | None) -> str:
digits = "".join(ch for ch in str(raw or "") if ch.isdigit())
return digits[:5] if len(digits) >= 5 else ""
def pcm_code_from_zip_crid(*, zip_code: str, crid_id: str) -> str:
return f"{zip_code}:{crid_id}"
def zips_from_route_codes(codes: list[str], *, hint: str = "") -> list[str]:
found: list[str] = []
z = zip5(hint)
if z:
found.append(z)
for code in codes:
z = zip5(str(code).split(":", 1)[0])
if z:
found.append(z)
return list(dict.fromkeys(found))
def _as_int(raw: Any) -> int | None:
if raw in (None, ""):
return None
try:
return int(float(raw))
except (TypeError, ValueError):
return None
def _as_float(raw: Any) -> float | None:
if raw in (None, ""):
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _stats_from_attrs(attrs: dict[str, Any]) -> dict[str, Any] | None:
zip_code = zip5(attrs.get("ZIP_CODE"))
crid = str(attrs.get("CRID_ID") or "").strip().upper()
if not zip_code or not crid:
zip_crid = str(attrs.get("ZIP_CRID") or "").strip().upper()
if len(zip_crid) >= 9:
zip_code = zip_crid[:5]
crid = zip_crid[5:]
if not zip_code or not crid:
return None
return {
"code": pcm_code_from_zip_crid(zip_code=zip_code, crid_id=crid),
"businesses": _as_int(attrs.get("BUS_CNT")),
"residential": _as_int(attrs.get("RES_CNT")),
"usps_total": _as_int(attrs.get("TOT_CNT")),
"household_size": _as_float(attrs.get("AVG_HH_SIZ")),
"median_income": _as_int(attrs.get("MED_INCOME")),
"city_state": str(attrs.get("CITY_STATE") or "").strip(),
}
def _fetch_one_zip(zip_code: str) -> dict[str, dict[str, Any]]:
response = requests.get(
USPS_EDDM_GIS_URL,
params={"f": "json", "ZIP": zip_code, "Rte_Box": "R", "UserName": "EDDM"},
timeout=25,
headers={"Accept": "application/json"},
)
response.raise_for_status()
payload = response.json()
results = payload.get("results") if isinstance(payload, dict) else None
if not isinstance(results, list) or not results:
return {}
value = results[0].get("value") if isinstance(results[0], dict) else None
features = value.get("features") if isinstance(value, dict) else None
if not isinstance(features, list):
return {}
out: dict[str, dict[str, Any]] = {}
for feature in features:
if not isinstance(feature, dict):
continue
attrs = feature.get("attributes")
if not isinstance(attrs, dict):
continue
stats = _stats_from_attrs(attrs)
if not stats:
continue
out[stats["code"]] = stats
return out
def fetch_eddm_route_stats(zip_codes: list[str]) -> dict[str, dict[str, Any]]:
"""ZIP → PCM-style ``60540:C001`` stats from USPS EDDM GIS (fail open)."""
out: dict[str, dict[str, Any]] = {}
now = time.monotonic()
for zip_code in zip_codes:
z = zip5(zip_code)
if not z:
continue
cached = _cache.get(z)
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
out.update(cached[1])
continue
try:
part = _fetch_one_zip(z)
except Exception: # noqa: BLE001 — compose still works without GIS
logger.exception("USPS EDDM GIS lookup failed for ZIP %s", z)
part = {}
_cache[z] = (now, part)
out.update(part)
return out
def enrich_crrt_rows(rows: list[dict[str, Any]], *, zip_hint: str = "") -> list[dict[str, Any]]:
"""Attach USPS businesses / size / income onto PCM ZipCRRT rows."""
codes = [str(row.get("code") or "") for row in rows]
stats = fetch_eddm_route_stats(zips_from_route_codes(codes, hint=zip_hint))
enriched = []
for row in rows:
extra = stats.get(str(row.get("code") or ""), {})
merged = {
**row,
"businesses": extra.get("businesses"),
"residential": extra.get("residential"),
"household_size": extra.get("household_size"),
"median_income": extra.get("median_income"),
}
if extra.get("city_state") and not (row.get("text") or "").strip():
merged["text"] = extra["city_state"]
enriched.append(merged)
return enriched
+8 -1
View File
@@ -456,6 +456,10 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
mail_class_for_size, mail_class_for_size,
pick_unit_price, pick_unit_price,
) )
from messaging.providers.postcard.usps_eddm import (
USPS_EDDM_MAP_URL,
enrich_crrt_rows,
)
template_id = str(payload.get("template_id") or "").strip() template_id = str(payload.get("template_id") or "").strip()
design_id = payload.get("design_id") design_id = payload.get("design_id")
@@ -511,7 +515,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
data = list_count_zipcode(zip_codes=[zip_code]) data = list_count_zipcode(zip_codes=[zip_code])
else: else:
raise ValueError("Enter a ZIP code (or address + ZIP + radius).") raise ValueError("Enter a ZIP code (or address + ZIP + radius).")
rows = extract_crrt_rows(data) rows = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code)
priced = [] priced = []
for row in rows: for row in rows:
per_piece, source = _price(row["total"]) per_piece, source = _price(row["total"])
@@ -528,6 +532,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
"mail_class": mail_class, "mail_class": mail_class,
"zip": zip_code, "zip": zip_code,
"routes": priced, "routes": priced,
"eddm_map_url": USPS_EDDM_MAP_URL,
"discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"), "discovery_list_count_id": data.get("listCountID") or data.get("list_count_id"),
} }
@@ -553,6 +558,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
rows = [{"code": code, "text": code, "total": 0} for code in codes] rows = [{"code": code, "text": code, "total": 0} for code in codes]
if record_count and len(rows) == 1: if record_count and len(rows) == 1:
rows[0]["total"] = record_count 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) record_count = int(data.get("recordCount") or data.get("record_count") or 0)
if not record_count: if not record_count:
record_count = sum(int(r.get("total") or 0) for r in rows) record_count = sum(int(r.get("total") or 0) for r in rows)
@@ -580,6 +586,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
"per_piece": per_piece, "per_piece": per_piece,
"total": money_total(per_piece, record_count), "total": money_total(per_piece, record_count),
"price_source": source, "price_source": source,
"eddm_map_url": USPS_EDDM_MAP_URL,
"estimated": { "estimated": {
"per_piece": per_piece, "per_piece": per_piece,
"pieces": record_count, "pieces": record_count,
@@ -31,14 +31,23 @@
<input id="route-state" type="text" maxlength="2" placeholder="IL"> <input id="route-state" type="text" maxlength="2" placeholder="IL">
</div> </div>
<button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button> <button class="btn btn-ghost btn-sm" type="button" id="route-find-btn">Find routes</button>
<p class="hint" style="margin-top:8px">
<a id="eddm-map-link" href="https://eddm.usps.com/eddm/select-routes.htm"
target="_blank" rel="noopener noreferrer">Open USPS EDDM map</a>
— official route map. Use Show Table there for size / income filters.
PCM still mails a purchased occupant list, not EDDM postage.
</p>
<p class="hint" id="route-status" style="margin-top:8px"></p> <p class="hint" id="route-status" style="margin-top:8px"></p>
<div id="route-table-wrap" hidden style="margin-top:12px"> <div id="route-table-wrap" hidden style="margin-top:12px;overflow-x:auto">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Route</th> <th>Route</th>
<th>Area</th> <th>Area</th>
<th>Households</th> <th>Households</th>
<th>Size</th>
<th>Income</th>
<th>Businesses</th>
<th>Est. cost</th> <th>Est. cost</th>
</tr> </tr>
</thead> </thead>
+52
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
from unittest.mock import patch
import json import json
from contacts.models import Channel, ConsentRecord, Contact, Suppression from contacts.models import Channel, ConsentRecord, Contact, Suppression
@@ -1559,6 +1560,8 @@ class CampaignUtmLinkTests(TestCase):
self.assertContains(response, "data-utm-qr") self.assertContains(response, "data-utm-qr")
self.assertContains(response, "utm_campaign") self.assertContains(response, "utm_campaign")
self.assertContains(response, reverse("messaging:campaign_short_link")) self.assertContains(response, reverse("messaging:campaign_short_link"))
self.assertContains(response, "https://eddm.usps.com/eddm/select-routes.htm")
self.assertContains(response, "Open USPS EDDM map")
def test_detail_shows_tracked_url(self): def test_detail_shows_tracked_url(self):
with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"): with self.settings(PUBLIC_SITE_URL="https://mkdrealtor.com"):
@@ -1721,6 +1724,12 @@ class CarrierRoutePostcardTests(TestCase):
email="mail@example.com", email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"), postal_address=Contact.make_postal_address(line1="9 Main"),
) )
self.eddm_patcher = patch(
"messaging.providers.postcard.usps_eddm.fetch_eddm_route_stats",
return_value={},
)
self.eddm_stats = self.eddm_patcher.start()
self.addCleanup(self.eddm_patcher.stop)
def test_pick_unit_price_uses_break_qty_and_fallback(self): def test_pick_unit_price_uses_break_qty_and_fallback(self):
from messaging.providers.postcard import pcm as pcm_mod from messaging.providers.postcard import pcm as pcm_mod
@@ -1776,6 +1785,49 @@ class CarrierRoutePostcardTests(TestCase):
self.assertEqual(len(data["routes"]), 2) self.assertEqual(len(data["routes"]), 2)
self.assertEqual(data["routes"][0]["cost"], 60.0) self.assertEqual(data["routes"][0]["cost"], 60.0)
self.assertEqual(data["mail_class"], "Standard") self.assertEqual(data["mail_class"], "Standard")
self.assertEqual(
data["eddm_map_url"], "https://eddm.usps.com/eddm/select-routes.htm"
)
def test_quote_search_includes_eddm_demographics(self):
self.eddm_stats.return_value = {
"60540:C001": {
"businesses": 22,
"household_size": 2.6,
"median_income": 120000,
"residential": 108,
}
}
payload = {
"listCountID": 11,
"recordCount": 120,
"breakdown": [
{
"breakdownType": "ZipCRRT",
"data": [
{"code": "60540:C001", "text": "NAPERVILLE", "total": 120},
],
}
],
}
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)
row = response.json()["routes"][0]
self.assertEqual(row["businesses"], 22)
self.assertEqual(row["household_size"], 2.6)
self.assertEqual(row["median_income"], 120000)
def test_quote_select_and_save_draft(self): def test_quote_select_and_save_draft(self):
from unittest.mock import patch from unittest.mock import patch
@@ -6,6 +6,27 @@
return "$" + v.toFixed(2); return "$" + v.toFixed(2);
} }
function hhSize(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return v.toFixed(1);
}
function income(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return "$" + Math.round(v).toLocaleString("en-US");
}
function count(n) {
if (n === null || n === undefined || n === "") return "—";
var v = Number(n);
if (Number.isNaN(v)) return "—";
return String(v);
}
function initPostcardRoutes(opts) { function initPostcardRoutes(opts) {
var form = document.getElementById("campaign-compose"); var form = document.getElementById("campaign-compose");
if (!form) return; if (!form) return;
@@ -18,6 +39,7 @@
var tableWrap = document.getElementById("route-table-wrap"); var tableWrap = document.getElementById("route-table-wrap");
var tbody = document.getElementById("route-table-body"); var tbody = document.getElementById("route-table-body");
var selectedBox = document.getElementById("route-selected-cost"); var selectedBox = document.getElementById("route-selected-cost");
var eddmLink = document.getElementById("eddm-map-link");
var quoteUrl = opts.quoteUrl; var quoteUrl = opts.quoteUrl;
var csrfToken = opts.csrfToken; var csrfToken = opts.csrfToken;
var maxRoutes = 2; var maxRoutes = 2;
@@ -173,6 +195,9 @@
String(row.code) + "</label></td>" + String(row.code) + "</label></td>" +
"<td>" + String(row.text || "") + "</td>" + "<td>" + String(row.text || "") + "</td>" +
"<td>" + String(row.total) + "</td>" + "<td>" + String(row.total) + "</td>" +
"<td>" + hhSize(row.household_size) + "</td>" +
"<td>" + income(row.median_income) + "</td>" +
"<td>" + count(row.businesses) + "</td>" +
"<td>" + money(row.cost) + "</td></tr>"; "<td>" + money(row.cost) + "</td></tr>";
}).join(""); }).join("");
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) { tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
@@ -207,6 +232,9 @@
radius: radius radius: radius
}).then(function (data) { }).then(function (data) {
renderRoutes(data.routes || []); renderRoutes(data.routes || []);
if (eddmLink && data.eddm_map_url) {
eddmLink.href = data.eddm_map_url;
}
if (!(data.routes || []).length) { if (!(data.routes || []).length) {
setStatus("No carrier routes for that search.", true); setStatus("No carrier routes for that search.", true);
} else { } else {