feat: Enhance currency handling and validation across scenarios

- 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.
This commit is contained in:
2025-11-11 18:29:59 +01:00
parent 032e6d2681
commit 795a9f99f4
50 changed files with 5110 additions and 81 deletions

512
routes/reports.py Normal file
View File

@@ -0,0 +1,512 @@
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,
)