feat: add scenarios list page with metrics and quick actions
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped

- Introduced a new template for listing scenarios associated with a project.
- Added metrics for total, active, draft, and archived scenarios.
- Implemented quick actions for creating new scenarios and reviewing project overview.
- Enhanced navigation with breadcrumbs for better user experience.

refactor: update Opex and Profitability templates for consistency

- Changed titles and button labels for clarity in Opex and Profitability templates.
- Updated form IDs and action URLs for better alignment with new naming conventions.
- Improved navigation links to include scenario and project overviews.

test: add integration tests for Opex calculations

- Created new tests for Opex calculation HTML and JSON flows.
- Validated successful calculations and ensured correct data persistence.
- Implemented tests for currency mismatch and unsupported frequency scenarios.

test: enhance project and scenario route tests

- Added tests to verify scenario list rendering and calculator shortcuts.
- Ensured scenario detail pages link back to the portfolio correctly.
- Validated project detail pages show associated scenarios accurately.
This commit is contained in:
2025-11-13 16:21:36 +01:00
parent 4f00bf0d3c
commit 522b1e4105
54 changed files with 3419 additions and 700 deletions

View File

@@ -6,20 +6,25 @@ from decimal import Decimal
from typing import Any, Sequence
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
from pydantic import ValidationError
from starlette.datastructures import FormData
from starlette.routing import NoMatchFound
from dependencies import get_pricing_metadata, get_unit_of_work, require_authenticated_user
from dependencies import (
get_pricing_metadata,
get_unit_of_work,
require_authenticated_user,
require_authenticated_user_html,
)
from models import (
Project,
ProjectCapexSnapshot,
ProjectProcessingOpexSnapshot,
ProjectOpexSnapshot,
ProjectProfitability,
Scenario,
ScenarioCapexSnapshot,
ScenarioProcessingOpexSnapshot,
ScenarioOpexSnapshot,
ScenarioProfitability,
User,
)
@@ -29,17 +34,17 @@ from schemas.calculations import (
CapexCalculationResult,
CapexComponentInput,
CapexParameters,
ProcessingOpexCalculationRequest,
ProcessingOpexCalculationResult,
ProcessingOpexComponentInput,
ProcessingOpexOptions,
ProcessingOpexParameters,
OpexCalculationRequest,
OpexCalculationResult,
OpexComponentInput,
OpexOptions,
OpexParameters,
ProfitabilityCalculationRequest,
ProfitabilityCalculationResult,
)
from services.calculations import (
calculate_initial_capex,
calculate_processing_opex,
calculate_opex,
calculate_profitability,
)
from services.exceptions import (
@@ -50,11 +55,10 @@ from services.exceptions import (
)
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
from routes.template_filters import register_common_filters
from routes.template_filters import create_templates
router = APIRouter(prefix="/calculations", tags=["Calculations"])
templates = Jinja2Templates(directory="templates")
register_common_filters(templates)
templates = create_templates()
_SUPPORTED_METALS: tuple[dict[str, str], ...] = (
{"value": "copper", "label": "Copper"},
@@ -90,7 +94,7 @@ _OPEX_FREQUENCY_OPTIONS: tuple[dict[str, str], ...] = (
_DEFAULT_OPEX_HORIZON_YEARS = 5
_PROCESSING_OPEX_TEMPLATE = "scenarios/opex.html"
_opex_TEMPLATE = "scenarios/opex.html"
def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
@@ -196,7 +200,7 @@ def _build_default_form_data(
"reference_price": "",
"treatment_charge": "",
"smelting_charge": "",
"processing_opex": "",
"opex": "",
"moisture_pct": "",
"moisture_threshold_pct": moisture_threshold_default,
"moisture_penalty_per_pct": moisture_penalty_default,
@@ -204,7 +208,7 @@ def _build_default_form_data(
"fx_rate": 1.0,
"currency_code": currency,
"impurities": None,
"initial_capex": "",
"capex": "",
"sustaining_capex": "",
"discount_rate": discount_rate,
"periods": _DEFAULT_EVALUATION_PERIODS,
@@ -380,6 +384,12 @@ def _prepare_capex_context(
currency_code = parameters.get(
"currency_code") or defaults["currency_code"]
navigation = _resolve_navigation_links(
request,
project=project,
scenario=scenario,
)
return {
"request": request,
"project": project,
@@ -396,14 +406,14 @@ def _prepare_capex_context(
"notices": notices or [],
"component_errors": component_errors or [],
"component_notices": component_notices or [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"form_action": str(request.url),
"csrf_token": None,
**navigation,
}
def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
if isinstance(component, ProcessingOpexComponentInput):
if isinstance(component, OpexComponentInput):
raw = component.model_dump()
elif isinstance(component, dict):
raw = dict(component)
@@ -436,7 +446,7 @@ def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
if isinstance(parameters, ProcessingOpexParameters):
if isinstance(parameters, OpexParameters):
raw = parameters.model_dump()
elif isinstance(parameters, dict):
raw = dict(parameters)
@@ -455,7 +465,7 @@ def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
def _serialise_opex_options(options: Any) -> dict[str, Any]:
if isinstance(options, ProcessingOpexOptions):
if isinstance(options, OpexOptions):
raw = options.model_dump()
elif isinstance(options, dict):
raw = dict(options)
@@ -511,7 +521,7 @@ def _prepare_opex_context(
project: Project | None,
scenario: Scenario | None,
form_data: dict[str, Any] | None = None,
result: ProcessingOpexCalculationResult | None = None,
result: OpexCalculationResult | None = None,
errors: list[str] | None = None,
notices: list[str] | None = None,
component_errors: list[str] | None = None,
@@ -544,6 +554,12 @@ def _prepare_opex_context(
currency_code = parameters.get(
"currency_code") or defaults["currency_code"]
navigation = _resolve_navigation_links(
request,
project=project,
scenario=scenario,
)
return {
"request": request,
"project": project,
@@ -561,9 +577,9 @@ def _prepare_opex_context(
"notices": notices or [],
"component_errors": component_errors or [],
"component_notices": component_notices or [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"form_action": str(request.url),
"csrf_token": None,
**navigation,
}
@@ -758,6 +774,76 @@ async def _extract_capex_payload(request: Request) -> dict[str, Any]:
return _capex_form_to_payload(form)
def _resolve_navigation_links(
request: Request,
*,
project: Project | None,
scenario: Scenario | None,
) -> dict[str, str | None]:
project_url: str | None = None
scenario_url: str | None = None
scenario_portfolio_url: str | None = None
candidate_project = project
if scenario is not None and getattr(scenario, "id", None) is not None:
try:
scenario_url = str(
request.url_for(
"scenarios.view_scenario", scenario_id=scenario.id
)
)
except NoMatchFound:
scenario_url = None
try:
scenario_portfolio_url = str(
request.url_for(
"scenarios.project_scenario_list",
project_id=scenario.project_id,
)
)
except NoMatchFound:
scenario_portfolio_url = None
if candidate_project is None:
candidate_project = getattr(scenario, "project", None)
if candidate_project is not None and getattr(candidate_project, "id", None) is not None:
try:
project_url = str(
request.url_for(
"projects.view_project", project_id=candidate_project.id
)
)
except NoMatchFound:
project_url = None
if scenario_portfolio_url is None:
try:
scenario_portfolio_url = str(
request.url_for(
"scenarios.project_scenario_list",
project_id=candidate_project.id,
)
)
except NoMatchFound:
scenario_portfolio_url = None
cancel_url = scenario_url or project_url or request.headers.get("Referer")
if cancel_url is None:
try:
cancel_url = str(request.url_for("projects.project_list_page"))
except NoMatchFound:
cancel_url = "/"
return {
"project_url": project_url,
"scenario_url": scenario_url,
"scenario_portfolio_url": scenario_portfolio_url,
"cancel_url": cancel_url,
}
def _prepare_default_context(
request: Request,
*,
@@ -781,6 +867,12 @@ def _prepare_default_context(
allow_empty_override=allow_empty_override,
)
navigation = _resolve_navigation_links(
request,
project=project,
scenario=scenario,
)
return {
"request": request,
"project": project,
@@ -792,10 +884,10 @@ def _prepare_default_context(
"result": result,
"errors": [],
"notices": [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"form_action": str(request.url),
"csrf_token": None,
"default_periods": _DEFAULT_EVALUATION_PERIODS,
**navigation,
}
@@ -920,11 +1012,11 @@ def _persist_profitability_snapshots(
created_by_id = getattr(user, "id", None)
revenue_total = float(result.pricing.net_revenue)
processing_total = float(result.costs.processing_opex_total)
processing_total = float(result.costs.opex_total)
sustaining_total = float(result.costs.sustaining_capex_total)
initial_capex = float(result.costs.initial_capex)
capex = float(result.costs.capex)
net_cash_flow_total = revenue_total - (
processing_total + sustaining_total + initial_capex
processing_total + sustaining_total + capex
)
npv_value = (
@@ -964,9 +1056,9 @@ def _persist_profitability_snapshots(
payback_period_years=payback_value,
margin_pct=margin_value,
revenue_total=revenue_total,
processing_opex_total=processing_total,
opex_total=processing_total,
sustaining_capex_total=sustaining_total,
initial_capex=initial_capex,
capex=capex,
net_cash_flow_total=net_cash_flow_total,
payload=payload,
)
@@ -983,9 +1075,9 @@ def _persist_profitability_snapshots(
payback_period_years=payback_value,
margin_pct=margin_value,
revenue_total=revenue_total,
processing_opex_total=processing_total,
opex_total=processing_total,
sustaining_capex_total=sustaining_total,
initial_capex=initial_capex,
capex=capex,
net_cash_flow_total=net_cash_flow_total,
payload=payload,
)
@@ -1067,7 +1159,7 @@ def _should_persist_opex(
*,
project: Project | None,
scenario: Scenario | None,
request_model: ProcessingOpexCalculationRequest,
request_model: OpexCalculationRequest,
) -> bool:
persist_requested = bool(
getattr(request_model, "options", None)
@@ -1082,8 +1174,8 @@ def _persist_opex_snapshots(
project: Project | None,
scenario: Scenario | None,
user: User | None,
request_model: ProcessingOpexCalculationRequest,
result: ProcessingOpexCalculationResult,
request_model: OpexCalculationRequest,
result: OpexCalculationResult,
) -> None:
if not _should_persist_opex(
project=project,
@@ -1130,11 +1222,11 @@ def _persist_opex_snapshots(
"result": result.model_dump(),
}
if scenario and uow.scenario_processing_opex:
scenario_snapshot = ScenarioProcessingOpexSnapshot(
if scenario and uow.scenario_opex:
scenario_snapshot = ScenarioOpexSnapshot(
scenario_id=scenario.id,
created_by_id=created_by_id,
calculation_source="calculations.processing_opex",
calculation_source="calculations.opex",
currency_code=result.currency,
overall_annual=overall_annual,
escalated_total=escalated_total,
@@ -1145,13 +1237,13 @@ def _persist_opex_snapshots(
component_count=component_count,
payload=payload,
)
uow.scenario_processing_opex.create(scenario_snapshot)
uow.scenario_opex.create(scenario_snapshot)
if project and uow.project_processing_opex:
project_snapshot = ProjectProcessingOpexSnapshot(
if project and uow.project_opex:
project_snapshot = ProjectOpexSnapshot(
project_id=project.id,
created_by_id=created_by_id,
calculation_source="calculations.processing_opex",
calculation_source="calculations.opex",
currency_code=result.currency,
overall_annual=overall_annual,
escalated_total=escalated_total,
@@ -1162,24 +1254,24 @@ def _persist_opex_snapshots(
component_count=component_count,
payload=payload,
)
uow.project_processing_opex.create(project_snapshot)
uow.project_opex.create(project_snapshot)
@router.get(
"/processing-opex",
"/opex",
response_class=HTMLResponse,
name="calculations.processing_opex_form",
name="calculations.opex_form",
)
def processing_opex_form(
def opex_form(
request: Request,
_: User = Depends(require_authenticated_user),
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the processing opex planner with default context."""
"""Render the opex planner with default context."""
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
@@ -1189,14 +1281,14 @@ def processing_opex_form(
project=project,
scenario=scenario,
)
return templates.TemplateResponse(_PROCESSING_OPEX_TEMPLATE, context)
return templates.TemplateResponse(_opex_TEMPLATE, context)
@router.post(
"/processing-opex",
name="calculations.processing_opex_submit",
"/opex",
name="calculations.opex_submit",
)
async def processing_opex_submit(
async def opex_submit(
request: Request,
current_user: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
@@ -1205,16 +1297,16 @@ async def processing_opex_submit(
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
"""Handle processing opex submissions and respond with HTML or JSON."""
"""Handle opex submissions and respond with HTML or JSON."""
wants_json = _is_json_request(request)
payload_data = await _extract_opex_payload(request)
try:
request_model = ProcessingOpexCalculationRequest.model_validate(
request_model = OpexCalculationRequest.model_validate(
payload_data
)
result = calculate_processing_opex(request_model)
result = calculate_opex(request_model)
except ValidationError as exc:
if wants_json:
return JSONResponse(
@@ -1237,7 +1329,7 @@ async def processing_opex_submit(
component_errors=component_errors,
)
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
_opex_TEMPLATE,
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
@@ -1263,7 +1355,7 @@ async def processing_opex_submit(
errors=errors,
)
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
_opex_TEMPLATE,
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
@@ -1295,10 +1387,10 @@ async def processing_opex_submit(
result=result,
)
notices = _list_from_context(context, "notices")
notices.append("Processing opex calculation completed successfully.")
notices.append("Opex calculation completed successfully.")
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
_opex_TEMPLATE,
context,
status_code=status.HTTP_200_OK,
)
@@ -1311,14 +1403,14 @@ async def processing_opex_submit(
)
def capex_form(
request: Request,
_: User = Depends(require_authenticated_user),
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the initial capex planner template with defaults."""
"""Render the capex planner template with defaults."""
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
@@ -1432,7 +1524,7 @@ async def capex_submit(
result=result,
)
notices = _list_from_context(context, "notices")
notices.append("Initial capex calculation completed successfully.")
notices.append("Capex calculation completed successfully.")
return templates.TemplateResponse(
"scenarios/capex.html",
@@ -1441,26 +1533,35 @@ async def capex_submit(
)
@router.get(
"/profitability",
response_class=HTMLResponse,
name="calculations.profitability_form",
)
def profitability_form(
def _render_profitability_form(
request: Request,
_: User = Depends(require_authenticated_user),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the profitability calculation form with default metadata."""
*,
metadata: PricingMetadata,
uow: UnitOfWork,
project_id: int | None,
scenario_id: int | None,
allow_redirect: bool,
) -> Response:
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
if allow_redirect and scenario is not None and getattr(scenario, "id", None):
target_project_id = project_id or getattr(scenario, "project_id", None)
if target_project_id is None and getattr(scenario, "project", None) is not None:
target_project_id = getattr(scenario.project, "id", None)
if target_project_id is not None:
redirect_url = request.url_for(
"calculations.profitability_form",
project_id=target_project_id,
scenario_id=scenario.id,
)
if redirect_url != str(request.url):
return RedirectResponse(
redirect_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
context = _prepare_default_context(
request,
project=project,
@@ -1471,28 +1572,74 @@ def profitability_form(
return templates.TemplateResponse("scenarios/profitability.html", context)
@router.post(
"/profitability",
name="calculations.profitability_submit",
@router.get(
"/projects/{project_id}/scenarios/{scenario_id}/profitability",
response_class=HTMLResponse,
include_in_schema=False,
name="calculations.profitability_form",
)
async def profitability_submit(
def profitability_form_for_scenario(
request: Request,
current_user: User = Depends(require_authenticated_user),
project_id: int,
scenario_id: int,
_: User = Depends(require_authenticated_user_html),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Response:
return _render_profitability_form(
request,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
allow_redirect=False,
)
@router.get(
"/profitability",
response_class=HTMLResponse,
)
def profitability_form(
request: Request,
_: User = Depends(require_authenticated_user_html),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
None, description="Optional project identifier"
),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
None, description="Optional scenario identifier"
),
) -> Response:
"""Handle profitability calculations and return HTML or JSON."""
"""Render the profitability calculation form with default metadata."""
return _render_profitability_form(
request,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
allow_redirect=True,
)
async def _handle_profitability_submission(
request: Request,
*,
current_user: User,
metadata: PricingMetadata,
uow: UnitOfWork,
project_id: int | None,
scenario_id: int | None,
) -> Response:
wants_json = _is_json_request(request)
payload_data = await _extract_payload(request)
try:
request_model = ProfitabilityCalculationRequest.model_validate(
payload_data)
payload_data
)
result = calculate_profitability(request_model, metadata=metadata)
except ValidationError as exc:
if wants_json:
@@ -1586,3 +1733,53 @@ async def profitability_submit(
context,
status_code=status.HTTP_200_OK,
)
@router.post(
"/projects/{project_id}/scenarios/{scenario_id}/profitability",
include_in_schema=False,
name="calculations.profitability_submit",
)
async def profitability_submit_for_scenario(
request: Request,
project_id: int,
scenario_id: int,
current_user: User = Depends(require_authenticated_user),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Response:
return await _handle_profitability_submission(
request,
current_user=current_user,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
)
@router.post(
"/profitability",
)
async def profitability_submit(
request: Request,
current_user: User = Depends(require_authenticated_user),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"
),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"
),
) -> Response:
"""Handle profitability calculations and return HTML or JSON."""
return await _handle_profitability_submission(
request,
current_user=current_user,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
)