"""v5 vessel enrichment service. Read-only side: `get_vessel_enrichment_bundle` is the only path the aggregation/detail endpoints use. It never reaches out to third parties; it just returns whatever the upsert side has already cached. Expired rows are filtered out so old data never leaks back into the live UI. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.models.vessel_enrichment import VesselMediaEnrichment, VesselProfileEnrichment def _coerce_datetime(value: Any) -> datetime | None: if value in (None, ""): return None if isinstance(value, datetime): return value if value.tzinfo else value.replace(tzinfo=UTC) if isinstance(value, (int, float)): ts = float(value) if ts > 10_000_000_000: ts /= 1000 return datetime.fromtimestamp(ts, UTC) if isinstance(value, str): try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) except ValueError: return None return None def _build_payload(record, *, now: datetime) -> dict[str, Any] | None: if record is None: return None expires_at = record.expires_at if isinstance(expires_at, datetime): if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=UTC) if expires_at < now: return None return record.to_dict() async def get_vessel_enrichment_bundle(db: AsyncSession, mmsi: int) -> dict[str, Any]: now = datetime.now(UTC) profile = await db.get(VesselProfileEnrichment, mmsi) media = await db.get(VesselMediaEnrichment, mmsi) return { "mmsi": mmsi, "profile": _build_payload(profile, now=now), "media": _build_payload(media, now=now), } async def upsert_vessel_profile_enrichment( db: AsyncSession, *, mmsi: int, payload: dict[str, Any], ) -> dict[str, Any]: record = await db.get(VesselProfileEnrichment, mmsi) if record is None: record = VesselProfileEnrichment(mmsi=mmsi) db.add(record) return _apply_upsert(record, payload) async def upsert_vessel_media_enrichment( db: AsyncSession, *, mmsi: int, payload: dict[str, Any], ) -> dict[str, Any]: record = await db.get(VesselMediaEnrichment, mmsi) if record is None: record = VesselMediaEnrichment(mmsi=mmsi) db.add(record) return _apply_upsert(record, payload) def _apply_upsert(record, payload: dict[str, Any]) -> dict[str, Any]: if not isinstance(payload, dict): raise ValueError("enrichment payload must be an object") body = payload.get("payload") if body is not None and not isinstance(body, dict): raise ValueError("payload.payload must be an object") if body is not None: record.payload = body if "source" in payload and isinstance(payload["source"], str) and payload["source"].strip(): record.source = payload["source"].strip() fetched_at = _coerce_datetime(payload.get("fetched_at")) record.fetched_at = fetched_at or datetime.now(UTC) record.expires_at = _coerce_datetime(payload.get("expires_at")) confidence = payload.get("confidence") if confidence is not None: try: record.confidence = float(confidence) except (TypeError, ValueError): record.confidence = None if "reference_url" in payload: ref = payload.get("reference_url") record.reference_url = str(ref) if ref else None return record.to_dict()