"""Metrics API endpoints — CRUD for metric definitions stored in DuckDB.""" import os import tempfile from typing import List, Optional import duckdb import yaml from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from app.auth.dependencies import get_current_user, require_admin, _get_db from src.repositories.metrics import MetricRepository router = APIRouter(tags=["metrics"]) class MetricCreate(BaseModel): id: str name: str display_name: str category: str sql: str description: Optional[str] = None type: str = "sum" unit: Optional[str] = None grain: str = "monthly" table_name: Optional[str] = None tables: Optional[List[str]] = None expression: Optional[str] = None time_column: Optional[str] = None dimensions: Optional[List[str]] = None filters: Optional[List[str]] = None synonyms: Optional[List[str]] = None notes: Optional[List[str]] = None sql_variants: Optional[dict] = None validation: Optional[dict] = None source: str = "manual" @router.get("/api/metrics") async def list_metrics( category: Optional[str] = None, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """List all metric definitions, optionally filtered by category.""" repo = MetricRepository(conn) metrics = repo.list(category=category) return {"metrics": metrics, "count": len(metrics)} @router.get("/api/metrics/{metric_id:path}") async def get_metric( metric_id: str, user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Get a single metric definition by ID.""" repo = MetricRepository(conn) metric = repo.get(metric_id) if metric is None: raise HTTPException(status_code=404, detail=f"Metric '{metric_id}' not found") return metric @router.post("/api/admin/metrics", status_code=201) async def create_or_update_metric( body: MetricCreate, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Create or update a metric definition (admin only).""" repo = MetricRepository(conn) metric = repo.create( id=body.id, name=body.name, display_name=body.display_name, category=body.category, sql=body.sql, description=body.description, type=body.type, unit=body.unit, grain=body.grain, table_name=body.table_name, tables=body.tables, expression=body.expression, time_column=body.time_column, dimensions=body.dimensions, filters=body.filters, synonyms=body.synonyms, notes=body.notes, sql_variants=body.sql_variants, validation=body.validation, source=body.source, ) return metric @router.delete("/api/admin/metrics/{metric_id:path}") async def delete_metric( metric_id: str, user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Delete a metric definition by ID (admin only).""" repo = MetricRepository(conn) deleted = repo.delete(metric_id) if not deleted: raise HTTPException(status_code=404, detail=f"Metric '{metric_id}' not found") return {"status": "deleted", "id": metric_id} @router.post("/api/admin/metrics/import", status_code=200) async def import_metrics( file: UploadFile = File(...), user: dict = Depends(require_admin), conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): """Import metrics from uploaded YAML file.""" content = await file.read() with tempfile.NamedTemporaryFile(suffix=".yml", delete=False, mode="wb") as tmp: tmp.write(content) tmp_path = tmp.name try: repo = MetricRepository(conn) count = repo.import_from_yaml(tmp_path) return {"status": "imported", "count": count} finally: os.unlink(tmp_path)