- 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.
148 lines
4.3 KiB
Python
148 lines
4.3 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import Request
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from services.navigation import NavigationService
|
|
from services.session import AuthSession
|
|
from services.unit_of_work import UnitOfWork
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def format_datetime(value: Any) -> str:
|
|
"""Render datetime values consistently for templates."""
|
|
if not isinstance(value, datetime):
|
|
return ""
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.strftime("%Y-%m-%d %H:%M UTC")
|
|
|
|
|
|
def currency_display(value: Any, currency_code: str | None) -> str:
|
|
"""Format numeric values with currency context."""
|
|
if value is None:
|
|
return "—"
|
|
if isinstance(value, (int, float)):
|
|
formatted_value = f"{value:,.2f}"
|
|
else:
|
|
formatted_value = str(value)
|
|
if currency_code:
|
|
return f"{currency_code} {formatted_value}"
|
|
return formatted_value
|
|
|
|
|
|
def format_metric(value: Any, metric_name: str, currency_code: str | None = None) -> str:
|
|
"""Format metrics according to their semantic type."""
|
|
if value is None:
|
|
return "—"
|
|
|
|
currency_metrics = {
|
|
"npv",
|
|
"inflows",
|
|
"outflows",
|
|
"net",
|
|
"total_inflows",
|
|
"total_outflows",
|
|
"total_net",
|
|
}
|
|
if metric_name in currency_metrics and currency_code:
|
|
return currency_display(value, currency_code)
|
|
|
|
percentage_metrics = {"irr", "payback_period"}
|
|
if metric_name in percentage_metrics:
|
|
if isinstance(value, (int, float)):
|
|
return f"{value:.2f}%"
|
|
return f"{value}%"
|
|
|
|
if isinstance(value, (int, float)):
|
|
return f"{value:,.2f}"
|
|
|
|
return str(value)
|
|
|
|
|
|
def percentage_display(value: Any) -> str:
|
|
"""Format numeric values as percentages."""
|
|
if value is None:
|
|
return "—"
|
|
if isinstance(value, (int, float)):
|
|
return f"{value:.2f}%"
|
|
return f"{value}%"
|
|
|
|
|
|
def period_display(value: Any) -> str:
|
|
"""Format period values in years."""
|
|
if value is None:
|
|
return "—"
|
|
if isinstance(value, (int, float)):
|
|
if value == int(value):
|
|
return f"{int(value)} years"
|
|
return f"{value:.1f} years"
|
|
return str(value)
|
|
|
|
|
|
def register_common_filters(templates: Jinja2Templates) -> None:
|
|
templates.env.filters["format_datetime"] = format_datetime
|
|
templates.env.filters["currency_display"] = currency_display
|
|
templates.env.filters["format_metric"] = format_metric
|
|
templates.env.filters["percentage_display"] = percentage_display
|
|
templates.env.filters["period_display"] = period_display
|
|
|
|
|
|
def _sidebar_navigation_for_request(request: Request | None):
|
|
if request is None:
|
|
return None
|
|
|
|
cached = getattr(request.state, "_navigation_sidebar_dto", None)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
session_context = getattr(request.state, "auth_session", None)
|
|
if isinstance(session_context, AuthSession):
|
|
session = session_context
|
|
else:
|
|
session = AuthSession.anonymous()
|
|
|
|
try:
|
|
with UnitOfWork() as uow:
|
|
if not uow.navigation:
|
|
logger.debug("Navigation repository unavailable for sidebar rendering")
|
|
sidebar_dto = None
|
|
else:
|
|
service = NavigationService(uow.navigation)
|
|
sidebar_dto = service.build_sidebar(session=session, request=request)
|
|
except Exception: # pragma: no cover - defensive fallback for templates
|
|
logger.exception("Failed to build sidebar navigation during template render")
|
|
sidebar_dto = None
|
|
|
|
setattr(request.state, "_navigation_sidebar_dto", sidebar_dto)
|
|
return sidebar_dto
|
|
|
|
|
|
def register_navigation_globals(templates: Jinja2Templates) -> None:
|
|
templates.env.globals["get_sidebar_navigation"] = _sidebar_navigation_for_request
|
|
|
|
|
|
def create_templates() -> Jinja2Templates:
|
|
templates = Jinja2Templates(directory="templates")
|
|
register_common_filters(templates)
|
|
register_navigation_globals(templates)
|
|
return templates
|
|
|
|
|
|
__all__ = [
|
|
"format_datetime",
|
|
"currency_display",
|
|
"format_metric",
|
|
"percentage_display",
|
|
"period_display",
|
|
"register_common_filters",
|
|
"register_navigation_globals",
|
|
"create_templates",
|
|
]
|