feat: Persist initial capex calculations and enhance navigation links in UI

This commit is contained in:
2025-11-12 23:52:06 +01:00
parent d9fd82b2e3
commit 1240b08740
7 changed files with 99 additions and 11 deletions

View File

@@ -2,6 +2,7 @@
## 2025-11-12
- Persisted initial capex calculations to project and scenario snapshot tables via shared helper in `routes/calculations.py`, centralised Jinja filter registration, and expanded integration tests to assert HTML/JSON flows create snapshots with correct totals and payload metadata (requires full-suite pytest run to satisfy 80% coverage gate).
- Resolved test suite regressions by registering the UI router in test fixtures, restoring `TABLE_DDLS` for enum validation checks, hardening token tamper detection, and reran the full pytest suite to confirm green builds.
- Fixed critical 500 error in reporting dashboard by correcting route reference in reporting.html template - changed 'reports.project_list_page' to 'projects.project_list_page' to resolve NoMatchFound error when accessing /ui/reporting.
- Completed navigation validation by inventorying all sidebar navigation links, identifying missing routes for simulations, reporting, settings, themes, and currencies, created new UI routes in routes/ui.py with proper authentication guards, built corresponding templates (simulations.html, reporting.html, settings.html, theme_settings.html, currencies.html), registered the UI router in main.py, updated sidebar navigation to use route names instead of hardcoded URLs, and enhanced navigation.js to use dynamic URL resolution for proper route handling.

View File

