feat: add scenarios list page with metrics and quick actions
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped

- Introduced a new template for listing scenarios associated with a project.
- Added metrics for total, active, draft, and archived scenarios.
- Implemented quick actions for creating new scenarios and reviewing project overview.
- Enhanced navigation with breadcrumbs for better user experience.

refactor: update Opex and Profitability templates for consistency

- Changed titles and button labels for clarity in Opex and Profitability templates.
- Updated form IDs and action URLs for better alignment with new naming conventions.
- Improved navigation links to include scenario and project overviews.

test: add integration tests for Opex calculations

- Created new tests for Opex calculation HTML and JSON flows.
- Validated successful calculations and ensured correct data persistence.
- Implemented tests for currency mismatch and unsupported frequency scenarios.

test: enhance project and scenario route tests

- Added tests to verify scenario list rendering and calculator shortcuts.
- Ensured scenario detail pages link back to the portfolio correctly.
- Validated project detail pages show associated scenarios accurately.
This commit is contained in:
2025-11-13 16:21:36 +01:00
parent 4f00bf0d3c
commit 522b1e4105
54 changed files with 3419 additions and 700 deletions

View File

@@ -23,6 +23,7 @@ from services.session import (
from services.unit_of_work import UnitOfWork
from services.importers import ImportIngestionService
from services.pricing import PricingMetadata
from services.navigation import NavigationService
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
from services.repositories import pricing_settings_to_metadata
@@ -64,6 +65,14 @@ def get_pricing_metadata(
return pricing_settings_to_metadata(seed_result.settings)
def get_navigation_service(
uow: UnitOfWork = Depends(get_unit_of_work),
) -> NavigationService:
if not uow.navigation:
raise RuntimeError("Navigation repository is not initialised")
return NavigationService(uow.navigation)
def get_pricing_evaluator(
metadata: PricingMetadata = Depends(get_pricing_metadata),
) -> ScenarioPricingEvaluator:
@@ -153,6 +162,28 @@ def require_authenticated_user(
return user
def require_authenticated_user_html(
request: Request,
session: AuthSession = Depends(get_auth_session),
) -> User:
"""HTML-aware authenticated dependency that redirects anonymous sessions."""
user = session.user
if user is None or session.tokens.is_empty:
login_url = str(request.url_for("auth.login_form"))
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": login_url},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled.",
)
return user
def _user_role_names(user: User) -> set[str]:
roles: Iterable[Role] = getattr(user, "roles", []) or []
return {role.name for role in roles}
@@ -186,12 +217,55 @@ def require_any_role(*roles: str) -> Callable[[User], User]:
return require_roles(*roles)
def require_project_resource(*, require_manage: bool = False) -> Callable[[int], Project]:
def require_roles_html(*roles: str) -> Callable[[Request], User]:
"""Ensure user is authenticated for HTML responses; redirect anonymous to login."""
required = tuple(role.strip() for role in roles if role.strip())
if not required:
raise ValueError("require_roles_html requires at least one role name")
def _dependency(
request: Request,
session: AuthSession = Depends(get_auth_session),
) -> User:
user = session.user
if user is None:
login_url = str(request.url_for("auth.login_form"))
raise HTTPException(
status_code=status.HTTP_303_SEE_OTHER,
headers={"Location": login_url},
)
if user.is_superuser:
return user
role_names = _user_role_names(user)
if not any(role in role_names for role in required):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions for this action.",
)
return user
return _dependency
def require_any_role_html(*roles: str) -> Callable[[Request], User]:
"""Alias of require_roles_html for readability."""
return require_roles_html(*roles)
def require_project_resource(
*,
require_manage: bool = False,
user_dependency: Callable[..., User] = require_authenticated_user,
) -> Callable[[int], Project]:
"""Dependency factory that resolves a project with authorization checks."""
def _dependency(
project_id: int,
user: User = Depends(require_authenticated_user),
user: User = Depends(user_dependency),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Project:
try:
@@ -216,13 +290,16 @@ def require_project_resource(*, require_manage: bool = False) -> Callable[[int],
def require_scenario_resource(
*, require_manage: bool = False, with_children: bool = False
*,
require_manage: bool = False,
with_children: bool = False,
user_dependency: Callable[..., User] = require_authenticated_user,
) -> Callable[[int], Scenario]:
"""Dependency factory that resolves a scenario with authorization checks."""
def _dependency(
scenario_id: int,
user: User = Depends(require_authenticated_user),
user: User = Depends(user_dependency),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Scenario:
try:
@@ -248,14 +325,17 @@ def require_scenario_resource(
def require_project_scenario_resource(
*, require_manage: bool = False, with_children: bool = False
*,
require_manage: bool = False,
with_children: bool = False,
user_dependency: Callable[..., User] = require_authenticated_user,
) -> Callable[[int, int], Scenario]:
"""Dependency factory ensuring a scenario belongs to the given project and is accessible."""
def _dependency(
project_id: int,
scenario_id: int,
user: User = Depends(require_authenticated_user),
user: User = Depends(user_dependency),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Scenario:
try:
@@ -279,3 +359,42 @@ def require_project_scenario_resource(
) from exc
return _dependency
def require_project_resource_html(
*, require_manage: bool = False
) -> Callable[[int], Project]:
"""HTML-aware project loader that redirects anonymous sessions."""
return require_project_resource(
require_manage=require_manage,
user_dependency=require_authenticated_user_html,
)
def require_scenario_resource_html(
*,
require_manage: bool = False,
with_children: bool = False,
) -> Callable[[int], Scenario]:
"""HTML-aware scenario loader that redirects anonymous sessions."""
return require_scenario_resource(
require_manage=require_manage,
with_children=with_children,
user_dependency=require_authenticated_user_html,
)
def require_project_scenario_resource_html(
*,
require_manage: bool = False,
with_children: bool = False,
) -> Callable[[int, int], Scenario]:
"""HTML-aware project-scenario loader redirecting anonymous sessions."""
return require_project_scenario_resource(
require_manage=require_manage,
with_children=with_children,
user_dependency=require_authenticated_user_html,
)