from __future__ import annotations from datetime import date, datetime from urllib.parse import urlencode 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 router = APIRouter(prefix="/reports", tags=["Reports"]) templates = Jinja2Templates(directory="templates") # Add custom Jinja2 filters def format_datetime(value): """Format a datetime object for display in templates.""" if not isinstance(value, datetime): return "" if value.tzinfo is None: # Assume UTC if no timezone from datetime import timezone value = value.replace(tzinfo=timezone.utc) # Format as readable date/time return value.strftime("%Y-%m-%d %H:%M UTC") def currency_display(value, currency_code): """Format a numeric value with currency symbol/code.""" if value is None: return "—" # Format the number if isinstance(value, (int, float)): formatted_value = f"{value:,.2f}" else: formatted_value = str(value) # Add currency code if currency_code: return f"{currency_code} {formatted_value}" return formatted_value def format_metric(value, metric_name, currency_code=None): """Format metric values appropriately based on metric type.""" if value is None: return "—" # For currency-related metrics, use currency formatting currency_metrics = {'npv', 'inflows', 'outflows', 'net', 'total_inflows', 'total_outflows', 'total_net'} if metric_name in currency_metrics and currency_code: return currency_display(value, currency_code) # For percentage metrics percentage_metrics = {'irr', 'payback_period'} if metric_name in percentage_metrics: if isinstance(value, (int, float)): return f"{value:.2f}%" return f"{value}%" # Default numeric formatting if isinstance(value, (int, float)): return f"{value:,.2f}" return str(value) def percentage_display(value): """Format a value as a percentage.""" if value is None: return "—" if isinstance(value, (int, float)): return f"{value:.2f}%" return f"{value}%" def period_display(value): """Format a period value (like payback period).""" if value is None: return "—" if isinstance(value, (int, float)): if value == int(value): return f"{int(value)} years" return f"{value:.1f} years" return str(value) templates.env.filters['format_datetime'] = format_datetime templates.env.filters['currency_display'] = currency_display templates.env.filters['format_metric'] = format_metric templates.env.filters['percentage_display'] = percentage_display templates.env.filters['period_display'] = period_display 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, )