- Updated form template to prefill currency input with default value and added help text for clarity. - Modified integration tests to assert more descriptive error messages for invalid currency codes. - Introduced new tests for currency normalization and validation in various scenarios, including imports and exports. - Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly. - Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly. - Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults. - Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios.
513 lines
16 KiB
Python
513 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
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_roles,
|
|
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")
|
|
|
|
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)
|
|
report = service.project_summary(
|
|
project,
|
|
filters=scenario_filter,
|
|
include=include_options,
|
|
iterations=iterations or DEFAULT_ITERATIONS,
|
|
percentiles=percentile_values,
|
|
)
|
|
context = {
|
|
"request": request,
|
|
"project": report["project"],
|
|
"scenario_count": report["scenario_count"],
|
|
"aggregates": report["aggregates"],
|
|
"scenarios": report["scenarios"],
|
|
"filters": report["filters"],
|
|
"include_options": include_options,
|
|
"iterations": iterations or DEFAULT_ITERATIONS,
|
|
"percentiles": percentile_values,
|
|
"title": f"Project Summary · {project.name}",
|
|
"subtitle": "Aggregated financial and simulation insights across scenarios.",
|
|
"actions": [
|
|
{
|
|
"href": request.url_for(
|
|
"reports.project_summary",
|
|
project_id=project.id,
|
|
),
|
|
"label": "Download JSON",
|
|
}
|
|
],
|
|
}
|
|
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)
|
|
report = service.scenario_comparison(
|
|
project,
|
|
scenarios,
|
|
include=include_options,
|
|
iterations=iterations or DEFAULT_ITERATIONS,
|
|
percentiles=percentile_values,
|
|
)
|
|
comparison_json_url = request.url_for(
|
|
"reports.project_scenario_comparison",
|
|
project_id=project.id,
|
|
)
|
|
comparison_query = urlencode(
|
|
[("scenario_ids", str(identifier)) for identifier in unique_ids]
|
|
)
|
|
if comparison_query:
|
|
comparison_json_url = f"{comparison_json_url}?{comparison_query}"
|
|
|
|
context = {
|
|
"request": request,
|
|
"project": report["project"],
|
|
"scenarios": report["scenarios"],
|
|
"comparison": report["comparison"],
|
|
"include_options": include_options,
|
|
"iterations": iterations or DEFAULT_ITERATIONS,
|
|
"percentiles": percentile_values,
|
|
"title": f"Scenario Comparison · {project.name}",
|
|
"subtitle": "Evaluate deterministic metrics and Monte Carlo trends side by side.",
|
|
"actions": [
|
|
{
|
|
"href": comparison_json_url,
|
|
"label": "Download JSON",
|
|
}
|
|
],
|
|
}
|
|
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)
|
|
report = service.scenario_distribution(
|
|
scenario,
|
|
include=include_options,
|
|
iterations=iterations or DEFAULT_ITERATIONS,
|
|
percentiles=percentile_values,
|
|
)
|
|
context = {
|
|
"request": request,
|
|
"scenario": report["scenario"],
|
|
"summary": report["summary"],
|
|
"metrics": report["metrics"],
|
|
"monte_carlo": report["monte_carlo"],
|
|
"include_options": include_options,
|
|
"iterations": iterations or DEFAULT_ITERATIONS,
|
|
"percentiles": percentile_values,
|
|
"title": f"Scenario Distribution · {scenario.name}",
|
|
"subtitle": "Deterministic and simulated distributions for a single scenario.",
|
|
"actions": [
|
|
{
|
|
"href": request.url_for(
|
|
"reports.scenario_distribution",
|
|
scenario_id=scenario.id,
|
|
),
|
|
"label": "Download JSON",
|
|
}
|
|
],
|
|
}
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"reports/scenario_distribution.html",
|
|
context,
|
|
)
|