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

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

View File

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

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