285a748d6a
- Change generic 'frontend' title to 'mockupAWS - AWS Cost Simulator' - Resolves frontend branding issue identified in testing
350 lines
9.9 KiB
Python
350 lines
9.9 KiB
Python
"""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
|