feat: implement v0.4.0 - Reports, Charts, Comparison, Dark Mode, E2E Testing
Some checks failed
E2E Tests / Run E2E Tests (push) Has been cancelled
E2E Tests / Visual Regression Tests (push) Has been cancelled
E2E Tests / Smoke Tests (push) Has been cancelled

Backend (@backend-dev):
- Add ReportService with PDF/CSV generation (reportlab, pandas)
- Implement Report API endpoints (POST, GET, DELETE, download)
- Add ReportRepository and schemas
- Configure storage with auto-cleanup (30 days)
- Rate limiting: 10 downloads/minute
- Professional PDF templates with charts support

Frontend (@frontend-dev):
- Integrate Recharts for data visualization
- Add CostBreakdown, TimeSeries, ComparisonBar charts
- Implement scenario comparison page with multi-select
- Add dark/light mode toggle with ThemeProvider
- Create Reports page with generation form and list
- Add new UI components: checkbox, dialog, tabs, label, skeleton
- Implement useComparison and useReports hooks

QA (@qa-engineer):
- Setup Playwright E2E testing framework
- Create 7 test spec files with 94 test cases
- Add visual regression testing with baselines
- Configure multi-browser testing (Chrome, Firefox, WebKit)
- Add mobile responsive tests
- Create test fixtures and helpers
- Setup GitHub Actions CI workflow

Documentation (@spec-architect):
- Create detailed kanban-v0.4.0.md with 27 tasks
- Update progress.md with v0.4.0 tracking
- Create v0.4.0 planning prompt

Features:
 PDF/CSV Report Generation
 Interactive Charts (Pie, Area, Bar)
 Scenario Comparison (2-4 scenarios)
 Dark/Light Mode Toggle
 E2E Test Suite (94 tests)

Dependencies added:
- Backend: reportlab, pandas, slowapi
- Frontend: recharts, date-fns, @radix-ui/react-checkbox/dialog/tabs
- Testing: @playwright/test

27 tasks completed, 100% v0.4.0 implementation
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 16:11:47 +02:00
parent 311a576f40
commit a5fc85897b
63 changed files with 9218 additions and 246 deletions

View File

@@ -5,8 +5,13 @@ from fastapi import APIRouter
from src.api.v1.scenarios import router as scenarios_router
from src.api.v1.ingest import router as ingest_router
from src.api.v1.metrics import router as metrics_router
from src.api.v1.reports import scenario_reports_router, reports_router
api_router = APIRouter()
api_router.include_router(scenarios_router, prefix="/scenarios", tags=["scenarios"])
api_router.include_router(ingest_router, tags=["ingest"])
api_router.include_router(metrics_router, prefix="/scenarios", tags=["metrics"])
api_router.include_router(
scenario_reports_router, prefix="/scenarios", tags=["reports"]
)
api_router.include_router(reports_router, prefix="/reports", tags=["reports"])

349
src/api/v1/reports.py Normal file
View File

