435 lines
14 KiB
Python
435 lines
14 KiB
Python
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 dependencies import (
|
|
get_unit_of_work,
|
|
require_any_role,
|
|
require_any_role_html,
|
|
require_project_resource,
|
|
require_scenario_resource,
|
|
require_project_resource_html,
|
|
require_scenario_resource_html,
|
|
)
|
|
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 create_templates
|
|
|
|
router = APIRouter(prefix="/reports", tags=["Reports"])
|
|
templates = create_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_CONTENT,
|
|
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_CONTENT,
|
|
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_CONTENT,
|
|
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_CONTENT,
|
|
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_CONTENT,
|
|
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_html()),
|
|
_: User = Depends(require_any_role_html(*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_CONTENT,
|
|
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_html()),
|
|
_: User = Depends(require_any_role_html(*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_CONTENT,
|
|
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_CONTENT,
|
|
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_CONTENT,
|
|
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,
|
|
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
|
scenario: Scenario = Depends(
|
|
require_scenario_resource_html()
|
|
),
|
|
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_CONTENT,
|
|
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,
|
|
)
|