@@ -805,7 +805,7 @@ def capex_form(
)
async def capex_submit(
request: Request,
_: User = Depends(require_authenticated_user),
current_user: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
@@ -877,6 +877,15 @@ async def capex_submit(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
_persist_capex_snapshots(
uow=uow,
project=project,
scenario=scenario,
user=current_user,
request_model=request_model,
result=result,
)
if wants_json:
return JSONResponse(
status_code=status.HTTP_200_OK,

View File

@@ -19,7 +19,7 @@ request.url_for('auth.password_reset_request_form') if request else
"match_prefix": "/"}, {"href": projects_href, "label": "Projects",
"match_prefix": "/projects"}, {"href": project_create_href, "label": "New
Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label":
"Imports", "match_prefix": "/imports"} ] }, { "label": "Insights", "links": [
"Imports", "match_prefix": "/imports"}, {"href": request.url_for('calculations.profitability_form') if request else '/calculations/profitability', "label": "Profitability Calculator", "match_prefix": "/calculations/profitability"}, {"href": request.url_for('calculations.capex_form') if request else '/calculations/capex', "label": "Initial Capex Planner", "match_prefix": "/calculations/capex"} ] }, { "label": "Insights", "links": [
{"href": "/ui/simulations", "label": "Simulations"}, {"href": "/ui/reporting",
"label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href":
"/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings",

View File

@@ -10,7 +10,7 @@
{% if scenario %}
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
{% endif %}
<span aria-current="page">Initial Capex</span>
<span aria-current="page">Initial Capex Planner</span>
</nav>
<header class="page-header">
@@ -249,13 +249,13 @@
</table>
{% endif %}
{% else %}
<p class="muted">Provide component details and calculate to see capex totals.</p>
<p class="muted">Provide component details and calculate to see initial capex totals.</p>
{% endif %}
</section>
<section class="report-section">
<header class="section-header">
<h2>Visualisations</h2>
<h2>Visualizations</h2>
<p class="section-subtitle">Charts render after calculations complete.</p>
</header>
<div id="capex-category-chart" class="chart-container"></div>

View File

@@ -13,12 +13,20 @@
</nav>
<header class="page-header">
{% set profitability_href = url_for('calculations.profitability_form') %}
{% set capex_href = url_for('calculations.capex_form') %}
{% if project and scenario %}
{% set profitability_href = profitability_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
{% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
{% endif %}
<div>
<h1>{{ scenario.name }}</h1>
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a>
<a class="btn" href="{{ profitability_href }}">Profitability Calculator</a>
<a class="btn" href="{{ capex_href }}">Initial Capex Planner</a>
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
</div>
</header>

View File

@@ -8,9 +8,9 @@
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
{% endif %}
{% if scenario %}
<a href="{{ url_for('scenarios.view_scenario', project_id=scenario.project_id, scenario_id=scenario.id) }}">{{ scenario.name }}</a>
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
{% endif %}
<span aria-current="page">Profitability</span>
<span aria-current="page">Profitability Calculator</span>
</nav>
<header class="page-header">
@@ -323,7 +323,7 @@
</table>
{% endif %}
{% else %}
<p class="muted">Run a calculation to see profitability metrics.</p>
<p class="muted">Provide inputs and run the profitability calculator to see scenario metrics.</p>
{% endif %}
</section>

View File

@@ -1,8 +1,12 @@
from __future__ import annotations
import pytest
from collections.abc import Callable
from fastapi.testclient import TestClient
from services.unit_of_work import UnitOfWork
def _create_project(client: TestClient, name: str) -> int:
response = client.post(
@@ -33,7 +37,10 @@ def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
return response.json()["id"]
def test_capex_calculation_html_flow(client: TestClient) -> None:
def test_capex_calculation_html_flow(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
) -> None:
project_id = _create_project(client, "Capex HTML Project")
scenario_id = _create_scenario(client, project_id, "Capex HTML Scenario")
@@ -69,8 +76,46 @@ def test_capex_calculation_html_flow(client: TestClient) -> None:
assert "$1,200,000.00" in response.text or "1,200,000" in response.text
assert "USD" in response.text
with unit_of_work_factory() as uow:
assert uow.project_capex is not None
assert uow.scenario_capex is not None
def test_capex_calculation_json_flow(client: TestClient) -> None:
project_snapshots = uow.project_capex.list_for_project(project_id)
scenario_snapshots = uow.scenario_capex.list_for_scenario(scenario_id)
assert len(project_snapshots) == 1
assert len(scenario_snapshots) == 1
project_snapshot = project_snapshots[0]
scenario_snapshot = scenario_snapshots[0]
assert project_snapshot.total_capex is not None
assert project_snapshot.contingency_pct is not None
assert project_snapshot.total_with_contingency is not None
assert float(project_snapshot.total_capex) == pytest.approx(2_000_000)
assert float(project_snapshot.contingency_pct) == pytest.approx(5.0)
assert float(project_snapshot.total_with_contingency) == pytest.approx(
2_100_000)
assert project_snapshot.component_count == 2
assert project_snapshot.currency_code == "USD"
assert scenario_snapshot.total_capex is not None
assert scenario_snapshot.contingency_amount is not None
assert scenario_snapshot.total_with_contingency is not None
assert float(scenario_snapshot.total_capex) == pytest.approx(2_000_000)
assert float(
scenario_snapshot.contingency_amount) == pytest.approx(100_000)
assert float(scenario_snapshot.total_with_contingency) == pytest.approx(
2_100_000)
assert scenario_snapshot.component_count == 2
assert scenario_snapshot.currency_code == "USD"
assert scenario_snapshot.payload is not None
def test_capex_calculation_json_flow(
client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork],
) -> None:
project_id = _create_project(client, "Capex JSON Project")
scenario_id = _create_scenario(client, project_id, "Capex JSON Scenario")
@@ -120,4 +165,29 @@ def test_capex_calculation_json_flow(client: TestClient) -> None:
assert len(data["timeline"]) == 2
assert data["timeline"][0]["year"] == 0
assert data["timeline"][0]["spend"] == pytest.approx(600_000)
assert data["timeline"][1]["cumulative"] == pytest.approx(1_000_000)
assert data["timeline"][1]["cumulative"] == pytest.approx(1_000_000)
with unit_of_work_factory() as uow:
assert uow.project_capex is not None
assert uow.scenario_capex is not None
scenario_snapshot = uow.scenario_capex.latest_for_scenario(scenario_id)
project_snapshot = uow.project_capex.latest_for_project(project_id)
assert scenario_snapshot is not None
assert project_snapshot is not None
assert scenario_snapshot.total_capex is not None
assert project_snapshot.total_with_contingency is not None
assert float(scenario_snapshot.total_capex) == pytest.approx(1_000_000)
assert float(project_snapshot.total_with_contingency) == pytest.approx(
1_125_000)
assert scenario_snapshot.payload is not None
payload = scenario_snapshot.payload or {}
result_payload = payload.get("result", {})
assert result_payload.get("currency") == "USD"
assert result_payload.get("totals", {}).get(
"with_contingency") == pytest.approx(1_125_000)
assert result_payload.get("totals", {}).get(
"overall") == pytest.approx(1_000_000)