feat: Persist initial capex calculations and enhance navigation links in UI
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user