Template
Compare commits
1
Commits
master
..
3a1ef797b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1ef797b8 |
No files matched your search
@@ -6,27 +6,6 @@
|
|||||||
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;
|
||||||
@@ -39,7 +18,6 @@
|
|||||||
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;
|
||||||
@@ -195,9 +173,6 @@
|
|||||||
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) {
|
||||||
@@ -232,9 +207,6 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -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
|
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.
|
|
||||||
|
|
||||||
### Campaign completion email
|
### Campaign completion email
|
||||||
|
|
||||||
When a campaign reaches **completed** (email, SMS, or postcard), one summary 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,
|
mail_class_for_size,
|
||||||
pick_unit_price,
|
pick_unit_price,
|
||||||
)
|
)
|
||||||
from directmail.providers.postcard.usps_eddm import (
|
|
||||||
USPS_EDDM_MAP_URL,
|
|
||||||
enrich_crrt_rows,
|
|
||||||
)
|
|
||||||
|
|
||||||
design_id = payload.get("design_id")
|
design_id = payload.get("design_id")
|
||||||
size = str(payload.get("size") or "").strip()
|
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])
|
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 = enrich_crrt_rows(extract_crrt_rows(data), zip_hint=zip_code)
|
rows = extract_crrt_rows(data)
|
||||||
priced = []
|
priced = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
per_piece, source = _price(row["total"])
|
per_piece, source = _price(row["total"])
|
||||||
@@ -231,7 +227,6 @@ 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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +251,6 @@ 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)
|
||||||
@@ -284,7 +278,6 @@ 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,23 +31,14 @@
|
|||||||
<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;overflow-x:auto">
|
<div id="route-table-wrap" hidden style="margin-top:12px">
|
||||||
<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>
|
||||||
|
|||||||
@@ -304,15 +304,13 @@ class PostcardUtmPanelTests(TestCase):
|
|||||||
self.assertContains(response, "qrcode.min.js")
|
self.assertContains(response, "qrcode.min.js")
|
||||||
self.assertContains(response, "data-utm-qr")
|
self.assertContains(response, "data-utm-qr")
|
||||||
self.assertContains(response, reverse("campaign_short_link"))
|
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):
|
class CarrierRoutePostcardTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
self.user = User.objects.create_user(
|
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 = Client()
|
||||||
self.client.login(username="routes", password="test-pass-123")
|
self.client.login(username="routes", password="test-pass-123")
|
||||||
@@ -320,12 +318,6 @@ 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(
|
|
||||||
"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):
|
def test_pick_unit_price_uses_break_qty_and_fallback(self):
|
||||||
from directmail.providers.postcard import pcm as pcm_mod
|
from directmail.providers.postcard import pcm as pcm_mod
|
||||||
@@ -379,49 +371,6 @@ 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(
|
|
||||||
"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):
|
def test_quote_select_and_save_draft(self):
|
||||||
import json
|
import json
|
||||||
|
|||||||
Reference in new issue
Block a user