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>
298 lines
9.9 KiB
Python
298 lines
9.9 KiB
Python
"""
|
|
Flowroute Canadian DID provisioning service.
|
|
|
|
Production client for searching and purchasing BC DIDs via the Flowroute API.
|
|
Called by the CRTC pipeline after domain provisioning.
|
|
|
|
Environment variables:
|
|
FLOWROUTE_ACCESS_KEY 743f4c49
|
|
FLOWROUTE_SECRET_KEY 0b10c6d5...
|
|
|
|
Usage:
|
|
from scripts.workers.services.flowroute import provision_bc_did
|
|
result = provision_bc_did(order_number="CA-2026-XXXXX")
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Optional
|
|
|
|
import requests
|
|
|
|
LOG = logging.getLogger("workers.flowroute")
|
|
|
|
API_BASE = "https://api.flowroute.com"
|
|
|
|
# BC area codes in preference order
|
|
BC_AREA_CODES = ["1604", "1778", "1236", "1250"]
|
|
|
|
_access_key = os.environ.get("FLOWROUTE_ACCESS_KEY", "")
|
|
_secret_key = os.environ.get("FLOWROUTE_SECRET_KEY", "")
|
|
|
|
|
|
def _session() -> requests.Session:
|
|
s = requests.Session()
|
|
s.auth = (_access_key, _secret_key)
|
|
s.headers["Accept"] = "application/json"
|
|
return s
|
|
|
|
|
|
def ping() -> bool:
|
|
"""Verify API credentials."""
|
|
try:
|
|
s = _session()
|
|
r = s.get(f"{API_BASE}/v2/numbers", params={"limit": 1}, timeout=10)
|
|
ok = r.status_code == 200
|
|
LOG.info("Flowroute ping: %s", "OK" if ok else f"FAIL {r.status_code}")
|
|
return ok
|
|
except Exception as e:
|
|
LOG.error("Flowroute ping failed: %s", e)
|
|
return False
|
|
|
|
|
|
def search_available_dids(
|
|
starts_with: str = "",
|
|
limit: int = 5,
|
|
number_type: str = "standard",
|
|
) -> list[dict]:
|
|
"""
|
|
Search for available Canadian DIDs across all BC area codes.
|
|
|
|
Tries 604 → 778 → 236 → 250 and stops at the first code with results.
|
|
|
|
Returns: List of {"did": "16045551234", "rate_center": "...", "monthly_cost": "..."}
|
|
"""
|
|
s = _session()
|
|
prefixes = [starts_with] if starts_with else BC_AREA_CODES
|
|
|
|
for prefix in prefixes:
|
|
try:
|
|
r = s.get(
|
|
f"{API_BASE}/v2/numbers/available",
|
|
params={"starts_with": prefix, "limit": limit, "number_type": number_type},
|
|
timeout=15,
|
|
)
|
|
if r.status_code != 200:
|
|
LOG.warning("Flowroute search %s: HTTP %d", prefix, r.status_code)
|
|
continue
|
|
|
|
results = []
|
|
for item in r.json().get("data", []):
|
|
attrs = item.get("attributes", {})
|
|
results.append({
|
|
"did": item.get("id", ""),
|
|
"rate_center": attrs.get("rate_center", ""),
|
|
"state": attrs.get("state", ""),
|
|
"monthly_cost": attrs.get("monthly_cost", ""),
|
|
})
|
|
|
|
if results:
|
|
LOG.info("Flowroute search '%s': %d results", prefix, len(results))
|
|
return results
|
|
|
|
LOG.info("Flowroute search '%s': 0 results", prefix)
|
|
|
|
except Exception as e:
|
|
LOG.error("Flowroute search error for %s: %s", prefix, e)
|
|
|
|
LOG.warning("No BC DIDs available in any area code")
|
|
return []
|
|
|
|
|
|
def purchase_did(did: str) -> dict:
|
|
"""
|
|
Purchase a DID from Flowroute.
|
|
|
|
Args:
|
|
did: Phone number to purchase (e.g. "16045551234")
|
|
|
|
Returns:
|
|
{"success": bool, "did": str, "monthly_cost": str, "error": str}
|
|
"""
|
|
s = _session()
|
|
try:
|
|
r = s.post(f"{API_BASE}/v2/numbers/{did}", timeout=15)
|
|
if r.status_code in (200, 201):
|
|
LOG.info("Flowroute: purchased DID %s", did)
|
|
return {"success": True, "did": did}
|
|
else:
|
|
msg = r.text[:200]
|
|
LOG.error("Flowroute purchase failed for %s: %d %s", did, r.status_code, msg)
|
|
return {"success": False, "did": did, "error": msg}
|
|
except Exception as e:
|
|
LOG.error("Flowroute purchase error for %s: %s", did, e)
|
|
return {"success": False, "did": did, "error": str(e)}
|
|
|
|
|
|
def create_route(
|
|
value: str,
|
|
route_type: str = "host",
|
|
alias: str = "",
|
|
) -> Optional[str]:
|
|
"""
|
|
Create a SIP route on Flowroute.
|
|
|
|
Args:
|
|
value: SIP URI or IP address (e.g. "sip:trunk@pbx.example.com" or "203.0.113.50")
|
|
route_type: "host" (SIP) — Flowroute only supports host routes
|
|
alias: Friendly name for the route
|
|
|
|
Returns: Route ID string, or None on failure
|
|
"""
|
|
s = _session()
|
|
try:
|
|
r = s.post(f"{API_BASE}/v2/routes", json={
|
|
"data": {
|
|
"type": "route",
|
|
"attributes": {
|
|
"route_type": route_type,
|
|
"value": value,
|
|
"alias": alias,
|
|
},
|
|
},
|
|
}, timeout=15)
|
|
if r.status_code in (200, 201):
|
|
route_id = r.json()["data"]["id"]
|
|
LOG.info("Flowroute: created route %s → %s (id=%s)", alias, value, route_id)
|
|
return route_id
|
|
else:
|
|
LOG.error("Flowroute create route failed: %d %s", r.status_code, r.text[:200])
|
|
return None
|
|
except Exception as e:
|
|
LOG.error("Flowroute create route error: %s", e)
|
|
return None
|
|
|
|
|
|
def set_primary_route(did: str, route_id: str) -> bool:
|
|
"""Assign a route as the primary route for a DID."""
|
|
s = _session()
|
|
# Strip leading + if present
|
|
clean_did = did.lstrip("+")
|
|
try:
|
|
r = s.patch(
|
|
f"{API_BASE}/v2/numbers/{clean_did}/relationships/primary_route",
|
|
json={"data": {"type": "route", "id": route_id}},
|
|
timeout=15,
|
|
)
|
|
if r.status_code == 204:
|
|
LOG.info("Flowroute: set primary route for %s → route %s", did, route_id)
|
|
return True
|
|
else:
|
|
LOG.error("Flowroute set route failed for %s: %d %s", did, r.status_code, r.text[:200])
|
|
return False
|
|
except Exception as e:
|
|
LOG.error("Flowroute set route error for %s: %s", did, e)
|
|
return False
|
|
|
|
|
|
def configure_routing(
|
|
did: str,
|
|
routing_type: str = "later",
|
|
forward_number: str = "",
|
|
sip_uri: str = "",
|
|
sip_ip: str = "",
|
|
order_number: str = "",
|
|
) -> dict:
|
|
"""
|
|
Configure call routing for a purchased DID.
|
|
|
|
Flowroute uses SIP "host" routes for all routing. For call forwarding
|
|
to a PSTN number, the forward number is set as the SIP route value
|
|
(Flowroute handles the SIP→PSTN gateway internally).
|
|
|
|
Args:
|
|
did: The DID to configure (e.g. "+16045551234")
|
|
routing_type: "forward", "sip", or "later"
|
|
forward_number: Phone number to forward to (for routing_type="forward")
|
|
sip_uri: SIP URI (for routing_type="sip")
|
|
sip_ip: IP address (for routing_type="sip", fallback)
|
|
order_number: For logging context
|
|
|
|
Returns:
|
|
{"success": bool, "route_id": str, "error": str}
|
|
"""
|
|
if routing_type == "later":
|
|
LOG.info("[%s] DID %s routing set to 'later' — no routing configured", order_number, did)
|
|
return {"success": True, "route_id": "", "note": "Customer will configure routing later"}
|
|
|
|
if routing_type == "forward" and forward_number:
|
|
# Create a host route with the forward number
|
|
# Flowroute routes PSTN calls when value is a phone number
|
|
clean_fwd = forward_number.lstrip("+").replace("-", "").replace(" ", "")
|
|
route_id = create_route(
|
|
value=clean_fwd,
|
|
alias=f"fwd-{order_number}-{clean_fwd[-4:]}",
|
|
)
|
|
if route_id:
|
|
if set_primary_route(did, route_id):
|
|
LOG.info("[%s] DID %s forwarding to %s", order_number, did, forward_number)
|
|
return {"success": True, "route_id": route_id}
|
|
return {"success": False, "route_id": route_id, "error": "Route created but could not assign to DID"}
|
|
return {"success": False, "route_id": "", "error": "Could not create forward route"}
|
|
|
|
if routing_type == "sip":
|
|
# Use SIP URI if provided, fall back to IP
|
|
value = sip_uri or sip_ip
|
|
if not value:
|
|
return {"success": False, "route_id": "", "error": "No SIP URI or IP provided"}
|
|
|
|
route_id = create_route(
|
|
value=value,
|
|
alias=f"sip-{order_number}",
|
|
)
|
|
if route_id:
|
|
if set_primary_route(did, route_id):
|
|
LOG.info("[%s] DID %s routed to SIP %s", order_number, did, value)
|
|
return {"success": True, "route_id": route_id}
|
|
return {"success": False, "route_id": route_id, "error": "Route created but could not assign to DID"}
|
|
return {"success": False, "route_id": "", "error": "Could not create SIP route"}
|
|
|
|
return {"success": False, "route_id": "", "error": f"Unknown routing type: {routing_type}"}
|
|
|
|
|
|
def release_did(did: str) -> bool:
|
|
"""Release a purchased DID (for test cleanup)."""
|
|
s = _session()
|
|
try:
|
|
r = s.delete(f"{API_BASE}/v2/numbers/{did}", timeout=15)
|
|
return r.status_code in (200, 204)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def provision_bc_did(order_number: str = "") -> dict:
|
|
"""
|
|
Full BC DID provisioning workflow:
|
|
1. Search for available BC DIDs (tries 604 → 778 → 236 → 250)
|
|
2. Purchase the first available one
|
|
3. Return the provisioned DID
|
|
|
|
Args:
|
|
order_number: For logging context
|
|
|
|
Returns:
|
|
{"success": bool, "did": str, "rate_center": str, "monthly_cost": str, "error": str}
|
|
"""
|
|
LOG.info("[%s] Provisioning BC DID...", order_number)
|
|
|
|
# Step 1: Search
|
|
available = search_available_dids(limit=3)
|
|
if not available:
|
|
return {"success": False, "did": "", "error": "No BC DIDs available in any area code"}
|
|
|
|
# Step 2: Purchase the first available
|
|
target = available[0]
|
|
result = purchase_did(target["did"])
|
|
|
|
if result["success"]:
|
|
# Format as +1XXXXXXXXXX
|
|
did = target["did"]
|
|
if not did.startswith("+"):
|
|
did = f"+{did}"
|
|
result["did"] = did
|
|
result["rate_center"] = target.get("rate_center", "")
|
|
result["monthly_cost"] = target.get("monthly_cost", "")
|
|
LOG.info("[%s] Provisioned DID: %s (%s)", order_number, did, target.get("rate_center"))
|
|
|
|
return result
|