agnes-the-ai-analyst/app/api/metrics.py
ZdenekSrotyr 126d151413 fix: address code review — path injection, multi-table search, metrics import API, error handling
- 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
2026-04-10 19:56:00 +02:00

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)