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:
@@ -121,9 +121,32 @@ async def export_projects(
|
||||
) -> Response:
|
||||
project_repo = _ensure_repository(
|
||||
getattr(uow, "projects", None), "Project")
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
projects = project_repo.filtered_for_export(request.filters)
|
||||
except ValueError as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
dataset="projects",
|
||||
status="failure",
|
||||
export_format=request.format,
|
||||
row_count=0,
|
||||
filename=None,
|
||||
)
|
||||
logger.warning(
|
||||
"export.validation_failed",
|
||||
extra={
|
||||
"event": "export",
|
||||
"dataset": "projects",
|
||||
"status": "validation_failed",
|
||||
"format": request.format.value,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
@@ -145,7 +168,6 @@ async def export_projects(
|
||||
raise exc
|
||||
|
||||
filename = f"projects-{_timestamp_suffix()}"
|
||||
start = time.perf_counter()
|
||||
|
||||
if request.format == ExportFormat.CSV:
|
||||
stream = stream_projects_to_csv(projects)
|
||||
@@ -226,10 +248,33 @@ async def export_scenarios(
|
||||
) -> Response:
|
||||
scenario_repo = _ensure_repository(
|
||||
getattr(uow, "scenarios", None), "Scenario")
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
start = time.perf_counter()
|
||||
scenarios = scenario_repo.filtered_for_export(
|
||||
request.filters, include_project=True)
|
||||
except ValueError as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
dataset="scenarios",
|
||||
status="failure",
|
||||
export_format=request.format,
|
||||
row_count=0,
|
||||
filename=None,
|
||||
)
|
||||
logger.warning(
|
||||
"export.validation_failed",
|
||||
extra={
|
||||
"event": "export",
|
||||
"dataset": "scenarios",
|
||||
"status": "validation_failed",
|
||||
"format": request.format.value,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
_record_export_audit(
|
||||
uow=uow,
|
||||
@@ -251,7 +296,6 @@ async def export_scenarios(
|
||||
raise exc
|
||||
|
||||
filename = f"scenarios-{_timestamp_suffix()}"
|
||||
start = time.perf_counter()
|
||||
|
||||
if request.format == ExportFormat.CSV:
|
||||
stream = stream_scenarios_to_csv(scenarios)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_project_resource,
|
||||
@@ -15,6 +16,7 @@ from dependencies import (
|
||||
from models import MiningOperationType, Project, ScenarioStatus, User
|
||||
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
from services.exceptions import EntityConflictError, EntityNotFoundError
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||
@@ -54,6 +56,7 @@ def create_project(
|
||||
payload: ProjectCreate,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ProjectRead:
|
||||
project = Project(**payload.model_dump())
|
||||
try:
|
||||
@@ -62,6 +65,9 @@ def create_project(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
default_settings = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata).settings
|
||||
uow.set_project_pricing_settings(created, default_settings)
|
||||
return _to_read_model(created)
|
||||
|
||||
|
||||
@@ -122,6 +128,7 @@ def create_project_submit(
|
||||
operation_type: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
):
|
||||
def _normalise(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
@@ -152,7 +159,7 @@ def create_project_submit(
|
||||
description=_normalise(description),
|
||||
)
|
||||
try:
|
||||
_require_project_repo(uow).create(project)
|
||||
created = _require_project_repo(uow).create(project)
|
||||
except EntityConflictError as exc:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -167,6 +174,10 @@ def create_project_submit(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
default_settings = uow.ensure_default_pricing_settings(
|
||||
metadata=metadata).settings
|
||||
uow.set_project_pricing_settings(created, default_settings)
|
||||
|
||||
return RedirectResponse(
|
||||
request.url_for("projects.project_list_page"),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
|
||||
512
routes/reports.py
Normal file
512
routes/reports.py
Normal 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,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
@@ -8,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_roles,
|
||||
@@ -21,11 +23,13 @@ from schemas.scenario import (
|
||||
ScenarioRead,
|
||||
ScenarioUpdate,
|
||||
)
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
from services.exceptions import (
|
||||
EntityConflictError,
|
||||
EntityNotFoundError,
|
||||
ScenarioValidationError,
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(tags=["Scenarios"])
|
||||
@@ -143,6 +147,7 @@ def create_scenario_for_project(
|
||||
payload: ScenarioCreate,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ScenarioRead:
|
||||
project_repo = _require_project_repo(uow)
|
||||
scenario_repo = _require_scenario_repo(uow)
|
||||
@@ -152,7 +157,10 @@ def create_scenario_for_project(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
scenario = Scenario(project_id=project_id, **payload.model_dump())
|
||||
scenario_data = payload.model_dump()
|
||||
if not scenario_data.get("currency") and metadata.default_currency:
|
||||
scenario_data["currency"] = metadata.default_currency
|
||||
scenario = Scenario(project_id=project_id, **scenario_data)
|
||||
|
||||
try:
|
||||
created = scenario_repo.create(scenario)
|
||||
@@ -219,6 +227,33 @@ def _parse_discount_rate(value: str | None) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _scenario_form_state(
|
||||
*,
|
||||
project_id: int,
|
||||
name: str,
|
||||
description: str | None,
|
||||
status: ScenarioStatus,
|
||||
start_date: date | None,
|
||||
end_date: date | None,
|
||||
discount_rate: float | None,
|
||||
currency: str | None,
|
||||
primary_resource: ResourceType | None,
|
||||
scenario_id: int | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=scenario_id,
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
description=description,
|
||||
status=status,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
discount_rate=discount_rate,
|
||||
currency=currency,
|
||||
primary_resource=primary_resource,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/new",
|
||||
response_class=HTMLResponse,
|
||||
@@ -230,6 +265,7 @@ def create_scenario_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
project = _require_project_repo(uow).get(project_id)
|
||||
@@ -252,6 +288,7 @@ def create_scenario_form(
|
||||
"cancel_url": request.url_for(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -274,6 +311,7 @@ def create_scenario_submit(
|
||||
currency: str | None = Form(None),
|
||||
primary_resource: str | None = Form(None),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
):
|
||||
project_repo = _require_project_repo(uow)
|
||||
scenario_repo = _require_scenario_repo(uow)
|
||||
@@ -296,17 +334,59 @@ def create_scenario_submit(
|
||||
except ValueError:
|
||||
resource_enum = None
|
||||
|
||||
currency_value = _normalise(currency)
|
||||
currency_value = currency_value.upper() if currency_value else None
|
||||
name_value = name.strip()
|
||||
description_value = _normalise(description)
|
||||
start_date_value = _parse_date(start_date)
|
||||
end_date_value = _parse_date(end_date)
|
||||
discount_rate_value = _parse_discount_rate(discount_rate)
|
||||
currency_input = _normalise(currency)
|
||||
effective_currency = currency_input or metadata.default_currency
|
||||
|
||||
try:
|
||||
currency_value = (
|
||||
normalise_currency(effective_currency)
|
||||
if effective_currency else None
|
||||
)
|
||||
except CurrencyValidationError as exc:
|
||||
form_state = _scenario_form_state(
|
||||
project_id=project_id,
|
||||
name=name_value,
|
||||
description=description_value,
|
||||
status=status_enum,
|
||||
start_date=start_date_value,
|
||||
end_date=end_date_value,
|
||||
discount_rate=discount_rate_value,
|
||||
currency=currency_input or metadata.default_currency,
|
||||
primary_resource=resource_enum,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"scenarios/form.html",
|
||||
{
|
||||
"project": project,
|
||||
"scenario": form_state,
|
||||
"scenario_statuses": _scenario_status_choices(),
|
||||
"resource_types": _resource_type_choices(),
|
||||
"form_action": request.url_for(
|
||||
"scenarios.create_scenario_submit", project_id=project_id
|
||||
),
|
||||
"cancel_url": request.url_for(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": str(exc),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
scenario = Scenario(
|
||||
project_id=project_id,
|
||||
name=name.strip(),
|
||||
description=_normalise(description),
|
||||
name=name_value,
|
||||
description=description_value,
|
||||
status=status_enum,
|
||||
start_date=_parse_date(start_date),
|
||||
end_date=_parse_date(end_date),
|
||||
discount_rate=_parse_discount_rate(discount_rate),
|
||||
start_date=start_date_value,
|
||||
end_date=end_date_value,
|
||||
discount_rate=discount_rate_value,
|
||||
currency=currency_value,
|
||||
primary_resource=resource_enum,
|
||||
)
|
||||
@@ -329,6 +409,7 @@ def create_scenario_submit(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": "Scenario could not be created.",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
@@ -392,6 +473,7 @@ def edit_scenario_form(
|
||||
require_scenario_resource(require_manage=True)
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> HTMLResponse:
|
||||
project = _require_project_repo(uow).get(scenario.project_id)
|
||||
|
||||
@@ -409,6 +491,7 @@ def edit_scenario_form(
|
||||
"cancel_url": request.url_for(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -432,22 +515,17 @@ def edit_scenario_submit(
|
||||
currency: str | None = Form(None),
|
||||
primary_resource: str | None = Form(None),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
):
|
||||
project = _require_project_repo(uow).get(scenario.project_id)
|
||||
|
||||
scenario.name = name.strip()
|
||||
scenario.description = _normalise(description)
|
||||
name_value = name.strip()
|
||||
description_value = _normalise(description)
|
||||
try:
|
||||
scenario.status = ScenarioStatus(status_value)
|
||||
except ValueError:
|
||||
scenario.status = ScenarioStatus.DRAFT
|
||||
scenario.start_date = _parse_date(start_date)
|
||||
scenario.end_date = _parse_date(end_date)
|
||||
|
||||
scenario.discount_rate = _parse_discount_rate(discount_rate)
|
||||
|
||||
currency_value = _normalise(currency)
|
||||
scenario.currency = currency_value.upper() if currency_value else None
|
||||
status_enum = scenario.status
|
||||
|
||||
resource_enum = None
|
||||
if primary_resource:
|
||||
@@ -455,6 +533,53 @@ def edit_scenario_submit(
|
||||
resource_enum = ResourceType(primary_resource)
|
||||
except ValueError:
|
||||
resource_enum = None
|
||||
|
||||
start_date_value = _parse_date(start_date)
|
||||
end_date_value = _parse_date(end_date)
|
||||
discount_rate_value = _parse_discount_rate(discount_rate)
|
||||
currency_input = _normalise(currency)
|
||||
|
||||
try:
|
||||
currency_value = normalise_currency(currency_input)
|
||||
except CurrencyValidationError as exc:
|
||||
form_state = _scenario_form_state(
|
||||
scenario_id=scenario.id,
|
||||
project_id=scenario.project_id,
|
||||
name=name_value,
|
||||
description=description_value,
|
||||
status=status_enum,
|
||||
start_date=start_date_value,
|
||||
end_date=end_date_value,
|
||||
discount_rate=discount_rate_value,
|
||||
currency=currency_input,
|
||||
primary_resource=resource_enum,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"scenarios/form.html",
|
||||
{
|
||||
"project": project,
|
||||
"scenario": form_state,
|
||||
"scenario_statuses": _scenario_status_choices(),
|
||||
"resource_types": _resource_type_choices(),
|
||||
"form_action": request.url_for(
|
||||
"scenarios.edit_scenario_submit", scenario_id=scenario.id
|
||||
),
|
||||
"cancel_url": request.url_for(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
),
|
||||
"error": str(exc),
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
scenario.name = name_value
|
||||
scenario.description = description_value
|
||||
scenario.start_date = start_date_value
|
||||
scenario.end_date = end_date_value
|
||||
scenario.discount_rate = discount_rate_value
|
||||
scenario.currency = currency_value
|
||||
scenario.primary_resource = resource_enum
|
||||
|
||||
uow.flush()
|
||||
|
||||
Reference in New Issue
Block a user