199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
"""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
|