- 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.
401 lines
12 KiB
Python
401 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Iterable, Generator
|
|
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
|
|
from config.settings import Settings, get_settings
|
|
from models import Project, Role, Scenario, User
|
|
from services.authorization import (
|
|
ensure_project_access as ensure_project_access_helper,
|
|
ensure_scenario_access as ensure_scenario_access_helper,
|
|
ensure_scenario_in_project as ensure_scenario_in_project_helper,
|
|
)
|
|
from services.exceptions import AuthorizationError, EntityNotFoundError
|
|
from services.security import JWTSettings
|
|
from services.session import (
|
|
AuthSession,
|
|
SessionStrategy,
|
|
SessionTokens,
|
|
build_session_strategy,
|
|
extract_session_tokens,
|
|
)
|
|
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
|
|
|
|
|
|
def get_unit_of_work() -> Generator[UnitOfWork, None, None]:
|
|
"""FastAPI dependency yielding a unit-of-work instance."""
|
|
|
|
with UnitOfWork() as uow:
|
|
yield uow
|
|
|
|
|
|
_IMPORT_INGESTION_SERVICE = ImportIngestionService(lambda: UnitOfWork())
|
|
|
|
|
|
def get_import_ingestion_service() -> ImportIngestionService:
|
|
"""Provide singleton import ingestion service."""
|
|
|
|
return _IMPORT_INGESTION_SERVICE
|
|
|
|
|
|
def get_application_settings() -> Settings:
|
|
"""Provide cached application settings instance."""
|
|
|
|
return get_settings()
|
|
|
|
|
|
def get_pricing_metadata(
|
|
settings: Settings = Depends(get_application_settings),
|
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
|
) -> PricingMetadata:
|
|
"""Return pricing metadata defaults sourced from persisted pricing settings."""
|
|
|
|
stored = uow.get_pricing_metadata()
|
|
if stored is not None:
|
|
return stored
|
|
|
|
fallback = settings.pricing_metadata()
|
|
seed_result = uow.ensure_default_pricing_settings(metadata=fallback)
|
|
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:
|
|
"""Provide a configured scenario pricing evaluator."""
|
|
|
|
return ScenarioPricingEvaluator(ScenarioPricingConfig(metadata=metadata))
|
|
|
|
|
|
def get_jwt_settings() -> JWTSettings:
|
|
"""Provide JWT runtime configuration derived from settings."""
|
|
|
|
return get_settings().jwt_settings()
|
|
|
|
|
|
def get_session_strategy(
|
|
settings: Settings = Depends(get_application_settings),
|
|
) -> SessionStrategy:
|
|
"""Yield configured session transport strategy."""
|
|
|
|
return build_session_strategy(settings.session_settings())
|
|
|
|
|
|
def get_session_tokens(
|
|
request: Request,
|
|
strategy: SessionStrategy = Depends(get_session_strategy),
|
|
) -> SessionTokens:
|
|
"""Extract raw session tokens from the incoming request."""
|
|
|
|
existing = getattr(request.state, "auth_session", None)
|
|
if isinstance(existing, AuthSession):
|
|
return existing.tokens
|
|
|
|
tokens = extract_session_tokens(request, strategy)
|
|
request.state.auth_session = AuthSession(tokens=tokens)
|
|
return tokens
|
|
|
|
|
|
def get_auth_session(
|
|
request: Request,
|
|
tokens: SessionTokens = Depends(get_session_tokens),
|
|
) -> AuthSession:
|
|
"""Provide authentication session context for the current request."""
|
|
|
|
existing = getattr(request.state, "auth_session", None)
|
|
if isinstance(existing, AuthSession):
|
|
return existing
|
|
|
|
if tokens.is_empty:
|
|
session = AuthSession.anonymous()
|
|
else:
|
|
session = AuthSession(tokens=tokens)
|
|
request.state.auth_session = session
|
|
return session
|
|
|
|
|
|
def get_current_user(
|
|
session: AuthSession = Depends(get_auth_session),
|
|
) -> User | None:
|
|
"""Return the current authenticated user if present."""
|
|
|
|
return session.user
|
|
|
|
|
|
def require_current_user(
|
|
session: AuthSession = Depends(get_auth_session),
|
|
) -> User:
|
|
"""Ensure that a request is authenticated and return the user context."""
|
|
|
|
if session.user is None or session.tokens.is_empty:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Authentication required.",
|
|
)
|
|
return session.user
|
|
|
|
|
|
def require_authenticated_user(
|
|
user: User = Depends(require_current_user),
|
|
) -> User:
|
|
"""Ensure the current user account is active."""
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User account is disabled.",
|
|
)
|
|
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}
|
|
|
|
|
|
def require_roles(*roles: str) -> Callable[[User], User]:
|
|
"""Dependency factory enforcing membership in one of the given roles."""
|
|
|
|
required = tuple(role.strip() for role in roles if role.strip())
|
|
if not required:
|
|
raise ValueError("require_roles requires at least one role name")
|
|
|
|
def _dependency(user: User = Depends(require_authenticated_user)) -> User:
|
|
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(*roles: str) -> Callable[[User], User]:
|
|
"""Alias of require_roles for readability in some contexts."""
|
|
|
|
return require_roles(*roles)
|
|
|
|
|
|
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(user_dependency),
|
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
|
) -> Project:
|
|
try:
|
|
return ensure_project_access_helper(
|
|
uow,
|
|
project_id=project_id,
|
|
user=user,
|
|
require_manage=require_manage,
|
|
)
|
|
except EntityNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=str(exc),
|
|
) from exc
|
|
except AuthorizationError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
return _dependency
|
|
|
|
|
|
def require_scenario_resource(
|
|
*,
|
|
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(user_dependency),
|
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
|
) -> Scenario:
|
|
try:
|
|
return ensure_scenario_access_helper(
|
|
uow,
|
|
scenario_id=scenario_id,
|
|
user=user,
|
|
require_manage=require_manage,
|
|
with_children=with_children,
|
|
)
|
|
except EntityNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=str(exc),
|
|
) from exc
|
|
except AuthorizationError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
return _dependency
|
|
|
|
|
|
def require_project_scenario_resource(
|
|
*,
|
|
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(user_dependency),
|
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
|
) -> Scenario:
|
|
try:
|
|
return ensure_scenario_in_project_helper(
|
|
uow,
|
|
project_id=project_id,
|
|
scenario_id=scenario_id,
|
|
user=user,
|
|
require_manage=require_manage,
|
|
with_children=with_children,
|
|
)
|
|
except EntityNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=str(exc),
|
|
) from exc
|
|
except AuthorizationError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=str(exc),
|
|
) 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,
|
|
)
|