110 lines
3.6 KiB
Python
110 lines
3.6 KiB
Python
"""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()
|