- 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.
189 lines
6.2 KiB
Python
189 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Iterable, List, Optional, Sequence
|
|
|
|
from fastapi import Request
|
|
|
|
from models.navigation import NavigationGroup, NavigationLink
|
|
from services.repositories import NavigationRepository
|
|
from services.session import AuthSession
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class NavigationLinkDTO:
|
|
id: int
|
|
label: str
|
|
href: str
|
|
match_prefix: str | None
|
|
icon: str | None
|
|
tooltip: str | None
|
|
is_external: bool
|
|
children: List["NavigationLinkDTO"] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class NavigationGroupDTO:
|
|
id: int
|
|
label: str
|
|
icon: str | None
|
|
tooltip: str | None
|
|
links: List[NavigationLinkDTO] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class NavigationSidebarDTO:
|
|
groups: List[NavigationGroupDTO]
|
|
roles: tuple[str, ...]
|
|
|
|
|
|
class NavigationService:
|
|
"""Build navigation payloads filtered for the current session."""
|
|
|
|
def __init__(self, repository: NavigationRepository) -> None:
|
|
self._repository = repository
|
|
|
|
def build_sidebar(
|
|
self,
|
|
*,
|
|
session: AuthSession,
|
|
request: Request | None = None,
|
|
include_disabled: bool = False,
|
|
) -> NavigationSidebarDTO:
|
|
roles = self._collect_roles(session)
|
|
groups = self._repository.list_groups_with_links(
|
|
include_disabled=include_disabled
|
|
)
|
|
context = self._derive_context(request)
|
|
|
|
mapped_groups: List[NavigationGroupDTO] = []
|
|
for group in groups:
|
|
if not include_disabled and not group.is_enabled:
|
|
continue
|
|
mapped_links = self._map_links(
|
|
group.links,
|
|
roles,
|
|
request=request,
|
|
include_disabled=include_disabled,
|
|
context=context,
|
|
)
|
|
if not mapped_links and not include_disabled:
|
|
continue
|
|
mapped_groups.append(
|
|
NavigationGroupDTO(
|
|
id=group.id,
|
|
label=group.label,
|
|
icon=group.icon,
|
|
tooltip=group.tooltip,
|
|
links=mapped_links,
|
|
)
|
|
)
|
|
return NavigationSidebarDTO(groups=mapped_groups, roles=roles)
|
|
|
|
def _map_links(
|
|
self,
|
|
links: Sequence[NavigationLink],
|
|
roles: Iterable[str],
|
|
*,
|
|
request: Request | None,
|
|
include_disabled: bool,
|
|
context: dict[str, str | None],
|
|
) -> List[NavigationLinkDTO]:
|
|
resolved_roles = tuple(roles)
|
|
mapped: List[NavigationLinkDTO] = []
|
|
for link in sorted(links, key=lambda l: (l.sort_order, l.id)):
|
|
if not include_disabled and (not link.is_enabled):
|
|
continue
|
|
if not self._link_visible(link, resolved_roles, include_disabled):
|
|
continue
|
|
href = self._resolve_href(link, request=request, context=context)
|
|
if not href:
|
|
continue
|
|
children = self._map_links(
|
|
link.children,
|
|
resolved_roles,
|
|
request=request,
|
|
include_disabled=include_disabled,
|
|
context=context,
|
|
)
|
|
match_prefix = link.match_prefix or href
|
|
mapped.append(
|
|
NavigationLinkDTO(
|
|
id=link.id,
|
|
label=link.label,
|
|
href=href,
|
|
match_prefix=match_prefix,
|
|
icon=link.icon,
|
|
tooltip=link.tooltip,
|
|
is_external=link.is_external,
|
|
children=children,
|
|
)
|
|
)
|
|
return mapped
|
|
|
|
@staticmethod
|
|
def _collect_roles(session: AuthSession) -> tuple[str, ...]:
|
|
roles = tuple((session.role_slugs or ()) if session else ())
|
|
if session and session.is_authenticated:
|
|
return roles
|
|
if "anonymous" in roles:
|
|
return roles
|
|
return roles + ("anonymous",)
|
|
|
|
@staticmethod
|
|
def _derive_context(request: Request | None) -> dict[str, str | None]:
|
|
if request is None:
|
|
return {"project_id": None, "scenario_id": None}
|
|
project_id = request.path_params.get(
|
|
"project_id") if hasattr(request, "path_params") else None
|
|
scenario_id = request.path_params.get(
|
|
"scenario_id") if hasattr(request, "path_params") else None
|
|
if not project_id:
|
|
project_id = request.query_params.get("project_id")
|
|
if not scenario_id:
|
|
scenario_id = request.query_params.get("scenario_id")
|
|
return {"project_id": project_id, "scenario_id": scenario_id}
|
|
|
|
def _resolve_href(
|
|
self,
|
|
link: NavigationLink,
|
|
*,
|
|
request: Request | None,
|
|
context: dict[str, str | None],
|
|
) -> str | None:
|
|
if link.route_name:
|
|
if request is None:
|
|
# Fallback to route name when no request is available
|
|
return f"/{link.route_name.replace('.', '/')}"
|
|
if link.slug in {"profitability", "profitability-calculator"}:
|
|
project_id = context.get("project_id")
|
|
scenario_id = context.get("scenario_id")
|
|
if project_id and scenario_id:
|
|
try:
|
|
return request.url_for(
|
|
link.route_name,
|
|
project_id=project_id,
|
|
scenario_id=scenario_id,
|
|
)
|
|
except Exception: # pragma: no cover - defensive
|
|
pass
|
|
try:
|
|
return request.url_for(link.route_name)
|
|
except Exception: # pragma: no cover - defensive
|
|
return link.href_override
|
|
return link.href_override
|
|
|
|
@staticmethod
|
|
def _link_visible(
|
|
link: NavigationLink,
|
|
roles: Iterable[str],
|
|
include_disabled: bool,
|
|
) -> bool:
|
|
role_tuple = tuple(roles)
|
|
if not include_disabled and not link.is_enabled:
|
|
return False
|
|
if not link.required_roles:
|
|
return True
|
|
role_set = set(role_tuple)
|
|
return any(role in role_set for role in link.required_roles)
|