- API versioning: all REST endpoints prefixed with /api/v1/ - Structured logging: replaced stdlib logging with structlog (console/JSON modes) - Alembic migrations: versioned DB schema with initial migration - Error standardization: global exception handlers for consistent envelope format - Interrupt cleanup: asyncio background task for expired interrupt removal - Integration tests: +30 tests (analytics, replay, openapi, error, session APIs) - Frontend tests: +57 tests (all components, pages, useWebSocket hook) - Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
61 lines
1.7 KiB
Python
61 lines
1.7 KiB
Python
"""Analytics API router -- dashboard metrics endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import asdict
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
|
|
from app.analytics.queries import get_analytics
|
|
from app.api_utils import envelope
|
|
from app.auth import require_admin_api_key
|
|
|
|
if TYPE_CHECKING:
|
|
from psycopg_pool import AsyncConnectionPool
|
|
|
|
router = APIRouter(
|
|
prefix="/api/v1/analytics",
|
|
tags=["analytics"],
|
|
dependencies=[Depends(require_admin_api_key)],
|
|
)
|
|
|
|
_RANGE_PATTERN = re.compile(r"^(\d+)d$")
|
|
_DEFAULT_RANGE = "7d"
|
|
_MAX_RANGE_DAYS = 365
|
|
|
|
|
|
async def _get_pool(request: Request) -> AsyncConnectionPool:
|
|
"""Dependency: extract the shared pool from app state."""
|
|
return request.app.state.pool
|
|
|
|
|
|
def _parse_range(range_str: str) -> int:
|
|
"""Parse 'Xd' range string to integer days. Raises 400 on invalid format."""
|
|
match = _RANGE_PATTERN.match(range_str)
|
|
if not match:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid range format. Expected: '<N>d' e.g. '7d', '30d'.",
|
|
)
|
|
days = int(match.group(1))
|
|
if days < 1 or days > _MAX_RANGE_DAYS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Range must be between 1 and {_MAX_RANGE_DAYS} days.",
|
|
)
|
|
return days
|
|
|
|
|
|
@router.get("")
|
|
async def analytics(
|
|
request: Request,
|
|
range: str = Query(default=_DEFAULT_RANGE, alias="range"), # noqa: A002
|
|
) -> dict:
|
|
"""Return aggregated analytics metrics for the given time range."""
|
|
range_days = _parse_range(range)
|
|
pool = await _get_pool(request)
|
|
result = await get_analytics(pool, range_days=range_days)
|
|
return envelope(asdict(result))
|