feat: Persist initial capex calculations and enhance navigation links in UI
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
@@ -120,4 +165,29 @@ def test_capex_calculation_json_flow(client: TestClient) -> None:
|
|||||||
assert len(data["timeline"]) == 2
|
assert len(data["timeline"]) == 2
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user