from __future__ import annotations import copy import csv import json from pathlib import Path from typing import Any, Iterable from .models import AttachmentConfig, CampaignConfig, EntryConfig, RecipientConfig, SourceType class EntryLoadError(ValueError): """Raised when campaign entries cannot be loaded from inline or external sources.""" def _resolve(campaign_file: str | Path, raw_path: str) -> Path: campaign_path = Path(campaign_file).resolve() path = Path(raw_path).expanduser() if path.is_absolute(): return path return (campaign_path.parent / path).resolve() def _parse_bool(value: Any) -> bool: if isinstance(value, bool): return value if value is None: return False text = str(value).strip().lower() if text in {"1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"}: return True if text in {"0", "false", "no", "n", "nein", "", "inactive", "inaktiv"}: return False raise EntryLoadError(f"cannot parse boolean value: {value!r}") def _parse_scalar_for_target(target: str, value: Any) -> Any: bool_targets = { "active", "combine_to", "combine_cc", "combine_bcc", "combine_reply_to", "combine_bounce_to", "combine_disposition_notification_to", "combine_attachments", } if target in bool_targets: return _parse_bool(value) if target.endswith(".include_subdirs") or target.endswith(".required") or target.endswith(".allow_multiple"): return _parse_bool(value) if target.endswith(".zip.enabled"): return _parse_bool(value) return value def _ensure_list_length(values: list[Any], index: int, factory: Any) -> None: while len(values) <= index: values.append(factory()) def _set_recipient_value(entry_data: dict[str, Any], target: str, value: Any) -> bool: # Examples: from.email, to.0.email, cc.0.name if target.startswith("from."): entry_data.setdefault("from", {}) _, field = target.split(".", 1) if field == "type": field = "type" entry_data["from"][field] = value return True for recipient_list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: prefix = recipient_list_name + "." if not target.startswith(prefix): continue parts = target.split(".") if len(parts) != 3 or not parts[1].isdigit(): raise EntryLoadError(f"invalid recipient mapping target: {target}") index = int(parts[1]) field = parts[2] recipients = entry_data.setdefault(recipient_list_name, []) _ensure_list_length(recipients, index, dict) recipients[index][field] = value return True return False def _set_attachment_value(entry_data: dict[str, Any], target: str, value: Any) -> bool: if not target.startswith("attachments."): return False parts = target.split(".") if len(parts) < 3 or not parts[1].isdigit(): raise EntryLoadError(f"invalid attachment mapping target: {target}") index = int(parts[1]) attachments = entry_data.setdefault("attachments", []) _ensure_list_length(attachments, index, dict) attachment = attachments[index] if parts[2] == "zip": if len(parts) != 4: raise EntryLoadError(f"invalid zip attachment mapping target: {target}") attachment.setdefault("zip", {})[parts[3]] = value return True if len(parts) != 3: raise EntryLoadError(f"invalid attachment mapping target: {target}") attachment[parts[2]] = value return True def _set_entry_value(entry_data: dict[str, Any], target: str, value: Any) -> None: value = _parse_scalar_for_target(target, value) if value is None: return if isinstance(value, str) and value == "": return if target.startswith("fields."): _, field_name = target.split(".", 1) entry_data.setdefault("fields", {})[field_name] = value return if _set_recipient_value(entry_data, target, value): return if _set_attachment_value(entry_data, target, value): return entry_data[target] = value def _entry_defaults_data(config: CampaignConfig) -> dict[str, Any]: if config.entries.defaults is None: return {} return config.entries.defaults.model_dump(mode="json", by_alias=True, exclude_none=True) def _load_csv_rows(path: Path, *, delimiter: str, encoding: str) -> list[dict[str, Any]]: try: with path.open("r", encoding=encoding, newline="") as handle: reader = csv.DictReader(handle, delimiter=delimiter) return [dict(row) for row in reader] except OSError as exc: raise EntryLoadError(f"could not read CSV entries source {path}: {exc}") from exc def _load_json_rows(path: Path, *, encoding: str) -> list[dict[str, Any]]: try: with path.open("r", encoding=encoding) as handle: data = json.load(handle) except OSError as exc: raise EntryLoadError(f"could not read JSON entries source {path}: {exc}") from exc except json.JSONDecodeError as exc: raise EntryLoadError(f"invalid JSON entries source {path}: {exc}") from exc if isinstance(data, list): rows = data elif isinstance(data, dict) and isinstance(data.get("entries"), list): rows = data["entries"] else: raise EntryLoadError("JSON entries source must be a list or an object with an 'entries' list") if not all(isinstance(row, dict) for row in rows): raise EntryLoadError("JSON entries source rows must be objects") return [dict(row) for row in rows] def _row_to_entry(defaults_data: dict[str, Any], mapping: dict[str, str], row: dict[str, Any], row_number: int) -> EntryConfig: entry_data = copy.deepcopy(defaults_data) for target, source_name in mapping.items(): if source_name not in row: # Detailed missing-column validation is handled in semantic validation. continue try: _set_entry_value(entry_data, target, row[source_name]) except EntryLoadError: raise except Exception as exc: raise EntryLoadError(f"row {row_number}: could not map {source_name!r} to {target!r}: {exc}") from exc try: return EntryConfig.model_validate(entry_data) except Exception as exc: raise EntryLoadError(f"row {row_number}: mapped entry is invalid: {exc}") from exc def load_campaign_entries(config: CampaignConfig, *, campaign_file: str | Path) -> list[EntryConfig]: """Load and normalize campaign entries from inline data or external CSV/JSON source. The normalized output is always a list of EntryConfig instances. This is intentionally UI/API friendly: a future web interface can generate the same JSON structure and use the same resolver without code changes. """ if config.entries.inline is not None: return list(config.entries.inline) if config.entries.source is None or config.entries.mapping is None: raise EntryLoadError("external entries require source and mapping") source = config.entries.source path = _resolve(campaign_file, source.path) if not path.exists(): raise EntryLoadError(f"entries source file does not exist: {path}") if source.type == SourceType.CSV: if not source.has_header: raise EntryLoadError("CSV entries currently require has_header=true") rows = _load_csv_rows(path, delimiter=source.delimiter, encoding=source.encoding) elif source.type == SourceType.JSON: rows = _load_json_rows(path, encoding=source.encoding) else: # pragma: no cover - defensive; Pydantic constrains this already. raise EntryLoadError(f"unsupported entries source type: {source.type}") defaults_data = _entry_defaults_data(config) return [_row_to_entry(defaults_data, config.entries.mapping, row, index + 2) for index, row in enumerate(rows)]