"""FreeSWITCH CDR adapter. Handles the standard ``mod_cdr_csv`` output format: "caller_id_name","caller_id_number","destination_number","context", "start_stamp","answer_stamp","end_stamp","duration","billsec", "hangup_cause","uuid","bleg_uuid","accountcode" Billed amount is populated via ``mod_nibblebill`` when installed — the additional columns ``nibble_total_billed`` / ``nibble_bill_amount`` / ``nibble_rate`` land in the same CSV. We pick them up when present. ``uuid`` is FreeSWITCH's unique call identifier and makes a perfect natural key for dedup. """ from __future__ import annotations import csv import logging from typing import Iterator from .base import BaseCDRAdapter, CDRRow, ValidationError logger = logging.getLogger(__name__) class FreeSWITCHAdapter(BaseCDRAdapter): FORMAT_SLUG = "freeswitch" # Columns we check for per-call billed amount (mod_nibblebill output) _BILLED_COLUMNS = ( "nibble_total_billed", "nibble_bill_amount", "billed_amount", "total_charge", "charge", ) def iter_rows(self, local_path: str) -> Iterator[CDRRow]: with open(local_path, "r", encoding="utf-8", errors="replace", newline="") as fh: reader = csv.DictReader(fh) for i, raw in enumerate(reader, start=1): try: start_raw = raw.get("start_stamp") or raw.get("answer_stamp") start = self.parse_ts(start_raw) duration_raw = raw.get("billsec") or raw.get("duration") or "0" duration = self.parse_duration(duration_raw) caller = (raw.get("caller_id_number") or "").strip() called = (raw.get("destination_number") or "").strip() uuid = (raw.get("uuid") or "").strip() billed_cents = None for col in self._BILLED_COLUMNS: if raw.get(col): billed_cents = self.parse_cents(raw[col]) if billed_cents is not None: break row = CDRRow( start_time=start, caller_number=caller, called_number=called, duration_sec=duration, billed_amount_cents=billed_cents, billed_currency=("USD" if billed_cents is not None else None), trunk_group_id=(raw.get("context") or "").strip() or None, customer_account_id=(raw.get("accountcode") or "").strip() or None, disposition=(raw.get("hangup_cause") or "").strip().lower() or None, natural_key=uuid or f"{caller}|{called}|{start.isoformat()}|{duration}", source_file=local_path, source_row=i, raw=dict(raw), ) self.validate_row(row) yield row except ValidationError: raise except Exception as exc: raise ValidationError("unparseable_row", str(exc)) from exc