feat(navigation): Enhance navigation links and add legacy route redirects
- 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:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user