"""Persistence + validation for the v4 vessel_ais aggregation strategy.""" from __future__ import annotations from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.system_setting import SystemSetting VESSEL_AGGREGATION_STRATEGY_CATEGORY = "vessel_aggregation_strategy" DYNAMIC_FIELDS: tuple[str, ...] = ("lat", "lon", "sog", "cog", "heading", "nav_status") STATIC_FIELDS: tuple[str, ...] = ( "name", "callsign", "imo", "flag", "vessel_type", "vessel_type_name", "length", "width", "draught", ) ALLOWED_FIELDS: frozenset[str] = frozenset(DYNAMIC_FIELDS + STATIC_FIELDS) ALLOWED_DYNAMIC_MODES: frozenset[str] = frozenset({"newest"}) ALLOWED_STATIC_MODES: frozenset[str] = frozenset({"source_priority", "non_empty", "newest", "locked"}) ALLOWED_LOCKED_DYNAMIC_MODES: frozenset[str] = frozenset({"newest", "source_priority", "locked"}) DEFAULT_STRATEGY: dict[str, Any] = { "version": 1, "vessel_ais": { "source_priority": ["aisstream_vessels", "barentswatch_vessels"], "field_rules": {}, "freshness": { "realtime_stream_seconds": 900, "polling_seconds": 3600, }, "allow_dynamic_lock": False, }, } class StrategyValidationError(ValueError): """Raised when a saved strategy payload is malformed.""" def _coerce_str_list(value: Any, *, label: str) -> list[str]: if value is None: return [] if not isinstance(value, list): raise StrategyValidationError(f"{label} must be a list of source names") out: list[str] = [] for item in value: if not isinstance(item, str) or not item.strip(): raise StrategyValidationError(f"{label} entries must be non-empty strings") out.append(item.strip()) return out def validate_strategy(payload: dict[str, Any]) -> dict[str, Any]: """Validate and normalize a strategy payload. Raise StrategyValidationError on issues.""" if not isinstance(payload, dict): raise StrategyValidationError("strategy payload must be an object") vessel_ais = payload.get("vessel_ais") if not isinstance(vessel_ais, dict): raise StrategyValidationError("strategy.vessel_ais is required and must be an object") allow_dynamic_lock = bool(vessel_ais.get("allow_dynamic_lock", False)) source_priority = _coerce_str_list( vessel_ais.get("source_priority"), label="vessel_ais.source_priority", ) raw_rules = vessel_ais.get("field_rules") or {} if not isinstance(raw_rules, dict): raise StrategyValidationError("vessel_ais.field_rules must be an object") field_rules: dict[str, dict[str, Any]] = {} for field, rule in raw_rules.items(): if field not in ALLOWED_FIELDS: raise StrategyValidationError(f"unknown vessel_ais field: {field}") if not isinstance(rule, dict): raise StrategyValidationError(f"field_rules.{field} must be an object") mode = str(rule.get("mode") or "").strip() if not mode: raise StrategyValidationError(f"field_rules.{field}.mode is required") is_dynamic = field in DYNAMIC_FIELDS if is_dynamic: allowed_modes = ALLOWED_LOCKED_DYNAMIC_MODES if allow_dynamic_lock else ALLOWED_DYNAMIC_MODES if mode not in allowed_modes: if not allow_dynamic_lock: raise StrategyValidationError( f"field_rules.{field}.mode='{mode}' requires allow_dynamic_lock=true" ) raise StrategyValidationError( f"field_rules.{field}.mode must be one of {sorted(allowed_modes)}" ) else: if mode not in ALLOWED_STATIC_MODES: raise StrategyValidationError( f"field_rules.{field}.mode must be one of {sorted(ALLOWED_STATIC_MODES)}" ) normalized_rule: dict[str, Any] = {"mode": mode} rule_priority = rule.get("source_priority") if rule_priority is not None: normalized_rule["source_priority"] = _coerce_str_list( rule_priority, label=f"field_rules.{field}.source_priority", ) if mode == "locked": locked_source = rule.get("locked_source") if not isinstance(locked_source, str) or not locked_source.strip(): raise StrategyValidationError( f"field_rules.{field}.locked_source must be a non-empty string when mode=locked" ) normalized_rule["locked_source"] = locked_source.strip() field_rules[field] = normalized_rule raw_freshness = vessel_ais.get("freshness") or {} if not isinstance(raw_freshness, dict): raise StrategyValidationError("vessel_ais.freshness must be an object") freshness: dict[str, int] = {} for key in ("realtime_stream_seconds", "polling_seconds"): value = raw_freshness.get(key, DEFAULT_STRATEGY["vessel_ais"]["freshness"][key]) try: seconds = int(value) except (TypeError, ValueError) as exc: raise StrategyValidationError(f"freshness.{key} must be an integer") from exc if seconds < 0: raise StrategyValidationError(f"freshness.{key} must be non-negative") freshness[key] = seconds return { "version": int(payload.get("version") or 0) + 1, "vessel_ais": { "source_priority": source_priority, "field_rules": field_rules, "freshness": freshness, "allow_dynamic_lock": allow_dynamic_lock, }, } async def _select_setting(db: AsyncSession) -> SystemSetting | None: result = await db.execute( select(SystemSetting).where(SystemSetting.category == VESSEL_AGGREGATION_STRATEGY_CATEGORY) ) return result.scalar_one_or_none() def _current_version(setting: SystemSetting | None) -> int: if setting is None: return 0 payload = setting.payload or {} return int(payload.get("version") or 0) async def load_strategy(db: AsyncSession) -> dict[str, Any]: setting = await _select_setting(db) if setting is None or not isinstance(setting.payload, dict): return DEFAULT_STRATEGY payload = setting.payload if "vessel_ais" not in payload: return DEFAULT_STRATEGY return payload async def save_strategy(db: AsyncSession, payload: dict[str, Any]) -> dict[str, Any]: """Validate + persist; bumps version automatically.""" existing = await _select_setting(db) incoming = dict(payload) incoming.setdefault("version", _current_version(existing)) validated = validate_strategy(incoming) if existing is None: existing = SystemSetting(category=VESSEL_AGGREGATION_STRATEGY_CATEGORY, payload=validated) db.add(existing) else: existing.payload = validated await db.commit() return validated async def reset_strategy(db: AsyncSession) -> dict[str, Any]: existing = await _select_setting(db) payload = {**DEFAULT_STRATEGY, "version": _current_version(existing) + 1} if existing is None: existing = SystemSetting(category=VESSEL_AGGREGATION_STRATEGY_CATEGORY, payload=payload) db.add(existing) else: existing.payload = payload await db.commit() return payload