Template
Compare commits
1
Commits
master
..
3a1ef797b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1ef797b8 |
No files matched your search
@@ -6,27 +6,6 @@
|
||||
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) {
|
||||
var form = document.getElementById("campaign-compose");
|
||||
if (!form) return;
|
||||
@@ -39,7 +18,6 @@
|
||||
var tableWrap = document.getElementById("route-table-wrap");
|
||||
var tbody = document.getElementById("route-table-body");
|
||||
var selectedBox = document.getElementById("route-selected-cost");
|
||||
var eddmLink = document.getElementById("eddm-map-link");
|
||||
var quoteUrl = opts.quoteUrl;
|
||||
var csrfToken = opts.csrfToken;
|
||||
var maxRoutes = 2;
|
||||
@@ -195,9 +173,6 @@
|
||||
String(row.code) + "</label></td>" +
|
||||
"<td>" + String(row.text || "") + "</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>";
|
||||
}).join("");
|
||||
tbody.querySelectorAll('input[name="route_code"]').forEach(function (box) {
|
||||
@@ -232,9 +207,6 @@
|
||||
radius: radius
|
||||
}).then(function (data) {
|
||||
renderRoutes(data.routes || []);
|
||||
if (eddmLink && data.eddm_map_url) {
|
||||
eddmLink.href = data.eddm_map_url;
|
||||
}
|
||||
if (!(data.routes || []).length) {
|
||||
setStatus("No carrier routes for that search.", true);
|
||||
} else {
|
||||
|
||||
@@ -106,11 +106,6 @@ 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
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""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
|
||||
@@ -159,10 +159,6 @@ 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()
|
||||
@@ -214,7 +210,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 = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code)
|
||||
rows = extract_crrt_rows(data)
|
||||
priced = []
|
||||
for row in rows:
|
||||
per_piece, source = _price(row["total"])
|
||||
@@ -231,7 +227,6 @@ 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"),
|
||||
}
|
||||
|
||||
@@ -256,7 +251,6 @@ 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)
|
||||
@@ -284,7 +278,6 @@ 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,23 +31,14 @@
|
||||
<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;overflow-x:auto">
|
||||
<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>Size</th>
|
||||
<th>Income</th>
|
||||
<th>Businesses</th>
|
||||
<th>Est. cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -304,15 +304,13 @@ 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", is_staff=True
|
||||
username="routes", password="test-pass-123"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.login(username="routes", password="test-pass-123")
|
||||
@@ -320,12 +318,6 @@ 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
|
||||
@@ -379,49 +371,6 @@ 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
|
||||
|
||||
Reference in new issue
Block a user