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
+1
View File
@@ -2,6 +2,7 @@
## 2025-11-12 ## 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. - 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. - 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. - 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.
+10 -1
View File
@@ -805,7 +805,7 @@ def capex_form(
) )
async def capex_submit( async def capex_submit(
request: Request, request: Request,
_: User = Depends(require_authenticated_user), current_user: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query( project_id: int | None = Query(
None, description="Optional project identifier"), None, description="Optional project identifier"),
@@ -877,6 +877,15 @@ async def capex_submit(
uow=uow, project_id=project_id, scenario_id=scenario_id 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: if wants_json:
return JSONResponse( return JSONResponse(
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
+1 -1
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": "/"}, {"href": projects_href, "label": "Projects",
"match_prefix": "/projects"}, {"href": project_create_href, "label": "New "match_prefix": "/projects"}, {"href": project_create_href, "label": "New
Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label": 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", {"href": "/ui/simulations", "label": "Simulations"}, {"href": "/ui/reporting",
"label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href": "label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href":
"/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings", "/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings",
+3 -3
View File
@@ -10,7 +10,7 @@
{% if scenario %} {% if scenario %}
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a> <a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
{% endif %} {% endif %}
<span aria-current="page">Initial Capex</span> <span aria-current="page">Initial Capex Planner</span>
</nav> </nav>
<header class="page-header"> <header class="page-header">
@@ -249,13 +249,13 @@
</table> </table>
{% endif %} {% endif %}
{% else %} {% 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 %} {% endif %}
</section> </section>
<section class="report-section"> <section class="report-section">
<header class="section-header"> <header class="section-header">
<h2>Visualisations</h2> <h2>Visualizations</h2>
<p class="section-subtitle">Charts render after calculations complete.</p> <p class="section-subtitle">Charts render after calculations complete.</p>
</header> </header>
<div id="capex-category-chart" class="chart-container"></div> <div id="capex-category-chart" class="chart-container"></div>
+8
View File
@@ -13,12 +13,20 @@
</nav> </nav>
<header class="page-header"> <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> <div>
<h1>{{ scenario.name }}</h1> <h1>{{ scenario.name }}</h1>
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p> <p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
</div> </div>
<div class="header-actions"> <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="{{ 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> <a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
</div> </div>
</header> </header>
+3 -3
View File
@@ -8,9 +8,9 @@
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a> <a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
{% endif %} {% endif %}
{% if scenario %} {% 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 %} {% endif %}
<span aria-current="page">Profitability</span> <span aria-current="page">Profitability Calculator</span>
</nav> </nav>
<header class="page-header"> <header class="page-header">
@@ -323,7 +323,7 @@
</table> </table>
{% endif %} {% endif %}
{% else %} {% 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 %} {% endif %}
</section> </section>
+72 -2
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from collections.abc import Callable
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from services.unit_of_work import UnitOfWork
def _create_project(client: TestClient, name: str) -> int: def _create_project(client: TestClient, name: str) -> int:
response = client.post( response = client.post(
@@ -33,7 +37,10 @@ def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
return response.json()["id"] 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") project_id = _create_project(client, "Capex HTML Project")
scenario_id = _create_scenario(client, project_id, "Capex HTML Scenario") 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 "$1,200,000.00" in response.text or "1,200,000" in response.text
assert "USD" 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") project_id = _create_project(client, "Capex JSON Project")
scenario_id = _create_scenario(client, project_id, "Capex JSON Scenario") scenario_id = _create_scenario(client, project_id, "Capex JSON Scenario")
@@ -121,3 +166,28 @@ def test_capex_calculation_json_flow(client: TestClient) -> None:
assert data["timeline"][0]["year"] == 0 assert data["timeline"][0]["year"] == 0
assert data["timeline"][0]["spend"] == pytest.approx(600_000) 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)