"""SIP Navigator (Cataleya Orchid One) preset. Orchid One publishes a REST CDR API in newer deployments, but older ones only expose the web CDR-export page. The preset tries the API first (requires ``api_key``); if the admin doesn't have API access, it falls back to the Playwright web-scrape flow. """ from __future__ import annotations import json import urllib.request from datetime import datetime, timedelta from typing import Iterable, Optional from .base import BasePreset, CredentialField, FetchedFile from ._scrape_base import ScrapePreset class SIPNavigatorPreset(ScrapePreset): PRESET_SLUG = "sip_navigator" LABEL = "SIP Navigator (Cataleya Orchid One)" CDR_FORMAT = "generic_csv" TRANSPORT_METHOD = "api" # preferred; scrape fallback if api_key absent DEFAULT_CRON = "0 3 * * *" CREDENTIAL_FIELDS = ( CredentialField("admin_url", "Orchid One admin URL", "text", help="e.g. https://orchid.example.com"), CredentialField("username", "Admin username", "text"), CredentialField("password", "Admin password", "password", sensitive=True), CredentialField("api_key", "Orchid One API key", "password", required=False, sensitive=True, help="Leave blank if the deployment doesn't expose the REST API."), ) FORMAT_CONFIG = { "start_time": "setup_time", "caller_number": "calling_party", "called_number": "called_party", "duration_sec": "answer_duration", "billed_amount": "charge_amount", "call_id": "global_call_id", "trunk_group": "ingress_peering", "disposition": "release_reason", "ts_format": "%Y-%m-%dT%H:%M:%S", } # ── API path (preferred) ──────────────────────────────────────── def validate(self, profile_config: dict, secrets: dict) -> tuple[bool, str]: if secrets.get("api_key"): try: base = profile_config["admin_url"].rstrip("/") req = urllib.request.Request( f"{base}/api/v1/system/status", headers={"Authorization": f"Bearer {secrets['api_key']}"}, ) with urllib.request.urlopen(req, timeout=15) as resp: return True, f"API reachable (HTTP {resp.status})" except Exception as exc: return False, f"Orchid API validate failed: {exc}" # No API key — fall back to ScrapePreset.validate() (HEAD the admin URL) return super().validate(profile_config, secrets) def fetch( self, profile_config: dict, secrets: dict, since: Optional[datetime], ) -> Iterable[FetchedFile]: if secrets.get("api_key"): return self._fetch_api(profile_config, secrets, since) # No API key → scrape (scaffolded, raises until recon finalized) return super().fetch(profile_config, secrets, since) def _fetch_api( self, cfg: dict, secrets: dict, since: Optional[datetime], ) -> Iterable[FetchedFile]: base = cfg["admin_url"].rstrip("/") since = since or (datetime.utcnow() - timedelta(days=1)) end = datetime.utcnow() req = urllib.request.Request( f"{base}/api/v1/cdrs?start={since:%Y-%m-%dT%H:%M:%SZ}" f"&end={end:%Y-%m-%dT%H:%M:%SZ}&format=csv", headers={"Authorization": f"Bearer {secrets['api_key']}", "Accept": "text/csv"}, ) with urllib.request.urlopen(req, timeout=60) as resp: content = resp.read() yield FetchedFile( remote_path=f"sip_navigator_cdrs_{end:%Y%m%dT%H%M%SZ}.csv", mtime=end, content=content, size_bytes=len(content), )