feat(navigation): Enhance navigation links and add legacy route redirects
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 14s
CI / deploy (push) Has been skipped

- Updated navigation links in `init_db.py` to include href overrides and parent slugs for profitability, opex, and capex planners.
- Modified `NavigationService` to handle child links and href overrides, ensuring proper routing when context is missing.
- Adjusted scenario detail and list templates to use new route names for opex and capex forms, with legacy fallbacks.
- Introduced integration tests for legacy calculation routes to ensure proper redirection and error handling.
- Added tests for navigation sidebar to validate role-based access and link visibility.
- Enhanced navigation sidebar tests to include calculation links and contextual URLs based on project and scenario IDs.
This commit is contained in:
2025-11-13 20:23:53 +01:00
parent ed8e05147c
commit 4d0e1a9989
12 changed files with 1050 additions and 73 deletions

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from decimal import Decimal
from typing import Any, Sequence
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
from pydantic import ValidationError
from starlette.datastructures import FormData
@@ -917,6 +917,29 @@ def _load_project_and_scenario(
return project, scenario
def _require_project_and_scenario(
*,
uow: UnitOfWork,
project_id: int,
scenario_id: int,
) -> tuple[Project, Scenario]:
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
if scenario is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario not found",
)
owning_project = project or scenario.project
if owning_project is None or owning_project.id != project_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario does not belong to specified project",
)
return owning_project, scenario
def _is_json_request(request: Request) -> bool:
content_type = request.headers.get("content-type", "").lower()
accept = request.headers.get("accept", "").lower()
@@ -930,6 +953,41 @@ def _normalise_form_value(value: Any) -> Any:
return value
def _normalise_legacy_context_params(
*, project_id: Any | None, scenario_id: Any | None
) -> tuple[int | None, int | None, list[str]]:
"""Convert raw legacy query params to validated identifiers."""
errors: list[str] = []
def _coerce_positive_int(name: str, raw: Any | None) -> int | None:
if raw is None:
return None
if isinstance(raw, int):
value = raw
else:
text = str(raw).strip()
if text == "":
return None
if text.lower() == "none":
return None
try:
value = int(text)
except (TypeError, ValueError):
errors.append(f"{name} must be a positive integer")
return None
if value <= 0:
errors.append(f"{name} must be a positive integer")
return None
return value
normalised_project_id = _coerce_positive_int("project_id", project_id)
normalised_scenario_id = _coerce_positive_int("scenario_id", scenario_id)
return normalised_project_id, normalised_scenario_id, errors
def _form_to_payload(form: FormData) -> dict[str, Any]:
data: dict[str, Any] = {}
impurities: dict[int, dict[str, Any]] = {}
@@ -1258,22 +1316,20 @@ def _persist_opex_snapshots(
@router.get(
"/opex",
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
response_class=HTMLResponse,
name="calculations.opex_form",
name="calculations.scenario_opex_form",
)
def opex_form(
request: Request,
project_id: int,
scenario_id: int,
_: 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 opex planner with default context."""
project, scenario = _load_project_and_scenario(
project, scenario = _require_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
context = _prepare_opex_context(
@@ -1285,23 +1341,25 @@ def opex_form(
@router.post(
"/opex",
name="calculations.opex_submit",
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
name="calculations.scenario_opex_submit",
)
async def opex_submit(
request: Request,
project_id: int,
scenario_id: int,
current_user: User = Depends(require_authenticated_user),
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 opex submissions and respond with HTML or JSON."""
wants_json = _is_json_request(request)
payload_data = await _extract_opex_payload(request)
project, scenario = _require_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
try:
request_model = OpexCalculationRequest.model_validate(
payload_data
@@ -1314,9 +1372,6 @@ async def opex_submit(
content={"errors": exc.errors()},
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
general_errors, component_errors = _partition_opex_error_messages(
exc.errors()
)
@@ -1344,9 +1399,6 @@ async def opex_submit(
},
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
errors = list(exc.field_errors or []) or [exc.message]
context = _prepare_opex_context(
request,
@@ -1362,10 +1414,6 @@ async def opex_submit(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
_persist_opex_snapshots(
uow=uow,
project=project,
@@ -1400,22 +1448,145 @@ async def opex_submit(
@router.get(
"/capex",
"/opex",
response_class=HTMLResponse,
name="calculations.capex_form",
name="calculations.opex_form_legacy",
)
def capex_form(
def opex_form_legacy(
request: Request,
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
project_id: str | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
scenario_id: str | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
project_id=project_id,
scenario_id=scenario_id,
)
if errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors),
)
if normalised_scenario_id is not None:
project, scenario = _load_project_and_scenario(
uow=uow,
project_id=normalised_project_id,
scenario_id=normalised_scenario_id,
)
if scenario is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario not found",
)
owning_project = project or scenario.project
if owning_project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
redirect_url = request.url_for(
"calculations.opex_form",
project_id=owning_project.id,
scenario_id=scenario.id,
)
return RedirectResponse(
redirect_url,
status_code=status.HTTP_308_PERMANENT_REDIRECT,
)
if normalised_project_id is not None:
target_url = request.url_for(
"scenarios.project_scenario_list", project_id=normalised_project_id
)
return RedirectResponse(
target_url,
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
request.url_for("projects.project_list_page"),
status_code=status.HTTP_303_SEE_OTHER,
)
@router.post(
"/opex",
name="calculations.opex_submit_legacy",
)
async def opex_submit_legacy(
request: Request,
_: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: str | None = Query(
None, description="Optional project identifier"),
scenario_id: str | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
project_id=project_id,
scenario_id=scenario_id,
)
if errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors),
)
if normalised_scenario_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="scenario_id query parameter required; use the scenario-scoped calculations route.",
)
project, scenario = _load_project_and_scenario(
uow=uow,
project_id=normalised_project_id,
scenario_id=normalised_scenario_id,
)
if scenario is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario not found",
)
owning_project = project or scenario.project
if owning_project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
redirect_url = request.url_for(
"calculations.opex_submit",
project_id=owning_project.id,
scenario_id=scenario.id,
)
return RedirectResponse(
redirect_url,
status_code=status.HTTP_308_PERMANENT_REDIRECT,
)
@router.get(
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
response_class=HTMLResponse,
name="calculations.scenario_capex_form",
)
def capex_form(
request: Request,
project_id: int,
scenario_id: int,
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
"""Render the capex planner template with defaults."""
project, scenario = _load_project_and_scenario(
project, scenario = _require_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
context = _prepare_capex_context(
@@ -1427,23 +1598,25 @@ def capex_form(
@router.post(
"/capex",
name="calculations.capex_submit",
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
name="calculations.scenario_capex_submit",
)
async def capex_submit(
request: Request,
project_id: int,
scenario_id: int,
current_user: User = Depends(require_authenticated_user),
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:
"""Process capex submissions and return aggregated results."""
wants_json = _is_json_request(request)
payload_data = await _extract_capex_payload(request)
project, scenario = _require_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
try:
request_model = CapexCalculationRequest.model_validate(payload_data)
result = calculate_initial_capex(request_model)
@@ -1454,9 +1627,6 @@ async def capex_submit(
content={"errors": exc.errors()},
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
general_errors, component_errors = _partition_capex_error_messages(
exc.errors()
)
@@ -1484,9 +1654,6 @@ async def capex_submit(
},
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
errors = list(exc.field_errors or []) or [exc.message]
context = _prepare_capex_context(
request,
@@ -1502,10 +1669,6 @@ async def capex_submit(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
)
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
_persist_capex_snapshots(
uow=uow,
project=project,
@@ -1539,6 +1702,164 @@ async def capex_submit(
)
# Route name aliases retained for legacy integrations using the former identifiers.
router.add_api_route(
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
opex_form,
response_class=HTMLResponse,
methods=["GET"],
name="calculations.opex_form",
include_in_schema=False,
)
router.add_api_route(
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
opex_submit,
methods=["POST"],
name="calculations.opex_submit",
include_in_schema=False,
)
router.add_api_route(
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
capex_form,
response_class=HTMLResponse,
methods=["GET"],
name="calculations.capex_form",
include_in_schema=False,
)
router.add_api_route(
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
capex_submit,
methods=["POST"],
name="calculations.capex_submit",
include_in_schema=False,
)
@router.get(
"/capex",
response_class=HTMLResponse,
name="calculations.capex_form_legacy",
)
def capex_form_legacy(
request: Request,
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: str | None = Query(
None, description="Optional project identifier"),
scenario_id: str | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
project_id=project_id,
scenario_id=scenario_id,
)
if errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors),
)
if normalised_scenario_id is not None:
project, scenario = _load_project_and_scenario(
uow=uow,
project_id=normalised_project_id,
scenario_id=normalised_scenario_id,
)
if scenario is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario not found",
)
owning_project = project or scenario.project
if owning_project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
redirect_url = request.url_for(
"calculations.capex_form",
project_id=owning_project.id,
scenario_id=scenario.id,
)
return RedirectResponse(
redirect_url,
status_code=status.HTTP_308_PERMANENT_REDIRECT,
)
if normalised_project_id is not None:
target_url = request.url_for(
"scenarios.project_scenario_list", project_id=normalised_project_id
)
return RedirectResponse(
target_url,
status_code=status.HTTP_303_SEE_OTHER,
)
return RedirectResponse(
request.url_for("projects.project_list_page"),
status_code=status.HTTP_303_SEE_OTHER,
)
@router.post(
"/capex",
name="calculations.capex_submit_legacy",
)
async def capex_submit_legacy(
request: Request,
_: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: str | None = Query(
None, description="Optional project identifier"),
scenario_id: str | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
project_id=project_id,
scenario_id=scenario_id,
)
if errors:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors),
)
if normalised_scenario_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="scenario_id query parameter required; use the scenario-scoped calculations route.",
)
project, scenario = _load_project_and_scenario(
uow=uow,
project_id=normalised_project_id,
scenario_id=normalised_scenario_id,
)
if scenario is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Scenario not found",
)
owning_project = project or scenario.project
if owning_project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Project not found",
)
redirect_url = request.url_for(
"calculations.capex_submit",
project_id=owning_project.id,
scenario_id=scenario.id,
)
return RedirectResponse(
redirect_url,
status_code=status.HTTP_308_PERMANENT_REDIRECT,
)
def _render_profitability_form(
request: Request,
*,