Files
calminer/dependencies.py
zwitschi 522b1e4105
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
feat: add scenarios list page with metrics and quick actions
- 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.
2025-11-13 16:21:36 +01:00

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,
)