@@ -0,0 +1,349 @@
"""Report API endpoints."""
from datetime import datetime
from pathlib import Path
from uuid import UUID
from fastapi import (
APIRouter,
Depends,
Query,
status,
BackgroundTasks,
Request,
)
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from slowapi import Limiter
from slowapi.util import get_remote_address
from src.api.deps import get_db
from src.core.config import settings
from src.core.exceptions import NotFoundException, ValidationException
from src.repositories.scenario import scenario_repository
from src.repositories.report import report_repository
from src.schemas.report import (
ReportCreateRequest,
ReportResponse,
ReportList,
ReportStatus,
ReportStatusResponse,
ReportGenerateResponse,
ReportFormat,
)
from src.services.report_service import report_service
# Separate routers for different route groups
scenario_reports_router = APIRouter()
reports_router = APIRouter()
# In-memory store for report generation status (use Redis in production)
_report_status_store: dict[UUID, dict] = {}
# Rate limiter for downloads
limiter = Limiter(key_func=get_remote_address)
def _update_report_status(
report_id: UUID,
status: ReportStatus,
progress: int = 0,
message: str = None,
file_path: str = None,
file_size_bytes: int = None,
):
"""Update report generation status in store."""
_report_status_store[report_id] = {
"status": status,
"progress": progress,
"message": message,
"file_path": file_path,
"file_size_bytes": file_size_bytes,
"completed_at": datetime.now()
if status in [ReportStatus.COMPLETED, ReportStatus.FAILED]
else None,
}
async def _generate_report_task(
db: AsyncSession,
scenario_id: UUID,
report_id: UUID,
request_data: ReportCreateRequest,
):
"""Background task for report generation."""
try:
_update_report_status(
report_id,
ReportStatus.PROCESSING,
progress=10,
message="Compiling metrics...",
)
if request_data.format == ReportFormat.PDF:
_update_report_status(
report_id,
ReportStatus.PROCESSING,
progress=30,
message="Generating PDF...",
)
file_path = await report_service.generate_pdf(
db=db,
scenario_id=scenario_id,
report_id=report_id,
include_sections=[s.value for s in request_data.sections],
date_from=request_data.date_from,
date_to=request_data.date_to,
)
else: # CSV
_update_report_status(
report_id,
ReportStatus.PROCESSING,
progress=30,
message="Generating CSV...",
)
file_path = await report_service.generate_csv(
db=db,
scenario_id=scenario_id,
report_id=report_id,
include_logs=request_data.include_logs,
date_from=request_data.date_from,
date_to=request_data.date_to,
)
# Update report with file size
file_size = file_path.stat().st_size
await report_repository.update_file_size(db, report_id, file_size)
_update_report_status(
report_id,
ReportStatus.COMPLETED,
progress=100,
message="Report generation completed",
file_path=str(file_path),
file_size_bytes=file_size,
)
except Exception as e:
_update_report_status(
report_id,
ReportStatus.FAILED,
progress=0,
message=f"Report generation failed: {str(e)}",
)
# Scenario-scoped routes (prefixed with /scenarios)
@scenario_reports_router.post(
"/{scenario_id}/reports",
response_model=ReportGenerateResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def create_report(
scenario_id: UUID,
request_data: ReportCreateRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
"""Generate a report for a scenario.
Returns 202 Accepted with report_id. Use GET /reports/{id}/status to check progress.
"""
# Validate scenario exists
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
# Create report record
report_id = UUID(int=datetime.now().timestamp())
await report_repository.create(
db,
obj_in={
"id": report_id,
"scenario_id": scenario_id,
"format": request_data.format.value,
"file_path": str(
report_service._get_file_path(
scenario_id, report_id, request_data.format.value
)
),
"generated_by": "api",
"extra_data": {
"include_logs": request_data.include_logs,
"sections": [s.value for s in request_data.sections],
"date_from": request_data.date_from.isoformat()
if request_data.date_from
else None,
"date_to": request_data.date_to.isoformat()
if request_data.date_to
else None,
},
},
)
# Initialize status
_update_report_status(
report_id,
ReportStatus.PENDING,
progress=0,
message="Report queued for generation",
)
# Start background task
background_tasks.add_task(
_generate_report_task,
db,
scenario_id,
report_id,
request_data,
)
return ReportGenerateResponse(
report_id=report_id,
status=ReportStatus.PENDING,
message="Report generation started. Check status at /reports/{id}/status",
)
@scenario_reports_router.get(
"/{scenario_id}/reports",
response_model=ReportList,
)
async def list_reports(
scenario_id: UUID,
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
settings.default_page_size,
ge=1,
le=settings.max_page_size,
description="Items per page",
),
db: AsyncSession = Depends(get_db),
):
"""List all reports for a scenario."""
# Validate scenario exists
scenario = await scenario_repository.get(db, scenario_id)
if not scenario:
raise NotFoundException("Scenario")
skip = (page - 1) * page_size
reports = await report_repository.get_by_scenario(
db, scenario_id, skip=skip, limit=page_size
)
total = await report_repository.count_by_scenario(db, scenario_id)
return ReportList(
items=[ReportResponse.model_validate(r) for r in reports],
total=total,
page=page,
page_size=page_size,
)
# Report-scoped routes (prefixed with /reports)
@reports_router.get(
"/{report_id}/status",
response_model=ReportStatusResponse,
)
async def get_report_status(
report_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Get the status of a report generation."""
report = await report_repository.get(db, report_id)
if not report:
raise NotFoundException("Report")
# Check in-memory status store
status_info = _report_status_store.get(report_id, {})
return ReportStatusResponse(
report_id=report_id,
status=status_info.get("status", ReportStatus.PENDING),
progress=status_info.get("progress", 0),
message=status_info.get("message"),
file_path=status_info.get("file_path") or report.file_path,
file_size_bytes=status_info.get("file_size_bytes") or report.file_size_bytes,
created_at=report.created_at,
completed_at=status_info.get("completed_at"),
)
@reports_router.get(
"/{report_id}/download",
responses={
200: {
"description": "Report file download",
"content": {
"application/pdf": {},
"text/csv": {},
},
},
},
)
@limiter.limit(f"{settings.reports_rate_limit_per_minute}/minute")
async def download_report(
request: Request,
report_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Download a generated report file.
Rate limited to 10 downloads per minute.
"""
report = await report_repository.get(db, report_id)
if not report:
raise NotFoundException("Report")
# Check if report is completed
status_info = _report_status_store.get(report_id, {})
if status_info.get("status") != ReportStatus.COMPLETED:
raise ValidationException("Report is not ready for download yet")
file_path = Path(report.file_path)
if not file_path.exists():
raise NotFoundException("Report file")
# Determine media type
media_type = "application/pdf" if report.format == "pdf" else "text/csv"
extension = report.format
# Get scenario name for filename
scenario = await scenario_repository.get(db, report.scenario_id)
filename = f"{scenario.name}_{datetime.now().strftime('%Y-%m-%d')}.{extension}"
return FileResponse(
path=file_path,
media_type=media_type,
filename=filename,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
},
)
@reports_router.delete(
"/{report_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_report(
report_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Delete a report and its associated file."""
report = await report_repository.get(db, report_id)
if not report:
raise NotFoundException("Report")
# Delete file if it exists
file_path = Path(report.file_path)
if file_path.exists():
file_path.unlink()
# Delete from database
await report_repository.delete(db, id=report_id)
# Clean up status store
_report_status_store.pop(report_id, None)
return None