feat: implement v0.4.0 - Reports, Charts, Comparison, Dark Mode, E2E Testing
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:
@@ -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
349
src/api/v1/reports.py
Normal 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
|
||||
Reference in New Issue
Block a user