"""CRUD APIs for persistent Earth interactables.""" from __future__ import annotations from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field, field_validator from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import get_current_user from app.db.session import get_db from app.models.earth_interactable import EarthInteractable from app.models.user import User from app.services.earth_interactables import ( build_interactable_event, interactables_to_geojson, invalidate_interactable_cache, list_interactables, normalize_interactable_id, publish_interactable_event, serialize_interactable, ) from app.services.earth_layer_cache import ( EarthLayerCachePolicy, earth_layer_cache, get_or_build_layer_payload, ) router = APIRouter() INTERACTABLE_CACHE_POLICY = EarthLayerCachePolicy( fresh_ttl_seconds=60, stale_ttl_seconds=10 * 60, max_features=5000, ) class InteractableCreate(BaseModel): id: str | None = Field(default=None, max_length=160) layer: str = Field(default="default", min_length=1, max_length=80) kind: str = Field(default="default", min_length=1, max_length=80) label: str = Field(default="", max_length=255) description: str = Field(default="", max_length=4000) latitude: float = Field(ge=-90, le=90) longitude: float = Field(ge=-180, le=180) altitude: float | None = None properties: dict[str, Any] = Field(default_factory=dict) @field_validator("layer", "kind") @classmethod def normalize_key(cls, value: str) -> str: normalized = str(value or "").strip() if not normalized: raise ValueError("must not be empty") return normalized class InteractableUpdate(BaseModel): layer: str | None = Field(default=None, min_length=1, max_length=80) kind: str | None = Field(default=None, min_length=1, max_length=80) label: str | None = Field(default=None, max_length=255) description: str | None = Field(default=None, max_length=4000) latitude: float | None = Field(default=None, ge=-90, le=90) longitude: float | None = Field(default=None, ge=-180, le=180) altitude: float | None = None properties: dict[str, Any] | None = None @router.get("") async def get_interactables( response: Response, layer: str | None = Query(default=None), include_deleted: bool = Query(default=False), db: AsyncSession = Depends(get_db), ): items = await list_interactables(db, layer=layer, include_deleted=include_deleted) response.headers["X-Planet-Interactables-Count"] = str(len(items)) return {"items": [serialize_interactable(item) for item in items]} @router.get("/geojson") async def get_interactables_geojson( response: Response, layer: str | None = Query(default=None), db: AsyncSession = Depends(get_db), ): async def build_payload() -> dict[str, Any]: items = await list_interactables(db, layer=layer) return interactables_to_geojson(items) payload = await get_or_build_layer_payload( key=earth_layer_cache.key("interactables", interactable_layer=layer or "all"), policy=INTERACTABLE_CACHE_POLICY, builder=build_payload, response=response, ) response.headers["X-Planet-Interactables-Count"] = str(len(payload.get("features") or [])) return payload @router.post("", status_code=status.HTTP_201_CREATED) async def create_interactable( payload: InteractableCreate, _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): record_id = normalize_interactable_id(payload.id) existing = await db.get(EarthInteractable, record_id) if existing and not existing.is_deleted: raise HTTPException(status_code=409, detail="Interactable already exists") if existing is None: record = EarthInteractable(id=record_id) db.add(record) else: record = existing record.is_deleted = False record.deleted_at = None record.revision += 1 record.layer = payload.layer record.kind = payload.kind record.label = payload.label record.description = payload.description record.latitude = payload.latitude record.longitude = payload.longitude record.altitude = payload.altitude record.properties = payload.properties await db.commit() await db.refresh(record) invalidate_interactable_cache(record.layer) await publish_interactable_event("created", record) return {"item": serialize_interactable(record)} @router.get("/{interactable_id}") async def get_interactable(interactable_id: str, db: AsyncSession = Depends(get_db)): record = await db.get(EarthInteractable, interactable_id) if record is None or record.is_deleted: raise HTTPException(status_code=404, detail="Interactable not found") return {"item": serialize_interactable(record)} @router.patch("/{interactable_id}") async def update_interactable( interactable_id: str, payload: InteractableUpdate, _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): record = await db.get(EarthInteractable, interactable_id) if record is None or record.is_deleted: raise HTTPException(status_code=404, detail="Interactable not found") previous_layer = record.layer patch = payload.model_dump(exclude_unset=True) for key, value in patch.items(): setattr(record, key, value) record.revision += 1 await db.commit() await db.refresh(record) invalidate_interactable_cache(previous_layer) if record.layer != previous_layer: invalidate_interactable_cache(record.layer) await publish_interactable_event("updated", record) return {"item": serialize_interactable(record)} @router.delete("/{interactable_id}") async def delete_interactable( interactable_id: str, _current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): record = await db.get(EarthInteractable, interactable_id) if record is None or record.is_deleted: raise HTTPException(status_code=404, detail="Interactable not found") record.is_deleted = True record.deleted_at = datetime.now(UTC) record.revision += 1 await db.commit() await db.refresh(record) invalidate_interactable_cache(record.layer) await publish_interactable_event("deleted", record) event = build_interactable_event(action="deleted", record=record, include_item=True) return {"deleted": True, "event": event}