from __future__ import annotations from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.encoders import jsonable_encoder from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from dependencies import ( get_unit_of_work, require_any_role, require_project_resource, require_scenario_resource, ) from models import Project, Scenario, User from services.exceptions import EntityNotFoundError, ScenarioValidationError from services.reporting import ( DEFAULT_ITERATIONS, IncludeOptions, ReportFilters, ReportingService, parse_include_tokens, validate_percentiles, ) from services.unit_of_work import UnitOfWork from routes.template_filters import register_common_filters router = APIRouter(prefix="/reports", tags=["Reports"]) templates = Jinja2Templates(directory="templates") register_common_filters(templates) READ_ROLES = ("viewer", "analyst", "project_manager", "admin") MANAGE_ROLES = ("project_manager", "admin") @router.get("/projects/{project_id}", name="reports.project_summary") def project_summary_report( project: Project = Depends(require_project_resource()), _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), include: str | None = Query( None, description="Comma-separated include tokens (distribution,samples,all).", ), scenario_ids: list[int] | None = Query( None, alias="scenario_ids", description="Repeatable scenario identifier filter.", ), start_date: date | None = Query( None, description="Filter scenarios starting on or after this date.", ), end_date: date | None = Query( None, description="Filter scenarios ending on or before this date.", ), fmt: str = Query( "json", alias="format", description="Response format (json only for this endpoint).", ), iterations: int | None = Query( None, gt=0, description="Override Monte Carlo iteration count when distribution is included.", ), percentiles: list[float] | None = Query( None, description="Percentiles (0-100) for Monte Carlo summaries when included.", ), ) -> dict[str, object]: if fmt.lower() != "json": raise HTTPException( status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="Only JSON responses are supported; use the HTML endpoint for templates.", ) include_options = parse_include_tokens(include) try: percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc scenario_filter = ReportFilters( scenario_ids=set(scenario_ids) if scenario_ids else None, start_date=start_date, end_date=end_date, ) service = ReportingService(uow) report = service.project_summary( project, filters=scenario_filter, include=include_options, iterations=iterations or DEFAULT_ITERATIONS, percentiles=percentile_values, ) return jsonable_encoder(report) @router.get( "/projects/{project_id}/scenarios/compare", name="reports.project_scenario_comparison", ) def project_scenario_comparison_report( project: Project = Depends(require_project_resource()), _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), scenario_ids: list[int] = Query( ..., alias="scenario_ids", description="Repeatable scenario identifier."), include: str | None = Query( None, description="Comma-separated include tokens (distribution,samples,all).", ), fmt: str = Query( "json", alias="format", description="Response format (json only for this endpoint).", ), iterations: int | None = Query( None, gt=0, description="Override Monte Carlo iteration count when distribution is included.", ), percentiles: list[float] | None = Query( None, description="Percentiles (0-100) for Monte Carlo summaries when included.", ), ) -> dict[str, object]: unique_ids = list(dict.fromkeys(scenario_ids)) if len(unique_ids) < 2: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="At least two unique scenario_ids must be provided for comparison.", ) if fmt.lower() != "json": raise HTTPException( status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="Only JSON responses are supported; use the HTML endpoint for templates.", ) include_options = parse_include_tokens(include) try: percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc try: scenarios = uow.validate_scenarios_for_comparison(unique_ids) except ScenarioValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={ "code": exc.code, "message": exc.message, "scenario_ids": list(exc.scenario_ids or []), }, ) from exc except EntityNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc), ) from exc if any(scenario.project_id != project.id for scenario in scenarios): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="One or more scenarios are not associated with this project.", ) service = ReportingService(uow) report = service.scenario_comparison( project, scenarios, include=include_options, iterations=iterations or DEFAULT_ITERATIONS, percentiles=percentile_values, ) return jsonable_encoder(report) @router.get( "/scenarios/{scenario_id}/distribution", name="reports.scenario_distribution", ) def scenario_distribution_report( scenario: Scenario = Depends(require_scenario_resource()), _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), include: str | None = Query( None, description="Comma-separated include tokens (samples,all).", ), fmt: str = Query( "json", alias="format", description="Response format (json only for this endpoint).", ), iterations: int | None = Query( None, gt=0, description="Override Monte Carlo iteration count (default applies otherwise).", ), percentiles: list[float] | None = Query( None, description="Percentiles (0-100) for Monte Carlo summaries.", ), ) -> dict[str, object]: if fmt.lower() != "json": raise HTTPException( status_code=status.HTTP_406_NOT_ACCEPTABLE, detail="Only JSON responses are supported; use the HTML endpoint for templates.", ) requested = parse_include_tokens(include) include_options = IncludeOptions( distribution=True, samples=requested.samples) try: percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc service = ReportingService(uow) report = service.scenario_distribution( scenario, include=include_options, iterations=iterations or DEFAULT_ITERATIONS, percentiles=percentile_values, ) return jsonable_encoder(report) @router.get( "/projects/{project_id}/ui", response_class=HTMLResponse, include_in_schema=False, name="reports.project_summary_page", ) def project_summary_page( request: Request, project: Project = Depends(require_project_resource()), _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), include: str | None = Query( None, description="Comma-separated include tokens (distribution,samples,all).", ), scenario_ids: list[int] | None = Query( None, alias="scenario_ids", description="Repeatable scenario identifier filter.", ), start_date: date | None = Query( None, description="Filter scenarios starting on or after this date.", ), end_date: date | None = Query( None, description="Filter scenarios ending on or before this date.", ), iterations: int | None = Query( None, gt=0, description="Override Monte Carlo iteration count when distribution is included.", ), percentiles: list[float] | None = Query( None, description="Percentiles (0-100) for Monte Carlo summaries when included.", ), ) -> HTMLResponse: include_options = parse_include_tokens(include) try: percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc scenario_filter = ReportFilters( scenario_ids=set(scenario_ids) if scenario_ids else None, start_date=start_date, end_date=end_date, ) service = ReportingService(uow) context = service.build_project_summary_context( project, scenario_filter, include_options, iterations or DEFAULT_ITERATIONS, percentile_values, request ) return templates.TemplateResponse( request, "reports/project_summary.html", context, ) @router.get( "/projects/{project_id}/scenarios/compare/ui", response_class=HTMLResponse, include_in_schema=False, name="reports.project_scenario_comparison_page", ) def project_scenario_comparison_page( request: Request, project: Project = Depends(require_project_resource()), _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), scenario_ids: list[int] = Query( ..., alias="scenario_ids", description="Repeatable scenario identifier."), include: str | None = Query( None, description="Comma-separated include tokens (distribution,samples,all).", ), iterations: int | None = Query( None, gt=0, description="Override Monte Carlo iteration count when distribution is included.", ), percentiles: list[float] | None = Query( None, description="Percentiles (0-100) for Monte Carlo summaries when included.", ), ) -> HTMLResponse: unique_ids = list(dict.fromkeys(scenario_ids)) if len(unique_ids) < 2: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="At least two unique scenario_ids must be provided for comparison.", ) include_options = parse_include_tokens(include) try: percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc try: scenarios = uow.validate_scenarios_for_comparison(unique_ids) except ScenarioValidationError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={ "code": exc.code, "message": exc.message, "scenario_ids": list(exc.scenario_ids or []), }, ) from exc except EntityNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc), ) from exc if any(scenario.project_id != project.id for scenario in scenarios): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="One or more scenarios are not associated with this project.", ) service = ReportingService(uow) context = service.build_scenario_comparison_context( project, scenarios, include_options, iterations or DEFAULT_ITERATIONS, percentile_values, request ) return templates.TemplateResponse( request, "reports/scenario_comparison.html", context, ) @router.get( "/scenarios/{scenario_id}/distribution/ui", response_class=HTMLResponse, include_in_schema=False, name="reports.scenario_distribution_page", ) def scenario_distribution_page( request: Request, scenario: Scenario = Depends(require_scenario_resource()), _: User = Depends(require_any_role(*READ_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), include: str | None = Query( None, description="Comma-separated include tokens (samples,all).", ), iterations: int | None = Query( None, gt=0, description="Override Monte Carlo iteration count (default applies otherwise).", ), percentiles: list[float] | None = Query( None, description="Percentiles (0-100) for Monte Carlo summaries.", ), ) -> HTMLResponse: requested = parse_include_tokens(include) include_options = IncludeOptions( distribution=True, samples=requested.samples) try: percentile_values = validate_percentiles(percentiles) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc), ) from exc service = ReportingService(uow) context = service.build_scenario_distribution_context( scenario, include_options, iterations or DEFAULT_ITERATIONS, percentile_values, request ) return templates.TemplateResponse( request, "reports/scenario_distribution.html", context, )