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>
345 lines
14 KiB
Python
345 lines
14 KiB
Python
"""FCC Broadband Data Collection (BDC) / Form 477 filing handler.
|
|
|
|
BDC submissions are due June 1 (Dec-31 snapshot) and December 1 (Jun-30
|
|
snapshot). Voice-only carriers still use the legacy Form 477 voice
|
|
subscription data template.
|
|
|
|
Flow:
|
|
1. Produce a summary packet from the carrier's availability data (pulled
|
|
from the order's ``intake_data``). We reuse ``BaseServiceHandler._fill_template``
|
|
with a lightweight DOCX template if present; otherwise we emit a
|
|
plain text summary (the packet is informational — the portal
|
|
submission is the real filing).
|
|
2. Launch undetected browser, log into the BDC portal at
|
|
https://bdc.fcc.gov/, navigate to the active filing window, upload
|
|
the availability CSV (from intake_data or a computed skeleton), and
|
|
submit.
|
|
3. Capture confirmation and persist.
|
|
|
|
Idempotency: if the carrier already has a BDC filing within the last 180
|
|
days, skip the portal submission.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
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
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BDC_URL = os.environ.get("FCC_BDC_URL", "https://bdc.fcc.gov/")
|
|
BDC_STORAGE_STATE = os.environ.get(
|
|
"FCC_BDC_STORAGE_STATE", "/app/data/fcc_bdc_session.json"
|
|
)
|
|
|
|
|
|
class BDCFilingHandler(BaseServiceHandler):
|
|
"""Unified BDC filing handler.
|
|
|
|
Drives all three catalog slugs:
|
|
* ``bdc-filing`` — both broadband deployment + voice subscription
|
|
* ``bdc-broadband`` — broadband deployment only
|
|
* ``bdc-voice`` — voice subscription only (formerly Form 477 Voice)
|
|
|
|
Mode is derived from ``intake_data.bdc_mode`` first (explicit override),
|
|
otherwise from the service slug on the order. The portal submission
|
|
only fills the blocks that match the mode; skipped blocks aren't
|
|
overwritten on the BDC portal side.
|
|
"""
|
|
|
|
SERVICE_SLUG = "bdc-filing"
|
|
SERVICE_NAME = "BDC Filing (Broadband + Voice)"
|
|
REQUIRES_LLM = False
|
|
|
|
# Slug → default mode lookup.
|
|
_SLUG_TO_MODE = {
|
|
"bdc-filing": "both",
|
|
"bdc-broadband": "broadband",
|
|
"bdc-voice": "voice",
|
|
}
|
|
|
|
def _resolve_mode(self, order_data: dict) -> str:
|
|
"""broadband | voice | both — explicit intake override wins."""
|
|
explicit = (order_data.get("intake_data") or {}).get("bdc_mode")
|
|
if explicit in ("broadband", "voice", "both"):
|
|
return explicit
|
|
slug = (order_data.get("service_slug")
|
|
or order_data.get("custom_order_type")
|
|
or self.SERVICE_SLUG)
|
|
return self._SLUG_TO_MODE.get(slug, "both")
|
|
|
|
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")
|
|
intake = order_data.get("intake_data") or {}
|
|
mode = self._resolve_mode(order_data)
|
|
logger.info("BDCFilingHandler: order %s mode=%s", order_number, mode)
|
|
|
|
generated: list[str] = []
|
|
|
|
# ── 1. Build availability CSV from intake_data (broadband only) ──
|
|
csv_path = None
|
|
if mode in ("broadband", "both"):
|
|
csv_path = self._build_availability_csv(
|
|
order_number, entity, intake, work_dir
|
|
)
|
|
if csv_path:
|
|
generated.append(csv_path)
|
|
|
|
# Summary text for the customer (always — it's the deliverable).
|
|
summary_path = self._build_summary_text(
|
|
order_number, entity, intake, work_dir, mode=mode,
|
|
)
|
|
if summary_path:
|
|
generated.append(summary_path)
|
|
|
|
# ── 2. Idempotency ──────────────────────────────────────────────
|
|
if entity_id and filing_state.already_filed(entity_id, "bdc"):
|
|
logger.info(
|
|
"BDCFilingHandler: BDC already filed for entity %s within 180 days",
|
|
entity_id,
|
|
)
|
|
return generated
|
|
|
|
# ── 2a. Auto-filing toggle ──────────────────────────────────────
|
|
decision = check_auto_filing(order_data)
|
|
if not decision.may_submit:
|
|
logger.info(
|
|
"BDCFilingHandler: %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"BDC availability CSV ready. Submit via {BDC_URL}.",
|
|
)
|
|
return generated
|
|
|
|
# ── 3. Portal submission ────────────────────────────────────────
|
|
confirmation_path, confirmation_number = await self._submit_to_bdc(
|
|
order_number=order_number,
|
|
entity=entity,
|
|
csv_path=csv_path,
|
|
work_dir=work_dir,
|
|
mode=mode,
|
|
intake=intake,
|
|
)
|
|
if confirmation_path:
|
|
generated.append(confirmation_path)
|
|
|
|
if entity_id and confirmation_number:
|
|
filing_state.record_bdc_filing(entity_id, confirmation_number)
|
|
|
|
return generated
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# CSV / summary
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _build_availability_csv(
|
|
self, order_number: str, entity: dict, intake: dict, work_dir: str
|
|
) -> str | None:
|
|
rows = intake.get("availability_rows") or []
|
|
if not rows:
|
|
logger.warning(
|
|
"BDCFilingHandler: no availability_rows in intake_data for %s — "
|
|
"emitting empty template",
|
|
order_number,
|
|
)
|
|
rows = []
|
|
|
|
csv_path = os.path.join(
|
|
work_dir, f"bdc_availability_{order_number}.csv"
|
|
)
|
|
fieldnames = [
|
|
"frn",
|
|
"provider_id",
|
|
"brand_name",
|
|
"location_id",
|
|
"technology",
|
|
"max_advertised_download_speed",
|
|
"max_advertised_upload_speed",
|
|
"low_latency",
|
|
"business_residential_code",
|
|
]
|
|
with open(csv_path, "w", newline="") as fh:
|
|
writer = csv.DictWriter(fh, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
for row in rows:
|
|
out: dict = {k: row.get(k, "") for k in fieldnames}
|
|
out.setdefault("frn", entity.get("frn", ""))
|
|
out.setdefault("brand_name", entity.get("dba_name") or entity.get("legal_name", ""))
|
|
writer.writerow(out)
|
|
|
|
return csv_path
|
|
|
|
def _build_summary_text(
|
|
self, order_number: str, entity: dict, intake: dict, work_dir: str,
|
|
*, mode: str = "both",
|
|
) -> str | None:
|
|
txt_path = os.path.join(work_dir, f"bdc_summary_{order_number}.txt")
|
|
mode_label = {
|
|
"broadband": "Broadband deployment only",
|
|
"voice": "Voice subscription only (formerly Form 477 Voice)",
|
|
"both": "Broadband deployment + Voice subscription",
|
|
}.get(mode, mode)
|
|
lines = [
|
|
f"FCC BDC Filing Summary",
|
|
f"Order: {order_number}",
|
|
f"Carrier: {entity.get('legal_name', '')}",
|
|
f"FRN: {entity.get('frn', '')}",
|
|
f"Scope: {mode_label}",
|
|
f"Filing Date: {datetime.now().strftime('%Y-%m-%d')}",
|
|
"",
|
|
]
|
|
if mode in ("broadband", "both"):
|
|
lines.append(f"Availability rows submitted: {len(intake.get('availability_rows') or [])}")
|
|
if mode in ("voice", "both"):
|
|
lines.append(f"Voice subscribers (formerly Form 477): {intake.get('voice_subscribers', 0)}")
|
|
lines.append(f"Filing window snapshot: {intake.get('snapshot_date', 'unspecified')}")
|
|
with open(txt_path, "w") as fh:
|
|
fh.write("\n".join(lines) + "\n")
|
|
return txt_path
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# BDC portal submission
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def _submit_to_bdc(
|
|
self,
|
|
*,
|
|
order_number: str,
|
|
entity: dict,
|
|
csv_path: str | None,
|
|
work_dir: str,
|
|
mode: str = "both",
|
|
intake: dict | None = None,
|
|
) -> tuple[str | None, str]:
|
|
intake = intake or {}
|
|
frn = entity.get("frn", "").strip()
|
|
needs_csv = mode in ("broadband", "both")
|
|
if not frn:
|
|
self._create_admin_todo(
|
|
order_number,
|
|
"BDC filing requires an FRN. Missing prerequisite — "
|
|
"order a CORES/FRN registration first or capture the FRN on the entity.",
|
|
)
|
|
return None, ""
|
|
if needs_csv and not csv_path:
|
|
self._create_admin_todo(
|
|
order_number,
|
|
f"BDC {mode} filing requires an availability CSV but none was generated. "
|
|
"Review the carrier's intake_data.availability_rows.",
|
|
)
|
|
return None, ""
|
|
|
|
storage_state = BDC_STORAGE_STATE if os.path.exists(BDC_STORAGE_STATE) else None
|
|
confirmation_path = os.path.join(
|
|
work_dir, f"bdc_confirmation_{order_number}.pdf"
|
|
)
|
|
confirmation_number = ""
|
|
|
|
try:
|
|
async with undetected_browser(
|
|
headless=True, storage_state=storage_state,
|
|
) as (ctx, page):
|
|
await page.goto(BDC_URL, wait_until="domcontentloaded")
|
|
await human_delay(1.5, 3.0)
|
|
|
|
if "login" in page.url.lower():
|
|
self._create_admin_todo(
|
|
order_number,
|
|
f"BDC portal required login for FRN {frn}. Run the FCC Access "
|
|
f"Helper to authorize our account, export session to "
|
|
f"{BDC_STORAGE_STATE}, then re-dispatch {order_number}.",
|
|
)
|
|
return None, ""
|
|
|
|
# Start a new filing for the active window.
|
|
await page.click("text=File Data")
|
|
await human_delay()
|
|
|
|
# Select the active filing window (most recent).
|
|
await page.click("text=Current Filing Window")
|
|
await human_delay()
|
|
|
|
# Broadband deployment block — availability CSV.
|
|
if mode in ("broadband", "both") and csv_path:
|
|
await page.set_input_files(
|
|
'input[type="file"][name="availability_data"]',
|
|
csv_path,
|
|
)
|
|
await human_delay(2.0, 4.0)
|
|
|
|
# Voice subscriber block (formerly Form 477 Voice). Intake
|
|
# provides voice_subscribers as an integer; we only fill when
|
|
# the mode includes voice.
|
|
if mode in ("voice", "both"):
|
|
voice_subs = int(
|
|
intake.get("voice_subscribers")
|
|
or entity.get("voice_subscribers")
|
|
or 0
|
|
)
|
|
if voice_subs:
|
|
await page.fill('input[name="voice_subscribers"]', str(voice_subs))
|
|
|
|
# Certify + submit.
|
|
await page.check('input[name="officer_certification"]')
|
|
await page.click('button:has-text("Submit Filing")')
|
|
await page.wait_for_selector("text=Confirmation", timeout=120000)
|
|
|
|
body = await page.locator("body").inner_text()
|
|
for line in body.splitlines():
|
|
if "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(
|
|
"BDCFilingHandler: filed FRN %s, confirmation %s",
|
|
frn, confirmation_number,
|
|
)
|
|
return confirmation_path, confirmation_number
|
|
|
|
except Exception as exc:
|
|
logger.exception("BDCFilingHandler: portal submission failed: %s", exc)
|
|
self._create_admin_todo(
|
|
order_number,
|
|
f"BDC submission failed for FRN {frn}: {exc}. Packet is in MinIO; "
|
|
"file manually at https://bdc.fcc.gov/.",
|
|
)
|
|
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)
|