new-site/scripts/populate_deficiency_list.py
justin 463c180444 Add shared DOCX style module + campaign tools
_styles.py: Centralized typography, spacing, and formatting for all
26 DOCX generators. Calibri 9.5pt body, 1.15 line spacing, navy
headings, consistent signature blocks, page numbers, PW footer.
All generators will be migrated to use this instead of defining
their own styles.

Campaign tools:
- campaign_template.html: Styled email template for Listmonk campaigns
- populate_deficiency_list.py: Populates Listmonk with FCC deficiency data
- send_test_campaigns.py: Sends test emails with real carrier data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 08:52:07 -05:00

234 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""Populate Listmonk with FCC RMD deficiency data for campaign sends.
Creates a dedicated list and upserts every carrier that has major/critical
audit deficiencies. Sets subscriber attributes with issues_html so the
campaign template can render personalized issue lists.
Skips carriers tagged as is_customer=true.
"""
import json
import subprocess
import sys
import time
import urllib.request
import urllib.error
import base64
AUTH = base64.b64encode(b"api:6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y").decode()
API = "http://localhost:9100/api"
ISSUE_LABELS = {
"ss_partial_note": "STIR/SHAKEN partial implementation \u2014 upstream provider not named",
"ss_vsp_no_shaken": "Voice Service Provider without STIR/SHAKEN implementation",
"conflicting_classification": "Unusual provider classification \u2014 may need correction",
"missing_kyc": "Missing Know Your Customer (KYC) procedures",
"missing_material_change": "Missing 10-business-day material change update commitment",
"missing_dno": "Missing Do-Not-Originate (DNO) list enforcement",
"missing_traceback": "Missing 24-hour traceback response commitment",
"missing_recertification": "Missing annual recertification acknowledgment",
"missing_perjury": "Missing perjury declaration",
"missing_mitigation": "Missing robocall mitigation program details",
"no_classification": "No provider classification selected",
"ss_intermediate_complete": "Intermediate provider claims Complete STIR/SHAKEN",
"xref_name_mismatch": "Business name not found in certification document",
"xref_old_document": "Outdated certification document",
"xref_ss_mismatch": "STIR/SHAKEN status mismatch between RMD and document",
"missing_stir_shaken": "Missing STIR/SHAKEN implementation details",
"missing_enforcement": "Missing enforcement history disclosure",
"missing_provider_id": "Missing provider identification",
"no_recert_date": "No recertification date on file",
}
def api_call(method, path, data=None):
payload = json.dumps(data).encode() if data else None
req = urllib.request.Request(
API + path, data=payload, method=method,
headers={"Content-Type": "application/json", "Authorization": "Basic " + AUTH},
)
return json.loads(urllib.request.urlopen(req).read())
def build_issues_html(structured_json, pdf_json):
"""Merge structured + PDF checks, return HTML list of major/critical issues."""
all_checks = []
for cj in [structured_json, pdf_json]:
try:
checks = json.loads(cj)
if isinstance(checks, list):
all_checks.extend(checks)
except Exception:
pass
severity_order = {"critical": 0, "major": 1, "minor": 2}
all_checks.sort(key=lambda c: severity_order.get(c.get("severity", "minor"), 2))
items = []
ids = []
seen = set()
for c in all_checks:
cid = c.get("id", "")
if cid in seen:
continue
seen.add(cid)
sev = c.get("severity", "minor")
if sev == "minor":
continue
label = ISSUE_LABELS.get(cid, c.get("label", cid))
items.append("<li>" + label + "</li>")
ids.append(cid)
if not items:
for c in all_checks:
cid = c.get("id", "")
label = ISSUE_LABELS.get(cid, c.get("label", cid))
items.append("<li>" + label + "</li>")
ids.append(cid)
html = '<ul style="margin:0;padding:0 0 0 16px">' + "".join(items[:6]) + "</ul>"
return html, ids
def main():
# Query carriers with deficiencies
result = subprocess.run([
"docker", "exec", "performancewest-api-postgres-1", "psql", "-U", "pw",
"performancewest", "-t", "-A", "-F", "||", "-c",
"""SELECT a.frn, a.business_name, a.total_deficiencies, a.severity,
r.contact_email, r.contact_name, r.implementation,
r.last_recertified::text, r.rmd_number,
a.structured_checks::text, a.pdf_checks::text
FROM fcc_rmd_audit_results a
JOIN fcc_rmd r ON r.frn = a.frn
WHERE a.total_deficiencies > 0
AND a.severity IN ('major', 'critical')
AND r.contact_email IS NOT NULL
AND r.removed_from_rmd = FALSE
ORDER BY a.total_deficiencies DESC""",
], capture_output=True, text=True)
lines = [l for l in result.stdout.strip().split("\n") if l.strip()]
print(f"Found {len(lines)} carriers with major/critical deficiencies")
# Find or create list
lists_resp = api_call("GET", "/lists")
deficiency_list_id = None
for l in lists_resp.get("data", {}).get("results", []):
if "Deficiency" in l["name"] and "2026" in l["name"]:
deficiency_list_id = l["id"]
break
if not deficiency_list_id:
resp = api_call("POST", "/lists", {
"name": "FCC RMD Deficiency Alert 2026",
"type": "public",
"optin": "single",
"tags": ["fcc", "rmd", "deficiency", "2026"],
})
deficiency_list_id = resp.get("data", {}).get("id")
print(f"Created list: {deficiency_list_id}")
else:
print(f"Using existing list: {deficiency_list_id}")
added = 0
updated = 0
skipped_customer = 0
errors = 0
for line in lines:
parts = line.split("||")
if len(parts) < 11:
continue
frn = parts[0].strip()
company = parts[1].strip()
deficiency_count = int(parts[2].strip() or 0)
severity = parts[3].strip()
email = parts[4].strip().lower()
contact_name = parts[5].strip()
implementation = parts[6].strip()
last_recert = parts[7].strip()
rmd_number = parts[8].strip()
structured_json = parts[9].strip()
pdf_json = parts[10].strip()
if not email or "@" not in email:
continue
issues_html, issue_ids = build_issues_html(structured_json, pdf_json)
attribs = {
"company": company,
"fcc_frn": frn,
"rmd_number": rmd_number,
"severity": severity,
"deficiency_count": deficiency_count,
"issues_html": issues_html,
"issue_ids": ",".join(issue_ids),
"implementation": implementation,
"last_recertified": last_recert,
}
try:
resp = api_call("POST", "/subscribers", {
"email": email,
"name": contact_name or company,
"status": "enabled",
"lists": [deficiency_list_id],
"attribs": attribs,
"preconfirm_subscriptions": True,
})
added += 1
except urllib.error.HTTPError as e:
err = e.read().decode()
if "already exists" in err:
try:
search = api_call("GET", "/subscribers?query=subscribers.email%3D%27" + email.replace("'", "") + "%27&per_page=1")
results = search.get("data", {}).get("results", [])
if results:
sub_id = results[0]["id"]
existing = results[0].get("attribs", {})
if existing.get("is_customer"):
skipped_customer += 1
continue
existing.update(attribs)
api_call("PUT", f"/subscribers/{sub_id}", {
"email": email,
"name": contact_name or results[0].get("name", company),
"attribs": existing,
"status": "enabled",
})
api_call("PUT", "/subscribers/lists", {
"ids": [sub_id],
"action": "add",
"target_list_ids": [deficiency_list_id],
"status": "confirmed",
})
updated += 1
except Exception as ex:
errors += 1
else:
errors += 1
if (added + updated) % 200 == 0 and (added + updated) > 0:
print(f" Progress: {added} added, {updated} updated, {skipped_customer} skipped")
time.sleep(0.05) # rate limit
print(f"\nDone:")
print(f" Added: {added}")
print(f" Updated: {updated}")
print(f" Customers skipped: {skipped_customer}")
print(f" Errors: {errors}")
print(f" List ID: {deficiency_list_id}")
search = api_call("GET", f"/subscribers?list_id={deficiency_list_id}&per_page=1")
total = search.get("data", {}).get("total", 0)
print(f" Total subscribers on list: {total}")
if __name__ == "__main__":
main()