"""Report API endpoints.""" from datetime import datetime from pathlib import Path from uuid import UUID, uuid4 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 = uuid4() 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