agnes-the-ai-analyst/app/api/metrics.py
ZdenekSrotyr fbad3f5538 fix: address Devin review — partial download cleanup, category validation, path escaping, docs
- cli/commands/analyst.py: delete partial parquet file on download failure to unblock re-download
- cli/commands/analyst.py: escape single quotes in parquet path to prevent SQL injection
- app/api/metrics.py: replace tempfile-based import with inline YAML parse + direct repo.create(); validates name+category upfront and returns 400 if missing; removes os/tempfile imports
- CLAUDE.md: update schema version text to v4 with full migration chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 09:41:29 +02:00

176 lines
5.5 KiB
Python

"""Metrics API endpoints — CRUD for metric definitions stored in DuckDB."""
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()
try:
data = yaml.safe_load(content)
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
if not data:
raise HTTPException(status_code=400, detail="Empty YAML file")
metric_list = data if isinstance(data, list) else [data]
repo = MetricRepository(conn)
count = 0
for metric in metric_list:
if not isinstance(metric, dict):
continue
name = metric.get("name")
category = metric.get("category")
if not name or not category:
raise HTTPException(
status_code=400,
detail="Each metric must have 'name' and 'category' fields",
)
metric_id = f"{category}/{name}"
table_name = metric.pop("table", None) or metric.get("table_name")
# Collect sql_by_* variants
sql_variants = {}
for key in list(metric.keys()):
if key.startswith("sql_by_"):
sql_variants[key[4:]] = metric.pop(key)
repo.create(
id=metric_id,
name=name,
display_name=metric.get("display_name", name),
category=category,
description=metric.get("description"),
type=metric.get("type", "sum"),
unit=metric.get("unit"),
grain=metric.get("grain", "monthly"),
table_name=table_name,
tables=metric.get("tables"),
expression=metric.get("expression"),
time_column=metric.get("time_column"),
dimensions=metric.get("dimensions"),
filters=metric.get("filters"),
synonyms=metric.get("synonyms"),
notes=metric.get("notes"),
sql=metric.get("sql", ""),
sql_variants=sql_variants if sql_variants else None,
validation=metric.get("validation"),
source="yaml_import",
)
count += 1
return {"status": "imported", "count": count}