Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
6.5 KiB
Python
172 lines
6.5 KiB
Python
"""
|
|
CDR unlock nudge — quarterly cron.
|
|
|
|
Emails customers whose ingestion is healthy but whose current reporting
|
|
year has no `cdr_study_access_grants` row. Upsell to the 499-A or
|
|
standalone `cdr-analysis` service so the classified study unlocks.
|
|
|
|
One nudge per profile per quarter. Tracks last_nudge_at on a simple
|
|
per-quarter key to prevent spam.
|
|
|
|
Usage:
|
|
python -m scripts.workers.cdr_unlock_nudge
|
|
CRON: 0 10 1 */3 * (10am on the 1st day of every third month)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import smtplib
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
|
|
log = logging.getLogger("cdr_unlock_nudge")
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
handlers=[logging.StreamHandler(sys.stdout)],
|
|
)
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
|
SMTP_HOST = os.environ.get("SMTP_HOST", "co.carrierone.com")
|
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
|
SMTP_USER = os.environ.get("SMTP_USER", "")
|
|
SMTP_PASS = os.environ.get("SMTP_PASS", "")
|
|
FROM_EMAIL = os.environ.get("FROM_EMAIL", "notifications@performancewest.net")
|
|
ADMIN_EMAIL = os.environ.get("ADMIN_EMAIL", "ops@performancewest.net")
|
|
SITE_URL = os.environ.get("SITE_URL", "https://performancewest.net")
|
|
|
|
MIN_ROWS_TO_NUDGE = 10_000 # don't pester customers with no real data yet
|
|
|
|
|
|
_EMAIL_HTML = """\
|
|
<!DOCTYPE html><html><body style="font-family:Arial;color:#1f2937;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:620px;margin:20px auto;background:#fff;border-radius:8px;overflow:hidden;">
|
|
<tr><td style="background:#1a2744;padding:22px 30px;">
|
|
<h1 style="margin:0;color:#fff;font-size:22px;">Performance West</h1>
|
|
<p style="margin:3px 0 0;color:#8fa4c8;font-size:13px;">Traffic Study Ready — Current Year Locked</p>
|
|
</td></tr>
|
|
<tr><td style="padding:26px 30px;">
|
|
<p style="margin:0 0 14px;font-size:15px;">Hi {customer_name},</p>
|
|
<p style="margin:0 0 14px;font-size:15px;">
|
|
You've sent us <strong>{row_count:,} calls</strong> for {reporting_year} so far.
|
|
Your classified traffic study is ready behind {reporting_year}'s 499-A filing service — one click and the full report,
|
|
regional breakouts, and pre-filled 499-A workbook unlock in your portal.
|
|
</p>
|
|
<div style="text-align:center;margin:24px 0;">
|
|
<a href="{unlock_url}" style="display:inline-block;padding:12px 26px;background:#059669;color:#fff;border-radius:4px;
|
|
text-decoration:none;font-weight:700;">Unlock {reporting_year} Traffic Study →</a>
|
|
</div>
|
|
<p style="margin:0 0 10px;font-size:13px;color:#475569;">
|
|
Ingestion keeps running regardless — nothing's lost. This email goes out once per quarter until you pay.
|
|
Already filed elsewhere for this year? Reply and we'll stop nudging.
|
|
</p>
|
|
</td></tr>
|
|
</table></body></html>
|
|
"""
|
|
|
|
|
|
def _send(to_email: str, subject: str, body_html: str) -> bool:
|
|
if not SMTP_USER or not SMTP_PASS:
|
|
log.warning("SMTP unconfigured — would email %s: %s", to_email, subject)
|
|
return False
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = FROM_EMAIL
|
|
msg["To"] = to_email
|
|
msg["Bcc"] = ADMIN_EMAIL
|
|
msg.attach(MIMEText(body_html, "html"))
|
|
try:
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
|
if SMTP_PORT != 25:
|
|
server.starttls()
|
|
server.login(SMTP_USER, SMTP_PASS)
|
|
server.sendmail(FROM_EMAIL, [to_email, ADMIN_EMAIL], msg.as_string())
|
|
return True
|
|
except Exception as exc:
|
|
log.error("send to %s failed: %s", to_email, exc)
|
|
return False
|
|
|
|
|
|
def run() -> dict:
|
|
if not DATABASE_URL:
|
|
return {"error": "no DATABASE_URL"}
|
|
year = datetime.utcnow().year
|
|
quarter = (datetime.utcnow().month - 1) // 3 + 1
|
|
period_key = f"{year}Q{quarter}"
|
|
|
|
conn = psycopg2.connect(DATABASE_URL)
|
|
sent = 0
|
|
skipped = 0
|
|
try:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
# Profiles with healthy ingestion + no grant for this year
|
|
cur.execute(
|
|
"""
|
|
SELECT
|
|
p.id AS profile_id,
|
|
p.customer_id,
|
|
p.telecom_entity_id,
|
|
te.legal_name AS entity_name,
|
|
m.rows_ingested,
|
|
co_email.customer_email,
|
|
co_email.customer_name
|
|
FROM cdr_ingestion_profiles p
|
|
JOIN telecom_entities te ON te.id = p.telecom_entity_id
|
|
LEFT JOIN cdr_usage_meters m
|
|
ON m.profile_id=p.id AND m.reporting_year=%s
|
|
LEFT JOIN LATERAL (
|
|
SELECT customer_email, customer_name
|
|
FROM compliance_orders
|
|
WHERE telecom_entity_id = p.telecom_entity_id
|
|
AND payment_status='paid'
|
|
ORDER BY created_at DESC LIMIT 1
|
|
) co_email ON TRUE
|
|
WHERE m.rows_ingested >= %s
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM cdr_study_access_grants g
|
|
WHERE g.profile_id=p.id
|
|
AND g.reporting_year=%s
|
|
)
|
|
""",
|
|
(year, MIN_ROWS_TO_NUDGE, year),
|
|
)
|
|
candidates = list(cur.fetchall())
|
|
|
|
for c in candidates:
|
|
if not c.get("customer_email"):
|
|
skipped += 1
|
|
continue
|
|
unlock_url = (
|
|
f"{SITE_URL.rstrip('/')}/order/fcc-499a"
|
|
f"?entity={c['telecom_entity_id']}&year={year}&source=nudge:{period_key}"
|
|
)
|
|
html = _EMAIL_HTML.format(
|
|
customer_name=c.get("customer_name") or "there",
|
|
reporting_year=year,
|
|
row_count=c["rows_ingested"] or 0,
|
|
unlock_url=unlock_url,
|
|
)
|
|
subject = f"Unlock your {year} traffic study — {c['entity_name']}"
|
|
if _send(c["customer_email"], subject, html):
|
|
sent += 1
|
|
else:
|
|
skipped += 1
|
|
finally:
|
|
conn.close()
|
|
log.info("unlock nudge run: sent=%s skipped=%s period=%s", sent, skipped, period_key)
|
|
return {"sent": sent, "skipped": skipped, "period": period_key}
|
|
|
|
|
|
def main() -> None:
|
|
print(run())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|