new-site/scripts/workers/services/cpni_certification.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
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>
2026-04-27 06:54:22 -05:00

404 lines
18 KiB
Python

"""CPNI Annual Certification filing handler.
Submits the Annual 47 C.F.R. § 64.2009(e) CPNI Certification to the FCC's
Electronic Comment Filing System (ECFS) under WC Docket No. 06-36. The
filing window opens each year in January and closes March 1.
Flow:
1. Generate the CPNI certification letter via the existing generator.
2. Convert to PDF.
3. Launch undetected browser, navigate to ECFS express comment form,
upload the signed letter, and submit under docket 06-36.
4. Capture confirmation page (ECFS assigns a document number like
``2026XXXXXXXX``) and persist it.
Idempotency: if the carrier already has a CPNI cert on record for the
current calendar year, skip the submission and deliver docs only.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from .base_handler import BaseServiceHandler
from .telecom import filing_state
from .telecom.auto_filing import check_auto_filing, request_admin_review
from .telecom.undetected_browser import undetected_browser, human_delay, type_slowly
logger = logging.getLogger(__name__)
ECFS_URL = os.environ.get(
"FCC_ECFS_URL",
"https://www.fcc.gov/ecfs/search/proceedings?name=06-36",
)
ECFS_UPLOAD_URL = os.environ.get(
"FCC_ECFS_UPLOAD_URL",
"https://www.fcc.gov/ecfs/upload/express",
)
CPNI_DOCKET = "06-36"
class CPNIFilingHandler(BaseServiceHandler):
SERVICE_SLUG = "cpni-certification"
SERVICE_NAME = "CPNI Annual Certification"
REQUIRES_LLM = False
async def process(self, order_data: dict) -> list[str]:
work_dir = self._make_work_dir()
order_number = order_data["name"]
entity = order_data.get("entity", {})
entity_id = entity.get("id")
date_str = datetime.now().strftime("%Y%m%d")
# ── Guard: require entity data before generating documents ───────
legal_name = entity.get("legal_name", "").strip()
if not legal_name:
logger.warning(
"CPNIFilingHandler: no entity data for %s — pausing for intake",
order_number,
)
self._request_entity_intake(order_data)
return []
# CPNI questionnaire answers from intake_data (filled by CPNIStep)
intake = order_data.get("intake_data", {}) or {}
cpni_answers = intake.get("cpni", {})
generated: list[str] = []
# ── 1. Generate packet ───────────────────────────────────────────
cpni_docx = os.path.join(
work_dir, f"cpni_certification_{order_number}_{date_str}.docx"
)
# Pick the Line 105 primary-category-specific variant if available.
variant_fn = self._pick_template_generator(entity)
if variant_fn is not None:
logger.info(
"CPNIFilingHandler: using variant generator %s for entity %s",
getattr(variant_fn, "__name__", "<unknown>"),
entity.get("id"),
)
result = variant_fn(
output_path=cpni_docx,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
filer_id_499=entity.get("filer_id_499", ""),
officer_name=entity.get("ceo_name") or entity.get("contact_name", ""),
officer_title=entity.get("ceo_title", "Chief Executive Officer"),
complaints_count=entity.get("cpni_complaints_count", 0),
has_data_broker_inquiries=bool(
entity.get("has_data_broker_inquiries", False)
),
reporting_year=entity.get("reporting_year", 0),
address_street=entity.get("address_street", ""),
address_city=entity.get("address_city", ""),
address_state=entity.get("address_state", ""),
address_zip=entity.get("address_zip", ""),
contact_email=entity.get("contact_email", ""),
contact_phone=entity.get("contact_phone", ""),
)
else:
from scripts.document_gen.templates.cpni_cert_letter_generator import (
generate_cpni_cert_letter,
)
result = generate_cpni_cert_letter(
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
filer_id_499=entity.get("filer_id_499", ""),
address_street=entity.get("address_street", ""),
address_city=entity.get("address_city", ""),
address_state=entity.get("address_state", ""),
address_zip=entity.get("address_zip", ""),
officer_name=entity.get("ceo_name") or entity.get("contact_name", ""),
officer_title=entity.get("ceo_title", "Chief Executive Officer"),
contact_email=entity.get("contact_email", ""),
contact_phone=entity.get("contact_phone", ""),
complaints_count=int(cpni_answers.get("complaints_count", 0) or 0),
complaints_description=cpni_answers.get("complaints_description", ""),
# Breaches — build the list[dict] format the generator expects
breaches=(
[{
"date": "during the reporting period",
"description": cpni_answers.get("breaches_description", ""),
"records_affected": 0,
"notified_fcc": cpni_answers.get("breaches_notified", "yes") in ("yes", "partial"),
"notified_customers": cpni_answers.get("breaches_notified", "yes") == "yes",
}] if cpni_answers.get("breaches", "no") != "no" else []
),
disciplinary_actions_taken=cpni_answers.get("disciplinary", "no") != "no",
disciplinary_actions_description=cpni_answers.get("disciplinary_description", ""),
data_broker_actions=cpni_answers.get("data_brokers_description", ""),
uses_cpni_for_marketing=cpni_answers.get("marketing_usage", "no") != "no",
cpni_approval_method=cpni_answers.get("marketing_usage", "no") if cpni_answers.get("marketing_usage", "no") != "no" else "opt_in",
reporting_year=int(cpni_answers.get("reporting_year", 0) or 0),
is_wholesale=entity.get("is_wholesale", False),
output_path=cpni_docx,
)
packet_pdf: str | None = None
if result:
generated.append(result)
try:
packet_pdf = self._convert_to_pdf(result)
generated.append(packet_pdf)
except Exception as exc:
logger.warning("CPNI letter PDF conversion failed: %s", exc)
# ── 1b. CPNI Procedure Statement (47 CFR § 64.2008 annual notice) ─
# Separate deliverable from the ECFS cert letter: the internal
# policy document every example filing includes alongside the
# cert. Customers post this on their website and send it to
# customers annually.
try:
from scripts.document_gen.templates.cpni_procedure_statement_generator import (
generate_cpni_procedure_statement,
)
policy_docx = os.path.join(
work_dir, f"cpni_procedure_statement_{order_number}_{date_str}.docx"
)
policy_path = generate_cpni_procedure_statement(
entity_name=entity.get("legal_name", ""),
support_email=entity.get("contact_email", ""),
website=entity.get("website", ""),
signatory_name=entity.get("ceo_name") or entity.get("contact_name", ""),
signatory_title=entity.get("ceo_title", "Chief Executive Officer"),
is_wholesale=entity.get("is_wholesale", False),
output_path=policy_docx,
)
if policy_path:
generated.append(policy_path)
try:
generated.append(self._convert_to_pdf(policy_path))
except Exception as exc:
logger.warning(
"CPNI procedure statement PDF conversion failed: %s",
exc,
)
except Exception as exc:
logger.warning("CPNI procedure statement generation failed: %s", exc)
# ── 2. Idempotency ──────────────────────────────────────────────
if entity_id and filing_state.already_filed(entity_id, "cpni"):
logger.info(
"CPNIFilingHandler: already on file for entity %s this cycle", entity_id
)
return generated
# ── 2a. Auto-filing toggle ──────────────────────────────────────
decision = check_auto_filing(order_data)
if not decision.may_submit:
logger.info(
"CPNIFilingHandler: %s — staging for admin review (order=%s)",
decision.reason, order_number,
)
request_admin_review(
order_number=order_number,
service_slug=self.SERVICE_SLUG,
service_name=self.SERVICE_NAME,
entity_name=entity.get("legal_name", ""),
frn=entity.get("frn", ""),
packet_minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in generated],
admin_email=decision.admin_email,
summary=(
f"CPNI annual certification packet ready for ECFS docket "
f"{CPNI_DOCKET}. 2026 deadline is March 2."
),
)
return generated
# ── 3. Submit to ECFS ───────────────────────────────────────────
confirmation_path, confirmation_number = await self._submit_to_ecfs(
order_number=order_number,
entity=entity,
packet_pdf=packet_pdf or cpni_docx,
work_dir=work_dir,
)
if confirmation_path:
generated.append(confirmation_path)
# ── 4. Persist success + email confirmation to client ──────────
if entity_id and confirmation_number:
filing_state.record_cpni_filing(entity_id, confirmation_number)
if confirmation_number:
try:
from scripts.workers.job_server import _send_filing_confirmation
from scripts.document_gen import MinioStorage
customer_email = order_data.get("customer_email") or entity.get("contact_email")
customer_name = order_data.get("customer_name") or entity.get("contact_name", "")
if customer_email:
conf_paths = [p for p in generated if "confirmation" in p.lower()]
_send_filing_confirmation(
customer_email=customer_email,
customer_name=customer_name,
order_number=order_number,
service_name=self.SERVICE_NAME,
confirmation_number=confirmation_number,
authority="FCC Electronic Comment Filing System (ECFS)",
minio_paths=[f"compliance/{order_number}/{os.path.basename(p)}" for p in conf_paths],
storage=MinioStorage(),
)
except Exception as exc:
logger.warning("CPNI filing confirmation email failed: %s", exc)
return generated
# ------------------------------------------------------------------ #
# Variant selection
# ------------------------------------------------------------------ #
def _pick_template_generator(self, entity: dict):
"""
Resolve the right CPNI generator based on the entity's Line 105
primary category and that category's infra_type.
Returns a callable ``generate_cpni_<variant>(...)`` on success, or
``None`` to indicate the caller should fall back to the generic
``cpni_cert_letter_generator.generate_cpni_cert_letter``.
"""
primary = entity.get("line_105_primary") or entity.get(
"filer_type", "voip_interconnected"
)
cats = entity.get("line_105_categories") or []
entry = next((c for c in cats if c.get("id") == primary), {})
infra = entry.get("infra_type", "facilities")
mapping = {
("clec", "facilities"): "cpni_clec_generator",
("clec", "reseller"): "cpni_clec_reseller_generator",
("ixc", "facilities"): "cpni_ixc_generator",
("ixc", "reseller"): "cpni_ixc_reseller_generator",
("wireless", "facilities"): "cpni_wireless_generator",
("wireless", "mvno"): "cpni_wireless_mvno_generator",
("satellite", "facilities"): "cpni_satellite_generator",
("audio_bridging", "facilities"): "cpni_audio_bridge_generator",
("private_line", "facilities"): "cpni_private_line_generator",
}
module_name = mapping.get((primary, infra))
if not module_name:
return None
try:
import importlib
mod = importlib.import_module(
f"scripts.document_gen.templates.{module_name}"
)
fn_name = "generate_" + module_name.replace("_generator", "")
return getattr(mod, fn_name)
except Exception as exc:
logger.warning(
"CPNIFilingHandler: failed to load CPNI variant %s: %s",
module_name, exc,
)
return None
# ------------------------------------------------------------------ #
# ECFS Express Upload flow
# ------------------------------------------------------------------ #
async def _submit_to_ecfs(
self,
*,
order_number: str,
entity: dict,
packet_pdf: str,
work_dir: str,
) -> tuple[str | None, str]:
confirmation_path = os.path.join(
work_dir, f"cpni_confirmation_{order_number}.pdf"
)
confirmation_number = ""
try:
async with undetected_browser(headless=True) as (ctx, page):
await page.goto(ECFS_UPLOAD_URL, wait_until="domcontentloaded")
await human_delay(1.5, 3.0)
# Express comment form — fields are: Proceedings, Name of
# filer, Filer's address, upload, type of filing.
await type_slowly(page, 'input[name="proceedings"]', CPNI_DOCKET)
# Dropdown suggestion click
await page.wait_for_selector(
f'li:has-text("{CPNI_DOCKET}")', timeout=10000
)
await page.click(f'li:has-text("{CPNI_DOCKET}")')
await human_delay()
await type_slowly(
page, 'input[name="name_of_filer"]', entity.get("legal_name", "")
)
await type_slowly(
page, 'input[name="filer_email"]', entity.get("contact_email", "")
)
# Address
await page.fill(
'input[name="address_line_1"]', entity.get("address_street", "")
)
await page.fill('input[name="city"]', entity.get("address_city", ""))
await page.fill('input[name="state"]', entity.get("address_state", ""))
await page.fill('input[name="zip_code"]', entity.get("address_zip", ""))
# Type of filing — "Certification"
await page.select_option(
'select[name="type_of_filing"]', label="Certification"
)
# Upload the signed PDF.
await page.set_input_files('input[type="file"]', packet_pdf)
await human_delay(1.0, 2.0)
await page.click('button:has-text("Continue")')
await page.wait_for_selector("text=Review", timeout=30000)
await human_delay(2.0, 4.0)
await page.click('button:has-text("Submit")')
await page.wait_for_selector("text=Confirmation", timeout=60000)
body = await page.locator("body").inner_text()
for line in body.splitlines():
if "Filing ID" in line or "Confirmation" in line:
parts = line.split(":", 1)
if len(parts) == 2 and parts[1].strip():
confirmation_number = parts[1].strip()
break
await page.pdf(path=confirmation_path, format="Letter")
logger.info(
"CPNIFilingHandler: submitted under docket %s, filing ID %s",
CPNI_DOCKET,
confirmation_number,
)
return confirmation_path, confirmation_number
except Exception as exc:
logger.exception("CPNIFilingHandler: ECFS submission failed: %s", exc)
self._create_admin_todo(
order_number,
f"ECFS CPNI certification submission failed: {exc}. "
f"Packet is available in MinIO under compliance/{order_number}/. "
"File manually at https://www.fcc.gov/ecfs/ under docket 06-36.",
)
return None, ""
def _create_admin_todo(self, order_number: str, description: str) -> None:
try:
from scripts.workers.erpnext_client import ERPNextClient
ERPNextClient().create_resource(
"ToDo",
{
"description": (
f"[{self.SERVICE_SLUG}] {order_number}\n\n{description}"
),
"priority": "High",
"role": "Accounting Advisor",
},
)
except Exception as exc:
logger.error("Could not create admin ToDo: %s", exc)