from __future__ import annotations from datetime import UTC, datetime from typing import Any from uuid import uuid4 from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.time import to_iso8601_utc from app.core.websocket.broadcaster import broadcaster from app.models.earth_interactable import EarthInteractable from app.services.earth_layer_cache import EARTH_LAYER_CACHE_PREFIX, earth_layer_cache INTERACTABLE_ENTITY = "interactable" INTERACTABLE_LAYER = "interactables" def normalize_interactable_id(value: str | None = None) -> str: raw = str(value or "").strip() return raw or f"interactable-{uuid4().hex}" def serialize_interactable(record: EarthInteractable) -> dict[str, Any]: return { "id": record.id, "layer": record.layer, "kind": record.kind, "label": record.label, "description": record.description, "latitude": record.latitude, "longitude": record.longitude, "altitude": record.altitude, "revision": record.revision, "properties": record.properties or {}, "is_deleted": bool(record.is_deleted), "created_at": to_iso8601_utc(record.created_at), "updated_at": to_iso8601_utc(record.updated_at), "deleted_at": to_iso8601_utc(record.deleted_at), } def interactables_to_geojson(items: list[EarthInteractable]) -> dict[str, Any]: return { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": item.id, "geometry": { "type": "Point", "coordinates": [item.longitude, item.latitude], }, "properties": serialize_interactable(item), } for item in items if not item.is_deleted ], } def invalidate_interactable_cache(layer: str | None = None) -> int: layer_key = str(layer or "*").strip() or "*" deleted = earth_layer_cache.delete_pattern( f"{EARTH_LAYER_CACHE_PREFIX}:interactables:interactable_layer:{layer_key}*" ) if layer_key != "all": deleted += earth_layer_cache.delete_pattern( f"{EARTH_LAYER_CACHE_PREFIX}:interactables:interactable_layer:all*" ) return deleted def build_interactable_event( *, action: str, record: EarthInteractable, include_item: bool = True, ) -> dict[str, Any]: item = serialize_interactable(record) return { "entity": INTERACTABLE_ENTITY, "action": action, "layer": record.layer, "layers": [INTERACTABLE_LAYER], "ids": [record.id], "revision": record.revision, "changed_at": item["deleted_at"] or item["updated_at"] or to_iso8601_utc(datetime.now(UTC)), "item": item if include_item else None, "source": "earth_interactables", } async def publish_interactable_event(action: str, record: EarthInteractable, *, include_item: bool = True) -> None: await broadcaster.broadcast_earth_update( build_interactable_event(action=action, record=record, include_item=include_item) ) async def list_interactables( db: AsyncSession, *, layer: str | None = None, include_deleted: bool = False, ) -> list[EarthInteractable]: stmt = select(EarthInteractable) if layer: stmt = stmt.where(EarthInteractable.layer == layer) if not include_deleted: stmt = stmt.where(EarthInteractable.is_deleted.is_(False)) stmt = stmt.order_by(EarthInteractable.updated_at.desc(), EarthInteractable.id.asc()) result = await db.execute(stmt) return list(result.scalars().all())