feat(stats): T32-T33 implement dashboard and usage endpoints
Add statistics router with two endpoints: - GET /api/stats/dashboard: Aggregated dashboard statistics - Query param: days (1-365, default 30) - Auth required - Returns DashboardResponse - GET /api/usage: Detailed usage statistics with filtering - Required params: start_date, end_date - Optional filters: api_key_id, model - Pagination: skip, limit (max 1000) - Auth required - Returns List[UsageStatsResponse] Also add get_usage_stats() service function for querying individual usage records with filtering and pagination.
This commit is contained in:
@@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from openrouter_monitor.config import get_settings
|
from openrouter_monitor.config import get_settings
|
||||||
from openrouter_monitor.routers import api_keys
|
from openrouter_monitor.routers import api_keys
|
||||||
from openrouter_monitor.routers import auth
|
from openrouter_monitor.routers import auth
|
||||||
|
from openrouter_monitor.routers import stats
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ app.add_middleware(
|
|||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
|
app.include_router(auth.router, prefix="/api/auth", tags=["authentication"])
|
||||||
app.include_router(api_keys.router, prefix="/api/keys", tags=["api-keys"])
|
app.include_router(api_keys.router, prefix="/api/keys", tags=["api-keys"])
|
||||||
|
app.include_router(stats.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Routers package for OpenRouter Monitor."""
|
"""Routers package for OpenRouter Monitor."""
|
||||||
from openrouter_monitor.routers import api_keys
|
from openrouter_monitor.routers import api_keys
|
||||||
from openrouter_monitor.routers import auth
|
from openrouter_monitor.routers import auth
|
||||||
|
from openrouter_monitor.routers import stats
|
||||||
|
|
||||||
__all__ = ["auth", "api_keys"]
|
__all__ = ["auth", "api_keys", "stats"]
|
||||||
|
|||||||
118
src/openrouter_monitor/routers/stats.py
Normal file
118
src/openrouter_monitor/routers/stats.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Statistics router for OpenRouter API Key Monitor.
|
||||||
|
|
||||||
|
T32-T33: Stats endpoints for dashboard and usage data.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from openrouter_monitor.database import get_db
|
||||||
|
from openrouter_monitor.dependencies import get_current_user
|
||||||
|
from openrouter_monitor.models import User
|
||||||
|
from openrouter_monitor.schemas.stats import (
|
||||||
|
DashboardResponse,
|
||||||
|
UsageStatsResponse,
|
||||||
|
)
|
||||||
|
from openrouter_monitor.services.stats import (
|
||||||
|
get_dashboard_data,
|
||||||
|
get_usage_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["statistics"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stats/dashboard",
|
||||||
|
response_model=DashboardResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get dashboard statistics",
|
||||||
|
description="Get aggregated statistics for the dashboard view.",
|
||||||
|
)
|
||||||
|
async def get_dashboard(
|
||||||
|
days: int = Query(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
le=365,
|
||||||
|
description="Number of days to look back (1-365)",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> DashboardResponse:
|
||||||
|
"""Get dashboard statistics for the current user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to look back (default 30, max 365)
|
||||||
|
db: Database session
|
||||||
|
current_user: Authenticated user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DashboardResponse with summary, by_model, by_date, and top_models
|
||||||
|
"""
|
||||||
|
return get_dashboard_data(db, current_user.id, days)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/usage",
|
||||||
|
response_model=List[UsageStatsResponse],
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Get detailed usage statistics",
|
||||||
|
description="Get detailed usage statistics with filtering and pagination.",
|
||||||
|
)
|
||||||
|
async def get_usage(
|
||||||
|
start_date: date = Query(
|
||||||
|
...,
|
||||||
|
description="Start date for the query (YYYY-MM-DD)",
|
||||||
|
),
|
||||||
|
end_date: date = Query(
|
||||||
|
...,
|
||||||
|
description="End date for the query (YYYY-MM-DD)",
|
||||||
|
),
|
||||||
|
api_key_id: Optional[int] = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by specific API key ID",
|
||||||
|
),
|
||||||
|
model: Optional[str] = Query(
|
||||||
|
default=None,
|
||||||
|
description="Filter by model name",
|
||||||
|
),
|
||||||
|
skip: int = Query(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Number of records to skip for pagination",
|
||||||
|
),
|
||||||
|
limit: int = Query(
|
||||||
|
default=100,
|
||||||
|
ge=1,
|
||||||
|
le=1000,
|
||||||
|
description="Maximum number of records to return (1-1000)",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> List[UsageStatsResponse]:
|
||||||
|
"""Get detailed usage statistics with filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date for the query period (required)
|
||||||
|
end_date: End date for the query period (required)
|
||||||
|
api_key_id: Optional filter by API key ID
|
||||||
|
model: Optional filter by model name
|
||||||
|
skip: Number of records to skip (pagination)
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
db: Database session
|
||||||
|
current_user: Authenticated user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of UsageStatsResponse matching the filters
|
||||||
|
"""
|
||||||
|
return get_usage_stats(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
model=model,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ from openrouter_monitor.schemas.stats import (
|
|||||||
StatsByDate,
|
StatsByDate,
|
||||||
StatsByModel,
|
StatsByModel,
|
||||||
StatsSummary,
|
StatsSummary,
|
||||||
|
UsageStatsResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -253,3 +254,61 @@ def get_dashboard_data(
|
|||||||
by_date=by_date,
|
by_date=by_date,
|
||||||
top_models=top_models,
|
top_models=top_models,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_usage_stats(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
api_key_id: Optional[int] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> List[UsageStatsResponse]:
|
||||||
|
"""Get detailed usage statistics with filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
user_id: User ID to filter by
|
||||||
|
start_date: Start date for the query period
|
||||||
|
end_date: End date for the query period
|
||||||
|
api_key_id: Optional filter by API key ID
|
||||||
|
model: Optional filter by model name
|
||||||
|
skip: Number of records to skip (pagination)
|
||||||
|
limit: Maximum number of records to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of UsageStatsResponse matching the filters
|
||||||
|
"""
|
||||||
|
from openrouter_monitor.models import UsageStats
|
||||||
|
|
||||||
|
# Build base query with join to ApiKey for user filtering
|
||||||
|
query = (
|
||||||
|
db.query(UsageStats)
|
||||||
|
.join(ApiKey, UsageStats.api_key_id == ApiKey.id)
|
||||||
|
.filter(ApiKey.user_id == user_id)
|
||||||
|
.filter(UsageStats.date >= start_date)
|
||||||
|
.filter(UsageStats.date <= end_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply optional filters
|
||||||
|
if api_key_id is not None:
|
||||||
|
query = query.filter(UsageStats.api_key_id == api_key_id)
|
||||||
|
|
||||||
|
if model is not None:
|
||||||
|
query = query.filter(UsageStats.model == model)
|
||||||
|
|
||||||
|
# Apply ordering and pagination
|
||||||
|
results = (
|
||||||
|
query.order_by(UsageStats.date.desc(), UsageStats.model)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to response schema
|
||||||
|
return [
|
||||||
|
UsageStatsResponse.model_validate(record)
|
||||||
|
for record in results
|
||||||
|
]
|
||||||
|
|||||||
272
tests/unit/routers/test_stats.py
Normal file
272
tests/unit/routers/test_stats.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"""Tests for statistics router.
|
||||||
|
|
||||||
|
T32-T33: Tests for stats endpoints - RED phase
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from openrouter_monitor.schemas.stats import (
|
||||||
|
DashboardResponse,
|
||||||
|
StatsByDate,
|
||||||
|
StatsByModel,
|
||||||
|
StatsSummary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardEndpoint:
|
||||||
|
"""Tests for GET /api/stats/dashboard endpoint."""
|
||||||
|
|
||||||
|
def test_dashboard_default_30_days(self, authorized_client):
|
||||||
|
"""Test dashboard endpoint with default 30 days parameter."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_dashboard_data") as mock_get_dashboard:
|
||||||
|
mock_get_dashboard.return_value = DashboardResponse(
|
||||||
|
summary=StatsSummary(
|
||||||
|
total_requests=1000,
|
||||||
|
total_cost=Decimal("5.678901"),
|
||||||
|
total_tokens_input=50000,
|
||||||
|
total_tokens_output=30000,
|
||||||
|
avg_cost_per_request=Decimal("0.005679"),
|
||||||
|
period_days=30,
|
||||||
|
),
|
||||||
|
by_model=[
|
||||||
|
StatsByModel(model="gpt-4", requests_count=600, cost=Decimal("4.00"), percentage_requests=60.0, percentage_cost=70.4),
|
||||||
|
],
|
||||||
|
by_date=[
|
||||||
|
StatsByDate(date=date(2024, 1, 1), requests_count=50, cost=Decimal("0.25")),
|
||||||
|
],
|
||||||
|
top_models=["gpt-4"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get("/api/stats/dashboard")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "summary" in data
|
||||||
|
assert data["summary"]["total_requests"] == 1000
|
||||||
|
assert data["summary"]["period_days"] == 30
|
||||||
|
assert "by_model" in data
|
||||||
|
assert "by_date" in data
|
||||||
|
assert "top_models" in data
|
||||||
|
|
||||||
|
def test_dashboard_custom_days(self, authorized_client):
|
||||||
|
"""Test dashboard endpoint with custom days parameter."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_dashboard_data") as mock_get_dashboard:
|
||||||
|
mock_get_dashboard.return_value = DashboardResponse(
|
||||||
|
summary=StatsSummary(total_requests=100, total_cost=Decimal("1.00"), period_days=7),
|
||||||
|
by_model=[],
|
||||||
|
by_date=[],
|
||||||
|
top_models=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get("/api/stats/dashboard?days=7")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["summary"]["period_days"] == 7
|
||||||
|
|
||||||
|
def test_dashboard_max_365_days(self, authorized_client):
|
||||||
|
"""Test dashboard endpoint enforces max 365 days limit."""
|
||||||
|
# Act - Request more than 365 days
|
||||||
|
response = authorized_client.get("/api/stats/dashboard?days=400")
|
||||||
|
|
||||||
|
# Assert - Should get validation error
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "detail" in data
|
||||||
|
|
||||||
|
def test_dashboard_min_1_day(self, authorized_client):
|
||||||
|
"""Test dashboard endpoint enforces min 1 day limit."""
|
||||||
|
# Act - Request less than 1 day
|
||||||
|
response = authorized_client.get("/api/stats/dashboard?days=0")
|
||||||
|
|
||||||
|
# Assert - Should get validation error
|
||||||
|
assert response.status_code == 422
|
||||||
|
data = response.json()
|
||||||
|
assert "detail" in data
|
||||||
|
|
||||||
|
def test_dashboard_without_auth(self, client):
|
||||||
|
"""Test dashboard endpoint requires authentication."""
|
||||||
|
# Act
|
||||||
|
response = client.get("/api/stats/dashboard")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 401
|
||||||
|
data = response.json()
|
||||||
|
assert "detail" in data
|
||||||
|
|
||||||
|
def test_dashboard_calls_service_with_correct_params(self, authorized_client):
|
||||||
|
"""Test that dashboard endpoint calls service with correct parameters."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_dashboard_data") as mock_get_dashboard:
|
||||||
|
mock_get_dashboard.return_value = DashboardResponse(
|
||||||
|
summary=StatsSummary(total_requests=0, total_cost=Decimal("0"), period_days=60),
|
||||||
|
by_model=[],
|
||||||
|
by_date=[],
|
||||||
|
top_models=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get("/api/stats/dashboard?days=60")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Verify service was called with correct params
|
||||||
|
mock_get_dashboard.assert_called_once()
|
||||||
|
args = mock_get_dashboard.call_args
|
||||||
|
assert args[0][2] == 60 # days parameter
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsageEndpoint:
|
||||||
|
"""Tests for GET /api/usage endpoint."""
|
||||||
|
|
||||||
|
def test_usage_with_required_dates(self, authorized_client):
|
||||||
|
"""Test usage endpoint with required date parameters."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
|
||||||
|
from openrouter_monitor.schemas.stats import UsageStatsResponse
|
||||||
|
mock_get_usage.return_value = [
|
||||||
|
UsageStatsResponse(
|
||||||
|
id=1,
|
||||||
|
api_key_id=1,
|
||||||
|
date=date(2024, 1, 15),
|
||||||
|
model="gpt-4",
|
||||||
|
requests_count=100,
|
||||||
|
tokens_input=5000,
|
||||||
|
tokens_output=3000,
|
||||||
|
cost=Decimal("0.123456"),
|
||||||
|
created_at="2024-01-15T12:00:00",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get("/api/usage?start_date=2024-01-01&end_date=2024-01-31")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["model"] == "gpt-4"
|
||||||
|
|
||||||
|
def test_usage_missing_required_dates(self, authorized_client):
|
||||||
|
"""Test usage endpoint requires start_date and end_date."""
|
||||||
|
# Act - Missing end_date
|
||||||
|
response = authorized_client.get("/api/usage?start_date=2024-01-01")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_usage_with_api_key_filter(self, authorized_client):
|
||||||
|
"""Test usage endpoint with api_key_id filter."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
|
||||||
|
mock_get_usage.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get(
|
||||||
|
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&api_key_id=5"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_get_usage.assert_called_once()
|
||||||
|
kwargs = mock_get_usage.call_args[1]
|
||||||
|
assert kwargs["api_key_id"] == 5
|
||||||
|
|
||||||
|
def test_usage_with_model_filter(self, authorized_client):
|
||||||
|
"""Test usage endpoint with model filter."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
|
||||||
|
mock_get_usage.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get(
|
||||||
|
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&model=gpt-4"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_get_usage.assert_called_once()
|
||||||
|
kwargs = mock_get_usage.call_args[1]
|
||||||
|
assert kwargs["model"] == "gpt-4"
|
||||||
|
|
||||||
|
def test_usage_with_pagination(self, authorized_client):
|
||||||
|
"""Test usage endpoint with skip and limit parameters."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
|
||||||
|
mock_get_usage.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get(
|
||||||
|
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&skip=10&limit=50"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_get_usage.assert_called_once()
|
||||||
|
kwargs = mock_get_usage.call_args[1]
|
||||||
|
assert kwargs["skip"] == 10
|
||||||
|
assert kwargs["limit"] == 50
|
||||||
|
|
||||||
|
def test_usage_max_limit_1000(self, authorized_client):
|
||||||
|
"""Test usage endpoint enforces max limit of 1000."""
|
||||||
|
# Act - Request more than 1000
|
||||||
|
response = authorized_client.get(
|
||||||
|
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&limit=1500"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_usage_combined_filters(self, authorized_client):
|
||||||
|
"""Test usage endpoint with all filters combined."""
|
||||||
|
# Arrange
|
||||||
|
with patch("openrouter_monitor.routers.stats.get_usage_stats") as mock_get_usage:
|
||||||
|
mock_get_usage.return_value = []
|
||||||
|
|
||||||
|
# Act
|
||||||
|
response = authorized_client.get(
|
||||||
|
"/api/usage?start_date=2024-01-01&end_date=2024-01-31&api_key_id=5&model=gpt-4&skip=0&limit=100"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_get_usage.assert_called_once()
|
||||||
|
kwargs = mock_get_usage.call_args[1]
|
||||||
|
assert kwargs["api_key_id"] == 5
|
||||||
|
assert kwargs["model"] == "gpt-4"
|
||||||
|
assert kwargs["skip"] == 0
|
||||||
|
assert kwargs["limit"] == 100
|
||||||
|
|
||||||
|
def test_usage_without_auth(self, client):
|
||||||
|
"""Test usage endpoint requires authentication."""
|
||||||
|
# Act
|
||||||
|
response = client.get("/api/usage?start_date=2024-01-01&end_date=2024-01-31")
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestSecurity:
|
||||||
|
"""Security tests for stats endpoints."""
|
||||||
|
|
||||||
|
def test_user_cannot_see_other_user_data_dashboard(self, authorized_client):
|
||||||
|
"""Test that user A cannot see dashboard data of user B."""
|
||||||
|
# This is tested implicitly by checking that the service is called
|
||||||
|
# with the current user's ID, not by allowing user_id parameter
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_cannot_see_other_user_data_usage(self, authorized_client):
|
||||||
|
"""Test that user A cannot see usage data of user B."""
|
||||||
|
# This is tested implicitly by the service filtering by user_id
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user