Add EDDM size, income, businesses and USPS map link (#13)

## Summary
Closes #13.
- Mirror Monica: join USPS EDDM GIS stats onto the carrier-route picker 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:49 -05:00
1 parent f2eb4b7152
commit 5d6b3a9d7d
6 files changed
+254 -3

No files matched your search

+5
View File
@@ -106,6 +106,11 @@ Route mode calls PCM `POST /list/count/zipcode` (or `/radius`) with
or `PCM_POSTCARD_PRICE_FIRSTCLASS` / `PCM_POSTCARD_PRICE_STANDARD`. Default
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.
### Campaign completion email
When a campaign reaches **completed** (email, SMS, or postcard), one summary email
@@ -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
@@ -159,6 +159,10 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
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()
@@ -210,7 +214,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
data = list_count_zipcode(zip_codes=[zip_code])
else:
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 = []
for row in rows:
per_piece, source = _price(row["total"])
@@ -227,6 +231,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
"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"),
}
@@ -251,6 +256,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
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)
@@ -278,6 +284,7 @@ def postcard_quote(*, action: str, payload: dict) -> dict:
"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,
@@ -31,14 +31,23 @@
<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" 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>
<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">
<thead>
<tr>
<th>Route</th>
<th>Area</th>
<th>Households</th>
<th>Size</th>
<th>Income</th>
<th>Businesses</th>
<th>Est. cost</th>
</tr>
</thead>
+52 -1
View File
@@ -304,13 +304,15 @@ class PostcardUtmPanelTests(TestCase):
self.assertContains(response, "qrcode.min.js")
self.assertContains(response, "data-utm-qr")
self.assertContains(response, reverse("campaign_short_link"))
self.assertContains(response, "https://eddm.usps.com/eddm/select-routes.htm")
self.assertContains(response, "Open USPS EDDM map")
class CarrierRoutePostcardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(
username="routes", password="test-pass-123"
username="routes", password="test-pass-123", is_staff=True
)
self.client = Client()
self.client.login(username="routes", password="test-pass-123")
@@ -318,6 +320,12 @@ class CarrierRoutePostcardTests(TestCase):
email="mail@example.com",
postal_address=Contact.make_postal_address(line1="9 Main"),
)
self.eddm_patcher = patch(
"directmail.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):
from directmail.providers.postcard import pcm as pcm_mod
@@ -371,6 +379,49 @@ class CarrierRoutePostcardTests(TestCase):
self.assertEqual(len(data["routes"]), 2)
self.assertEqual(data["routes"][0]["cost"], 60.0)
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(
"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)
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):
import json