"""Generic CSV adapter — configurable column mapping. For switches that don't match any of the specific presets or for customers whose mediation layer emits a custom CSV, the profile stores a column mapping in ``format_config`` JSONB and this adapter maps it into the normalized CDR row. Example ``format_config`` (set by customer via portal): { "start_time": "call_date", "caller_number": "source", "called_number": "destination", "duration_sec": "billsec", "billed_amount": "charge_usd", "trunk_group": "trunk", "account_id": "accountcode", "direction": "direction", "disposition": "disposition", "customer_type_override": "cust_type", "call_id": "uniqueid", "ts_format": "%Y-%m-%d %H:%M:%S", "encoding": "utf-8", "delimiter": "," } """ from __future__ import annotations import csv import logging from datetime import datetime from typing import Iterator from .base import BaseCDRAdapter, CDRRow, ValidationError logger = logging.getLogger(__name__) class GenericCSVAdapter(BaseCDRAdapter): FORMAT_SLUG = "generic_csv" # Required mapping keys: the profile's format_config MUST name a source # column for each of these. REQUIRED_MAPPING_KEYS = ( "start_time", "caller_number", "called_number", "duration_sec", ) OPTIONAL_MAPPING_KEYS = ( "billed_amount", "trunk_group", "account_id", "direction", "disposition", "customer_type_override", "call_id", ) def _check_mapping(self) -> None: missing = [k for k in self.REQUIRED_MAPPING_KEYS if not self.profile_config.get(k)] if missing: raise ValidationError( "bad_mapping", f"generic_csv profile config missing required keys: {missing}", ) def iter_rows(self, local_path: str) -> Iterator[CDRRow]: self._check_mapping() cfg = self.profile_config encoding = cfg.get("encoding", "utf-8") delimiter = cfg.get("delimiter", ",") ts_format = cfg.get("ts_format") col = { "start_time": cfg["start_time"], "caller_number": cfg["caller_number"], "called_number": cfg["called_number"], "duration_sec": cfg["duration_sec"], } # Optional source column names — None when not mapped opt = {k: cfg.get(k) for k in self.OPTIONAL_MAPPING_KEYS} with open(local_path, "r", encoding=encoding, errors="replace", newline="") as fh: reader = csv.DictReader(fh, delimiter=delimiter) for i, raw_row in enumerate(reader, start=1): try: start_time = self.parse_ts(raw_row.get(col["start_time"]), ts_format) duration = self.parse_duration(raw_row.get(col["duration_sec"])) caller = (raw_row.get(col["caller_number"]) or "").strip() called = (raw_row.get(col["called_number"]) or "").strip() row = CDRRow( start_time=start_time, caller_number=caller, called_number=called, duration_sec=duration, billed_amount_cents=( self.parse_cents(raw_row.get(opt["billed_amount"])) if opt.get("billed_amount") else None ), billed_currency=(cfg.get("currency", "USD") if opt.get("billed_amount") else None), trunk_group_id=( raw_row.get(opt["trunk_group"]).strip() if opt.get("trunk_group") and raw_row.get(opt["trunk_group"]) else None ), customer_account_id=( raw_row.get(opt["account_id"]).strip() if opt.get("account_id") and raw_row.get(opt["account_id"]) else None ), call_direction=( (raw_row.get(opt["direction"]) or "").strip().lower() or None if opt.get("direction") else None ), disposition=( (raw_row.get(opt["disposition"]) or "").strip().lower() or None if opt.get("disposition") else None ), customer_type_override=( (raw_row.get(opt["customer_type_override"]) or "").strip().lower() or None if opt.get("customer_type_override") else None ), natural_key=( raw_row.get(opt["call_id"]).strip() if opt.get("call_id") and raw_row.get(opt["call_id"]) else f"{caller}|{called}|{start_time.isoformat()}|{duration}" ), source_file=local_path, source_row=i, raw=dict(raw_row), ) self.validate_row(row) yield row except ValidationError: raise # let ingester catch + quarantine except Exception as exc: raise ValidationError("unparseable_row", str(exc)) from exc