- Validate view names with _SAFE_IDENTIFIER regex and check path traversal in _initialize_duckdb() - find_by_table() and get_table_map() now also search the tables[] array field - Add POST /api/admin/metrics/import endpoint for YAML file upload - Replace generic except in _connect_to_instance() with specific HTTPStatusError/TimeoutException handlers - Generate .claude/settings.json in _generate_claude_md() bootstrap - Update test_find_by_table and test_get_table_map to cover tables[] array lookups - Add test_import_metrics_yaml in TestMetricsAPI
132 lines
3.9 KiB
Python
132 lines
3.9 KiB
Python
"""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)
|