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

View File

@@ -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()