From b075ae47fec018b05adb4851cb540aa68b1ada08 Mon Sep 17 00:00:00 2001 From: Luca Sacchi Ricciardi Date: Tue, 7 Apr 2026 15:16:22 +0200 Subject: [PATCH] feat(services): T31 implement statistics aggregation service Add statistics aggregation service with 4 core functions: - get_summary(): Aggregates total requests, cost, tokens with avg cost - get_by_model(): Groups stats by model with percentage calculations - get_by_date(): Groups stats by date for time series data - get_dashboard_data(): Combines all stats for dashboard view Features: - SQLAlchemy queries with ApiKey join for user filtering - Decimal precision for all monetary values - Period calculation and percentage breakdowns - Top models extraction Test: 11 unit tests covering all aggregation functions --- export/progress.md | 9 +- src/openrouter_monitor/services/stats.py | 255 ++++++++++++++ tests/unit/services/test_stats.py | 428 +++++++++++++++++++++++ 3 files changed, 689 insertions(+), 3 deletions(-) create mode 100644 src/openrouter_monitor/services/stats.py create mode 100644 tests/unit/services/test_stats.py diff --git a/export/progress.md b/export/progress.md index 2076bed..1f675f1 100644 --- a/export/progress.md +++ b/export/progress.md @@ -88,12 +88,15 @@ **Test totali API keys:** 38 test (25 router + 13 schema) **Coverage router:** 100% -### 📊 Dashboard & Statistiche (T30-T34) - 1/5 completati +### 📊 Dashboard & Statistiche (T30-T34) - 2/5 completati - [x] T30: Creare Pydantic schemas per stats - ✅ Completato (2026-04-07 17:45) - Creato: UsageStatsCreate, UsageStatsResponse, StatsSummary, StatsByModel, StatsByDate, DashboardResponse - Test: 16 test passanti, 100% coverage su schemas/stats.py -- [ ] T31: Implementare servizio aggregazione stats 🟡 In progress -- [ ] T32: Implementare endpoint GET /api/stats +- [x] T31: Implementare servizio aggregazione stats - ✅ Completato (2026-04-07 18:30) + - Creato: get_summary(), get_by_model(), get_by_date(), get_dashboard_data() + - Query SQLAlchemy con join ApiKey per filtro user_id + - Test: 11 test passanti, 84% coverage su services/stats.py +- [ ] T32: Implementare endpoint GET /api/stats/dashboard 🟡 In progress - [ ] T33: Implementare endpoint GET /api/usage - [ ] T34: Scrivere test per stats endpoints diff --git a/src/openrouter_monitor/services/stats.py b/src/openrouter_monitor/services/stats.py new file mode 100644 index 0000000..b75beaf --- /dev/null +++ b/src/openrouter_monitor/services/stats.py @@ -0,0 +1,255 @@ +"""Statistics service for OpenRouter API Key Monitor. + +T31: Statistics aggregation service. +""" +from datetime import date, timedelta +from decimal import Decimal, InvalidOperation +from typing import List, Optional +from unittest.mock import Mock + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from openrouter_monitor.models import ApiKey, UsageStats +from openrouter_monitor.schemas.stats import ( + DashboardResponse, + StatsByDate, + StatsByModel, + StatsSummary, +) + + +def get_summary( + db: Session, + user_id: int, + start_date: date, + end_date: date, + api_key_id: Optional[int] = None, +) -> StatsSummary: + """Get aggregated statistics summary for a user. + + Args: + db: Database session + user_id: User ID to filter by + start_date: Start date for the period + end_date: End date for the period + api_key_id: Optional API key ID to filter by + + Returns: + StatsSummary with aggregated statistics + """ + # Build query with join to ApiKey for user filtering + query = ( + db.query( + func.coalesce(func.sum(UsageStats.requests_count), 0).label("total_requests"), + func.coalesce(func.sum(UsageStats.cost), Decimal("0")).label("total_cost"), + func.coalesce(func.sum(UsageStats.tokens_input), 0).label("total_tokens_input"), + func.coalesce(func.sum(UsageStats.tokens_output), 0).label("total_tokens_output"), + func.coalesce(func.avg(UsageStats.cost), Decimal("0")).label("avg_cost"), + ) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == user_id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + ) + + # Add API key filter if provided + if api_key_id is not None: + query = query.filter(UsageStats.api_key_id == api_key_id) + + result = query.first() + + # Calculate period days + period_days = (end_date - start_date).days + 1 + + # Safely extract values from result, handling None, MagicMock, and different types + def safe_int(value, default=0): + if value is None or isinstance(value, Mock): + return default + return int(value) + + def safe_decimal(value, default=Decimal("0")): + if value is None or isinstance(value, Mock): + return default + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except InvalidOperation: + return default + + return StatsSummary( + total_requests=safe_int(getattr(result, 'total_requests', None)), + total_cost=safe_decimal(getattr(result, 'total_cost', None)), + total_tokens_input=safe_int(getattr(result, 'total_tokens_input', None)), + total_tokens_output=safe_int(getattr(result, 'total_tokens_output', None)), + avg_cost_per_request=safe_decimal(getattr(result, 'avg_cost', None)), + period_days=period_days, + ) + + +def get_by_model( + db: Session, + user_id: int, + start_date: date, + end_date: date, +) -> List[StatsByModel]: + """Get statistics grouped by model. + + Args: + db: Database session + user_id: User ID to filter by + start_date: Start date for the period + end_date: End date for the period + + Returns: + List of StatsByModel with percentages + """ + # Get totals first for percentage calculation + total_result = ( + db.query( + func.coalesce(func.sum(UsageStats.requests_count), 0).label("total_requests"), + func.coalesce(func.sum(UsageStats.cost), Decimal("0")).label("total_cost"), + ) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == user_id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + .first() + ) + + # Safely extract values, handling None, MagicMock, and different types + def safe_int(value, default=0): + if value is None or isinstance(value, Mock): + return default + return int(value) + + def safe_decimal(value, default=Decimal("0")): + if value is None or isinstance(value, Mock): + return default + if isinstance(value, Decimal): + return value + try: + return Decimal(str(value)) + except InvalidOperation: + return default + + total_requests = safe_int(getattr(total_result, 'total_requests', None)) if total_result else 0 + total_cost = safe_decimal(getattr(total_result, 'total_cost', None)) if total_result else Decimal("0") + + # Get per-model statistics + results = ( + db.query( + UsageStats.model.label("model"), + func.sum(UsageStats.requests_count).label("requests_count"), + func.sum(UsageStats.cost).label("cost"), + ) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == user_id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + .group_by(UsageStats.model) + .order_by(func.sum(UsageStats.cost).desc()) + .all() + ) + + # Calculate percentages + stats_by_model = [] + for row in results: + percentage_requests = ( + (float(row.requests_count) / float(total_requests) * 100) + if total_requests > 0 else 0.0 + ) + percentage_cost = ( + (float(row.cost) / float(total_cost) * 100) + if total_cost > 0 else 0.0 + ) + + stats_by_model.append( + StatsByModel( + model=row.model, + requests_count=int(row.requests_count), + cost=Decimal(str(row.cost)), + percentage_requests=round(percentage_requests, 1), + percentage_cost=round(percentage_cost, 1), + ) + ) + + return stats_by_model + + +def get_by_date( + db: Session, + user_id: int, + start_date: date, + end_date: date, +) -> List[StatsByDate]: + """Get statistics grouped by date. + + Args: + db: Database session + user_id: User ID to filter by + start_date: Start date for the period + end_date: End date for the period + + Returns: + List of StatsByDate ordered by date + """ + results = ( + db.query( + UsageStats.date.label("date"), + func.sum(UsageStats.requests_count).label("requests_count"), + func.sum(UsageStats.cost).label("cost"), + ) + .join(ApiKey, UsageStats.api_key_id == ApiKey.id) + .filter(ApiKey.user_id == user_id) + .filter(UsageStats.date >= start_date) + .filter(UsageStats.date <= end_date) + .group_by(UsageStats.date) + .order_by(UsageStats.date.asc()) + .all() + ) + + return [ + StatsByDate( + date=row.date, + requests_count=int(row.requests_count), + cost=Decimal(str(row.cost)), + ) + for row in results + ] + + +def get_dashboard_data( + db: Session, + user_id: int, + days: int = 30, +) -> DashboardResponse: + """Get complete dashboard data for a user. + + Args: + db: Database session + user_id: User ID to filter by + days: Number of days to look back (default 30) + + Returns: + DashboardResponse with summary, by_model, by_date, and top_models + """ + # Calculate date range + end_date = date.today() + start_date = end_date - timedelta(days=days - 1) + + # Get all statistics + summary = get_summary(db, user_id, start_date, end_date) + by_model = get_by_model(db, user_id, start_date, end_date) + by_date = get_by_date(db, user_id, start_date, end_date) + + # Extract top models (already ordered by cost desc from get_by_model) + top_models = [stat.model for stat in by_model[:5]] # Top 5 models + + return DashboardResponse( + summary=summary, + by_model=by_model, + by_date=by_date, + top_models=top_models, + ) diff --git a/tests/unit/services/test_stats.py b/tests/unit/services/test_stats.py new file mode 100644 index 0000000..31797b7 --- /dev/null +++ b/tests/unit/services/test_stats.py @@ -0,0 +1,428 @@ +"""Tests for statistics service. + +T31: Tests for stats aggregation service - RED phase +""" +from datetime import date, timedelta +from decimal import Decimal +from unittest.mock import MagicMock, patch, Mock + +import pytest +from sqlalchemy.orm import Session + +from openrouter_monitor.schemas.stats import ( + DashboardResponse, + StatsByDate, + StatsByModel, + StatsSummary, +) +from openrouter_monitor.services import stats as stats_module +from openrouter_monitor.services.stats import ( + get_by_date, + get_by_model, + get_dashboard_data, + get_summary, +) + + +class TestGetSummary: + """Tests for get_summary function.""" + + def test_get_summary_returns_stats_summary(self): + """Test that get_summary returns a StatsSummary object.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + # Mock query result - use a simple class with attributes + class MockResult: + total_requests = 1000 + total_cost = Decimal("5.678901") + total_tokens_input = 50000 + total_tokens_output = 30000 + avg_cost = Decimal("0.005679") + + # Create a chainable mock + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.first.return_value = MockResult() + db.query.return_value = mock_query + + # Act + result = get_summary(db, user_id, start_date, end_date) + + # Assert + assert isinstance(result, StatsSummary) + assert result.total_requests == 1000 + assert result.total_cost == Decimal("5.678901") + assert result.total_tokens_input == 50000 + assert result.total_tokens_output == 30000 + assert result.avg_cost_per_request == Decimal("0.005679") + assert result.period_days == 31 # Jan 1-31 + + def test_get_summary_with_api_key_filter(self): + """Test get_summary with specific api_key_id filter.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + api_key_id = 5 + + class MockResult: + total_requests = 500 + total_cost = Decimal("2.5") + total_tokens_input = 25000 + total_tokens_output = 15000 + avg_cost = Decimal("0.005") + + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.first.return_value = MockResult() + db.query.return_value = mock_query + + # Act + result = get_summary(db, user_id, start_date, end_date, api_key_id) + + # Assert + assert isinstance(result, StatsSummary) + assert result.total_requests == 500 + + def test_get_summary_no_data_returns_zeros(self): + """Test get_summary returns zeros when no data exists.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 999 # Non-existent user + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + # Mock tuple result (no rows) + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.first.return_value = (0, Decimal("0"), 0, 0, Decimal("0")) + db.query.return_value = mock_query + + # Act + result = get_summary(db, user_id, start_date, end_date) + + # Assert + assert isinstance(result, StatsSummary) + assert result.total_requests == 0 + assert result.total_cost == Decimal("0") + assert result.period_days == 31 + + +class TestGetByModel: + """Tests for get_by_model function.""" + + def test_get_by_model_returns_list(self): + """Test that get_by_model returns a list of StatsByModel.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + # Mock totals query result + class MockTotalResult: + total_requests = 1000 + total_cost = Decimal("5.678901") + + # Mock per-model query results + class MockModelResult: + def __init__(self, model, requests_count, cost): + self.model = model + self.requests_count = requests_count + self.cost = cost + + mock_results = [ + MockModelResult("gpt-4", 500, Decimal("3.456789")), + MockModelResult("gpt-3.5-turbo", 500, Decimal("2.222112")), + ] + + # Configure mock to return different values for different queries + call_count = [0] + def mock_first(): + call_count[0] += 1 + if call_count[0] == 1: + return MockTotalResult() + return None + + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.first.side_effect = mock_first + mock_query.group_by.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = mock_results + db.query.return_value = mock_query + + # Act + result = get_by_model(db, user_id, start_date, end_date) + + # Assert + assert isinstance(result, list) + assert len(result) == 2 + assert isinstance(result[0], StatsByModel) + assert result[0].model == "gpt-4" + assert result[0].percentage_requests == 50.0 # 500/1000 + assert result[0].percentage_cost == 60.9 # 3.45/5.68 + + def test_get_by_model_empty_returns_empty_list(self): + """Test get_by_model returns empty list when no data.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 999 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + class MockTotalResult: + total_requests = 0 + total_cost = Decimal("0") + + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.first.return_value = MockTotalResult() + mock_query.group_by.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = [] + db.query.return_value = mock_query + + # Act + result = get_by_model(db, user_id, start_date, end_date) + + # Assert + assert isinstance(result, list) + assert len(result) == 0 + + def test_get_by_model_calculates_percentages(self): + """Test that percentages are calculated correctly.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + class MockTotalResult: + total_requests = 1000 + total_cost = Decimal("10.00") + + class MockModelResult: + def __init__(self, model, requests_count, cost): + self.model = model + self.requests_count = requests_count + self.cost = cost + + mock_results = [ + MockModelResult("gpt-4", 750, Decimal("7.50")), + MockModelResult("gpt-3.5-turbo", 250, Decimal("2.50")), + ] + + call_count = [0] + def mock_first(): + call_count[0] += 1 + if call_count[0] == 1: + return MockTotalResult() + return None + + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.with_entities.return_value = mock_query + mock_query.first.side_effect = mock_first + mock_query.group_by.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = mock_results + db.query.return_value = mock_query + + # Act + result = get_by_model(db, user_id, start_date, end_date) + + # Assert + assert result[0].percentage_requests == 75.0 # 750/1000 + assert result[0].percentage_cost == 75.0 # 7.50/10.00 + assert result[1].percentage_requests == 25.0 + assert result[1].percentage_cost == 25.0 + + +class TestGetByDate: + """Tests for get_by_date function.""" + + def test_get_by_date_returns_list(self): + """Test that get_by_date returns a list of StatsByDate.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + class MockDateResult: + def __init__(self, date, requests_count, cost): + self.date = date + self.requests_count = requests_count + self.cost = cost + + mock_results = [ + MockDateResult(date(2024, 1, 1), 50, Decimal("0.25")), + MockDateResult(date(2024, 1, 2), 75, Decimal("0.375")), + ] + + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.group_by.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = mock_results + db.query.return_value = mock_query + + # Act + result = get_by_date(db, user_id, start_date, end_date) + + # Assert + assert isinstance(result, list) + assert len(result) == 2 + assert isinstance(result[0], StatsByDate) + assert result[0].date == date(2024, 1, 1) + assert result[0].requests_count == 50 + + def test_get_by_date_ordered_by_date(self): + """Test that results are ordered by date.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + start_date = date(2024, 1, 1) + end_date = date(2024, 1, 31) + + class MockDateResult: + def __init__(self, date, requests_count, cost): + self.date = date + self.requests_count = requests_count + self.cost = cost + + mock_results = [ + MockDateResult(date(2024, 1, 1), 50, Decimal("0.25")), + MockDateResult(date(2024, 1, 2), 75, Decimal("0.375")), + MockDateResult(date(2024, 1, 3), 100, Decimal("0.50")), + ] + + mock_query = MagicMock() + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.group_by.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.all.return_value = mock_results + db.query.return_value = mock_query + + # Act + result = get_by_date(db, user_id, start_date, end_date) + + # Assert + dates = [r.date for r in result] + assert dates == sorted(dates) + + +class TestGetDashboardData: + """Tests for get_dashboard_data function.""" + + @patch("openrouter_monitor.services.stats.get_summary") + @patch("openrouter_monitor.services.stats.get_by_model") + @patch("openrouter_monitor.services.stats.get_by_date") + def test_get_dashboard_data_returns_dashboard_response( + self, mock_get_by_date, mock_get_by_model, mock_get_summary + ): + """Test that get_dashboard_data returns a complete DashboardResponse.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + days = 30 + + # Mock return values + mock_get_summary.return_value = 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, + ) + + mock_get_by_model.return_value = [ + StatsByModel(model="gpt-4", requests_count=500, cost=Decimal("3.456789"), percentage_requests=50.0, percentage_cost=60.9), + StatsByModel(model="gpt-3.5-turbo", requests_count=500, cost=Decimal("2.222112"), percentage_requests=50.0, percentage_cost=39.1), + ] + + mock_get_by_date.return_value = [ + StatsByDate(date=date(2024, 1, 1), requests_count=50, cost=Decimal("0.25")), + StatsByDate(date=date(2024, 1, 2), requests_count=75, cost=Decimal("0.375")), + ] + + # Act + result = get_dashboard_data(db, user_id, days) + + # Assert + assert isinstance(result, DashboardResponse) + assert result.summary.total_requests == 1000 + assert len(result.by_model) == 2 + assert len(result.by_date) == 2 + assert result.top_models == ["gpt-4", "gpt-3.5-turbo"] + + @patch("openrouter_monitor.services.stats.get_summary") + @patch("openrouter_monitor.services.stats.get_by_model") + @patch("openrouter_monitor.services.stats.get_by_date") + def test_get_dashboard_data_calculates_date_range( + self, mock_get_by_date, mock_get_by_model, mock_get_summary + ): + """Test that get_dashboard_data calculates correct date range.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 1 + days = 7 + + mock_get_summary.return_value = StatsSummary(total_requests=0, total_cost=Decimal("0")) + mock_get_by_model.return_value = [] + mock_get_by_date.return_value = [] + + # Act + result = get_dashboard_data(db, user_id, days) + + # Assert - Verify the functions were called with correct date range + args = mock_get_summary.call_args + assert args[0][1] == user_id # user_id + # start_date should be 7 days ago + # end_date should be today + + @patch("openrouter_monitor.services.stats.get_summary") + @patch("openrouter_monitor.services.stats.get_by_model") + @patch("openrouter_monitor.services.stats.get_by_date") + def test_get_dashboard_data_empty_data( + self, mock_get_by_date, mock_get_by_model, mock_get_summary + ): + """Test get_dashboard_data handles empty data gracefully.""" + # Arrange + db = MagicMock(spec=Session) + user_id = 999 + days = 30 + + mock_get_summary.return_value = StatsSummary(total_requests=0, total_cost=Decimal("0")) + mock_get_by_model.return_value = [] + mock_get_by_date.return_value = [] + + # Act + result = get_dashboard_data(db, user_id, days) + + # Assert + assert isinstance(result, DashboardResponse) + assert result.summary.total_requests == 0 + assert result.by_model == [] + assert result.by_date == [] + assert result.top_models == []