from __future__ import annotations from dataclasses import dataclass, field from typing import Iterable, List, Sequence from fastapi import Request from models.navigation import 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], include_children: bool = False, ) -> List[NavigationLinkDTO]: resolved_roles = tuple(roles) mapped: List[NavigationLinkDTO] = [] for link in sorted(links, key=lambda x: (x.sort_order, x.id)): if not include_children and link.parent_link_id is not None: continue 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, include_children=True, ) 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 = link.href_override if fallback: return fallback # Fallback to route name when no request is available return f"/{link.route_name.replace('.', '/')}" requires_context = link.slug in { "profitability", "profitability-calculator", "opex", "capex", } if requires_context: project_id = context.get("project_id") scenario_id = context.get("scenario_id") if project_id and scenario_id: try: return str( request.url_for( link.route_name, project_id=project_id, scenario_id=scenario_id, ) ) except Exception: # pragma: no cover - defensive pass try: return str(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)