feat: add scenarios list page with metrics and quick actions
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

- 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.
This commit is contained in:
2025-11-13 16:21:36 +01:00
parent 4f00bf0d3c
commit 522b1e4105
54 changed files with 3419 additions and 700 deletions

View File

@@ -5,7 +5,6 @@ from typing import Any, Iterable
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import ValidationError
from starlette.datastructures import FormData
@@ -43,9 +42,10 @@ from services.session import (
)
from services.repositories import RoleRepository, UserRepository
from services.unit_of_work import UnitOfWork
from routes.template_filters import create_templates
router = APIRouter(tags=["Authentication"])
templates = Jinja2Templates(directory="templates")
templates = create_templates()
_PASSWORD_RESET_SCOPE = "password-reset"
_AUTH_SCOPE = "auth"

View File

@@ -6,20 +6,25 @@ from decimal import Decimal
from typing import Any, Sequence
from fastapi import APIRouter, Depends, Query, Request, status
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
from pydantic import ValidationError
from starlette.datastructures import FormData
from starlette.routing import NoMatchFound
from dependencies import get_pricing_metadata, get_unit_of_work, require_authenticated_user
from dependencies import (
get_pricing_metadata,
get_unit_of_work,
require_authenticated_user,
require_authenticated_user_html,
)
from models import (
Project,
ProjectCapexSnapshot,
ProjectProcessingOpexSnapshot,
ProjectOpexSnapshot,
ProjectProfitability,
Scenario,
ScenarioCapexSnapshot,
ScenarioProcessingOpexSnapshot,
ScenarioOpexSnapshot,
ScenarioProfitability,
User,
)
@@ -29,17 +34,17 @@ from schemas.calculations import (
CapexCalculationResult,
CapexComponentInput,
CapexParameters,
ProcessingOpexCalculationRequest,
ProcessingOpexCalculationResult,
ProcessingOpexComponentInput,
ProcessingOpexOptions,
ProcessingOpexParameters,
OpexCalculationRequest,
OpexCalculationResult,
OpexComponentInput,
OpexOptions,
OpexParameters,
ProfitabilityCalculationRequest,
ProfitabilityCalculationResult,
)
from services.calculations import (
calculate_initial_capex,
calculate_processing_opex,
calculate_opex,
calculate_profitability,
)
from services.exceptions import (
@@ -50,11 +55,10 @@ from services.exceptions import (
)
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
from routes.template_filters import register_common_filters
from routes.template_filters import create_templates
router = APIRouter(prefix="/calculations", tags=["Calculations"])
templates = Jinja2Templates(directory="templates")
register_common_filters(templates)
templates = create_templates()
_SUPPORTED_METALS: tuple[dict[str, str], ...] = (
{"value": "copper", "label": "Copper"},
@@ -90,7 +94,7 @@ _OPEX_FREQUENCY_OPTIONS: tuple[dict[str, str], ...] = (
_DEFAULT_OPEX_HORIZON_YEARS = 5
_PROCESSING_OPEX_TEMPLATE = "scenarios/opex.html"
_opex_TEMPLATE = "scenarios/opex.html"
def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
@@ -196,7 +200,7 @@ def _build_default_form_data(
"reference_price": "",
"treatment_charge": "",
"smelting_charge": "",
"processing_opex": "",
"opex": "",
"moisture_pct": "",
"moisture_threshold_pct": moisture_threshold_default,
"moisture_penalty_per_pct": moisture_penalty_default,
@@ -204,7 +208,7 @@ def _build_default_form_data(
"fx_rate": 1.0,
"currency_code": currency,
"impurities": None,
"initial_capex": "",
"capex": "",
"sustaining_capex": "",
"discount_rate": discount_rate,
"periods": _DEFAULT_EVALUATION_PERIODS,
@@ -380,6 +384,12 @@ def _prepare_capex_context(
currency_code = parameters.get(
"currency_code") or defaults["currency_code"]
navigation = _resolve_navigation_links(
request,
project=project,
scenario=scenario,
)
return {
"request": request,
"project": project,
@@ -396,14 +406,14 @@ def _prepare_capex_context(
"notices": notices or [],
"component_errors": component_errors or [],
"component_notices": component_notices or [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"form_action": str(request.url),
"csrf_token": None,
**navigation,
}
def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
if isinstance(component, ProcessingOpexComponentInput):
if isinstance(component, OpexComponentInput):
raw = component.model_dump()
elif isinstance(component, dict):
raw = dict(component)
@@ -436,7 +446,7 @@ def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
if isinstance(parameters, ProcessingOpexParameters):
if isinstance(parameters, OpexParameters):
raw = parameters.model_dump()
elif isinstance(parameters, dict):
raw = dict(parameters)
@@ -455,7 +465,7 @@ def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
def _serialise_opex_options(options: Any) -> dict[str, Any]:
if isinstance(options, ProcessingOpexOptions):
if isinstance(options, OpexOptions):
raw = options.model_dump()
elif isinstance(options, dict):
raw = dict(options)
@@ -511,7 +521,7 @@ def _prepare_opex_context(
project: Project | None,
scenario: Scenario | None,
form_data: dict[str, Any] | None = None,
result: ProcessingOpexCalculationResult | None = None,
result: OpexCalculationResult | None = None,
errors: list[str] | None = None,
notices: list[str] | None = None,
component_errors: list[str] | None = None,
@@ -544,6 +554,12 @@ def _prepare_opex_context(
currency_code = parameters.get(
"currency_code") or defaults["currency_code"]
navigation = _resolve_navigation_links(
request,
project=project,
scenario=scenario,
)
return {
"request": request,
"project": project,
@@ -561,9 +577,9 @@ def _prepare_opex_context(
"notices": notices or [],
"component_errors": component_errors or [],
"component_notices": component_notices or [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"form_action": str(request.url),
"csrf_token": None,
**navigation,
}
@@ -758,6 +774,76 @@ async def _extract_capex_payload(request: Request) -> dict[str, Any]:
return _capex_form_to_payload(form)
def _resolve_navigation_links(
request: Request,
*,
project: Project | None,
scenario: Scenario | None,
) -> dict[str, str | None]:
project_url: str | None = None
scenario_url: str | None = None
scenario_portfolio_url: str | None = None
candidate_project = project
if scenario is not None and getattr(scenario, "id", None) is not None:
try:
scenario_url = str(
request.url_for(
"scenarios.view_scenario", scenario_id=scenario.id
)
)
except NoMatchFound:
scenario_url = None
try:
scenario_portfolio_url = str(
request.url_for(
"scenarios.project_scenario_list",
project_id=scenario.project_id,
)
)
except NoMatchFound:
scenario_portfolio_url = None
if candidate_project is None:
candidate_project = getattr(scenario, "project", None)
if candidate_project is not None and getattr(candidate_project, "id", None) is not None:
try:
project_url = str(
request.url_for(
"projects.view_project", project_id=candidate_project.id
)
)
except NoMatchFound:
project_url = None
if scenario_portfolio_url is None:
try:
scenario_portfolio_url = str(
request.url_for(
"scenarios.project_scenario_list",
project_id=candidate_project.id,
)
)
except NoMatchFound:
scenario_portfolio_url = None
cancel_url = scenario_url or project_url or request.headers.get("Referer")
if cancel_url is None:
try:
cancel_url = str(request.url_for("projects.project_list_page"))
except NoMatchFound:
cancel_url = "/"
return {
"project_url": project_url,
"scenario_url": scenario_url,
"scenario_portfolio_url": scenario_portfolio_url,
"cancel_url": cancel_url,
}
def _prepare_default_context(
request: Request,
*,
@@ -781,6 +867,12 @@ def _prepare_default_context(
allow_empty_override=allow_empty_override,
)
navigation = _resolve_navigation_links(
request,
project=project,
scenario=scenario,
)
return {
"request": request,
"project": project,
@@ -792,10 +884,10 @@ def _prepare_default_context(
"result": result,
"errors": [],
"notices": [],
"cancel_url": request.headers.get("Referer"),
"form_action": request.url.path,
"form_action": str(request.url),
"csrf_token": None,
"default_periods": _DEFAULT_EVALUATION_PERIODS,
**navigation,
}
@@ -920,11 +1012,11 @@ def _persist_profitability_snapshots(
created_by_id = getattr(user, "id", None)
revenue_total = float(result.pricing.net_revenue)
processing_total = float(result.costs.processing_opex_total)
processing_total = float(result.costs.opex_total)
sustaining_total = float(result.costs.sustaining_capex_total)
initial_capex = float(result.costs.initial_capex)
capex = float(result.costs.capex)
net_cash_flow_total = revenue_total - (
processing_total + sustaining_total + initial_capex
processing_total + sustaining_total + capex
)
npv_value = (
@@ -964,9 +1056,9 @@ def _persist_profitability_snapshots(
payback_period_years=payback_value,
margin_pct=margin_value,
revenue_total=revenue_total,
processing_opex_total=processing_total,
opex_total=processing_total,
sustaining_capex_total=sustaining_total,
initial_capex=initial_capex,
capex=capex,
net_cash_flow_total=net_cash_flow_total,
payload=payload,
)
@@ -983,9 +1075,9 @@ def _persist_profitability_snapshots(
payback_period_years=payback_value,
margin_pct=margin_value,
revenue_total=revenue_total,
processing_opex_total=processing_total,
opex_total=processing_total,
sustaining_capex_total=sustaining_total,
initial_capex=initial_capex,
capex=capex,
net_cash_flow_total=net_cash_flow_total,
payload=payload,
)
@@ -1067,7 +1159,7 @@ def _should_persist_opex(
*,
project: Project | None,
scenario: Scenario | None,
request_model: ProcessingOpexCalculationRequest,
request_model: OpexCalculationRequest,
) -> bool:
persist_requested = bool(
getattr(request_model, "options", None)
@@ -1082,8 +1174,8 @@ def _persist_opex_snapshots(
project: Project | None,
scenario: Scenario | None,
user: User | None,
request_model: ProcessingOpexCalculationRequest,
result: ProcessingOpexCalculationResult,
request_model: OpexCalculationRequest,
result: OpexCalculationResult,
) -> None:
if not _should_persist_opex(
project=project,
@@ -1130,11 +1222,11 @@ def _persist_opex_snapshots(
"result": result.model_dump(),
}
if scenario and uow.scenario_processing_opex:
scenario_snapshot = ScenarioProcessingOpexSnapshot(
if scenario and uow.scenario_opex:
scenario_snapshot = ScenarioOpexSnapshot(
scenario_id=scenario.id,
created_by_id=created_by_id,
calculation_source="calculations.processing_opex",
calculation_source="calculations.opex",
currency_code=result.currency,
overall_annual=overall_annual,
escalated_total=escalated_total,
@@ -1145,13 +1237,13 @@ def _persist_opex_snapshots(
component_count=component_count,
payload=payload,
)
uow.scenario_processing_opex.create(scenario_snapshot)
uow.scenario_opex.create(scenario_snapshot)
if project and uow.project_processing_opex:
project_snapshot = ProjectProcessingOpexSnapshot(
if project and uow.project_opex:
project_snapshot = ProjectOpexSnapshot(
project_id=project.id,
created_by_id=created_by_id,
calculation_source="calculations.processing_opex",
calculation_source="calculations.opex",
currency_code=result.currency,
overall_annual=overall_annual,
escalated_total=escalated_total,
@@ -1162,24 +1254,24 @@ def _persist_opex_snapshots(
component_count=component_count,
payload=payload,
)
uow.project_processing_opex.create(project_snapshot)
uow.project_opex.create(project_snapshot)
@router.get(
"/processing-opex",
"/opex",
response_class=HTMLResponse,
name="calculations.processing_opex_form",
name="calculations.opex_form",
)
def processing_opex_form(
def opex_form(
request: Request,
_: User = Depends(require_authenticated_user),
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the processing opex planner with default context."""
"""Render the opex planner with default context."""
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
@@ -1189,14 +1281,14 @@ def processing_opex_form(
project=project,
scenario=scenario,
)
return templates.TemplateResponse(_PROCESSING_OPEX_TEMPLATE, context)
return templates.TemplateResponse(_opex_TEMPLATE, context)
@router.post(
"/processing-opex",
name="calculations.processing_opex_submit",
"/opex",
name="calculations.opex_submit",
)
async def processing_opex_submit(
async def opex_submit(
request: Request,
current_user: User = Depends(require_authenticated_user),
uow: UnitOfWork = Depends(get_unit_of_work),
@@ -1205,16 +1297,16 @@ async def processing_opex_submit(
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> Response:
"""Handle processing opex submissions and respond with HTML or JSON."""
"""Handle opex submissions and respond with HTML or JSON."""
wants_json = _is_json_request(request)
payload_data = await _extract_opex_payload(request)
try:
request_model = ProcessingOpexCalculationRequest.model_validate(
request_model = OpexCalculationRequest.model_validate(
payload_data
)
result = calculate_processing_opex(request_model)
result = calculate_opex(request_model)
except ValidationError as exc:
if wants_json:
return JSONResponse(
@@ -1237,7 +1329,7 @@ async def processing_opex_submit(
component_errors=component_errors,
)
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
_opex_TEMPLATE,
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
@@ -1263,7 +1355,7 @@ async def processing_opex_submit(
errors=errors,
)
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
_opex_TEMPLATE,
context,
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
)
@@ -1295,10 +1387,10 @@ async def processing_opex_submit(
result=result,
)
notices = _list_from_context(context, "notices")
notices.append("Processing opex calculation completed successfully.")
notices.append("Opex calculation completed successfully.")
return templates.TemplateResponse(
_PROCESSING_OPEX_TEMPLATE,
_opex_TEMPLATE,
context,
status_code=status.HTTP_200_OK,
)
@@ -1311,14 +1403,14 @@ async def processing_opex_submit(
)
def capex_form(
request: Request,
_: User = Depends(require_authenticated_user),
_: User = Depends(require_authenticated_user_html),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the initial capex planner template with defaults."""
"""Render the capex planner template with defaults."""
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
@@ -1432,7 +1524,7 @@ async def capex_submit(
result=result,
)
notices = _list_from_context(context, "notices")
notices.append("Initial capex calculation completed successfully.")
notices.append("Capex calculation completed successfully.")
return templates.TemplateResponse(
"scenarios/capex.html",
@@ -1441,26 +1533,35 @@ async def capex_submit(
)
@router.get(
"/profitability",
response_class=HTMLResponse,
name="calculations.profitability_form",
)
def profitability_form(
def _render_profitability_form(
request: Request,
_: User = Depends(require_authenticated_user),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
) -> HTMLResponse:
"""Render the profitability calculation form with default metadata."""
*,
metadata: PricingMetadata,
uow: UnitOfWork,
project_id: int | None,
scenario_id: int | None,
allow_redirect: bool,
) -> Response:
project, scenario = _load_project_and_scenario(
uow=uow, project_id=project_id, scenario_id=scenario_id
)
if allow_redirect and scenario is not None and getattr(scenario, "id", None):
target_project_id = project_id or getattr(scenario, "project_id", None)
if target_project_id is None and getattr(scenario, "project", None) is not None:
target_project_id = getattr(scenario.project, "id", None)
if target_project_id is not None:
redirect_url = request.url_for(
"calculations.profitability_form",
project_id=target_project_id,
scenario_id=scenario.id,
)
if redirect_url != str(request.url):
return RedirectResponse(
redirect_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
)
context = _prepare_default_context(
request,
project=project,
@@ -1471,28 +1572,74 @@ def profitability_form(
return templates.TemplateResponse("scenarios/profitability.html", context)
@router.post(
"/profitability",
name="calculations.profitability_submit",
@router.get(
"/projects/{project_id}/scenarios/{scenario_id}/profitability",
response_class=HTMLResponse,
include_in_schema=False,
name="calculations.profitability_form",
)
async def profitability_submit(
def profitability_form_for_scenario(
request: Request,
current_user: User = Depends(require_authenticated_user),
project_id: int,
scenario_id: int,
_: User = Depends(require_authenticated_user_html),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Response:
return _render_profitability_form(
request,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
allow_redirect=False,
)
@router.get(
"/profitability",
response_class=HTMLResponse,
)
def profitability_form(
request: Request,
_: User = Depends(require_authenticated_user_html),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"),
None, description="Optional project identifier"
),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"),
None, description="Optional scenario identifier"
),
) -> Response:
"""Handle profitability calculations and return HTML or JSON."""
"""Render the profitability calculation form with default metadata."""
return _render_profitability_form(
request,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
allow_redirect=True,
)
async def _handle_profitability_submission(
request: Request,
*,
current_user: User,
metadata: PricingMetadata,
uow: UnitOfWork,
project_id: int | None,
scenario_id: int | None,
) -> Response:
wants_json = _is_json_request(request)
payload_data = await _extract_payload(request)
try:
request_model = ProfitabilityCalculationRequest.model_validate(
payload_data)
payload_data
)
result = calculate_profitability(request_model, metadata=metadata)
except ValidationError as exc:
if wants_json:
@@ -1586,3 +1733,53 @@ async def profitability_submit(
context,
status_code=status.HTTP_200_OK,
)
@router.post(
"/projects/{project_id}/scenarios/{scenario_id}/profitability",
include_in_schema=False,
name="calculations.profitability_submit",
)
async def profitability_submit_for_scenario(
request: Request,
project_id: int,
scenario_id: int,
current_user: User = Depends(require_authenticated_user),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> Response:
return await _handle_profitability_submission(
request,
current_user=current_user,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
)
@router.post(
"/profitability",
)
async def profitability_submit(
request: Request,
current_user: User = Depends(require_authenticated_user),
metadata: PricingMetadata = Depends(get_pricing_metadata),
uow: UnitOfWork = Depends(get_unit_of_work),
project_id: int | None = Query(
None, description="Optional project identifier"
),
scenario_id: int | None = Query(
None, description="Optional scenario identifier"
),
) -> Response:
"""Handle profitability calculations and return HTML or JSON."""
return await _handle_profitability_submission(
request,
current_user=current_user,
metadata=metadata,
uow=uow,
project_id=project_id,
scenario_id=scenario_id,
)

View File

@@ -4,14 +4,14 @@ from datetime import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from routes.template_filters import create_templates
from dependencies import get_current_user, get_unit_of_work
from models import ScenarioStatus, User
from services.unit_of_work import UnitOfWork
router = APIRouter(tags=["Dashboard"])
templates = Jinja2Templates(directory="templates")
templates = create_templates()
def _format_timestamp(moment: datetime | None) -> str | None:

View File

@@ -7,7 +7,6 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_unit_of_work, require_any_role
from schemas.exports import (
@@ -24,10 +23,12 @@ from services.export_serializers import (
from services.unit_of_work import UnitOfWork
from models.import_export_log import ImportExportLog
from monitoring.metrics import observe_export
from routes.template_filters import create_templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/exports", tags=["exports"])
templates = create_templates()
@router.get(
@@ -49,7 +50,6 @@ async def export_modal(
submit_url = request.url_for(
"export_projects" if dataset == "projects" else "export_scenarios"
)
templates = Jinja2Templates(directory="templates")
return templates.TemplateResponse(
request,
"exports/modal.html",

View File

@@ -5,9 +5,12 @@ from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from dependencies import get_import_ingestion_service, require_roles
from dependencies import (
get_import_ingestion_service,
require_roles,
require_roles_html,
)
from models import User
from schemas.imports import (
ImportCommitRequest,
@@ -17,9 +20,10 @@ from schemas.imports import (
ScenarioImportPreviewResponse,
)
from services.importers import ImportIngestionService, UnsupportedImportFormat
from routes.template_filters import create_templates
router = APIRouter(prefix="/imports", tags=["Imports"])
templates = Jinja2Templates(directory="templates")
templates = create_templates()
MANAGE_ROLES = ("project_manager", "admin")
@@ -32,7 +36,7 @@ MANAGE_ROLES = ("project_manager", "admin")
)
def import_dashboard(
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,

63
routes/navigation.py Normal file
View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from dependencies import (
get_auth_session,
get_navigation_service,
require_authenticated_user,
)
from models import User
from schemas.navigation import (
NavigationGroupSchema,
NavigationLinkSchema,
NavigationSidebarResponse,
)
from services.navigation import NavigationGroupDTO, NavigationLinkDTO, NavigationService
from services.session import AuthSession
router = APIRouter(prefix="/navigation", tags=["Navigation"])
def _to_link_schema(dto: NavigationLinkDTO) -> NavigationLinkSchema:
return NavigationLinkSchema(
id=dto.id,
label=dto.label,
href=dto.href,
match_prefix=dto.match_prefix,
icon=dto.icon,
tooltip=dto.tooltip,
is_external=dto.is_external,
children=[_to_link_schema(child) for child in dto.children],
)
def _to_group_schema(dto: NavigationGroupDTO) -> NavigationGroupSchema:
return NavigationGroupSchema(
id=dto.id,
label=dto.label,
icon=dto.icon,
tooltip=dto.tooltip,
links=[_to_link_schema(link) for link in dto.links],
)
@router.get(
"/sidebar",
response_model=NavigationSidebarResponse,
name="navigation.sidebar",
)
async def get_sidebar_navigation(
request: Request,
_: User = Depends(require_authenticated_user),
session: AuthSession = Depends(get_auth_session),
service: NavigationService = Depends(get_navigation_service),
) -> NavigationSidebarResponse:
dto = service.build_sidebar(session=session, request=request)
return NavigationSidebarResponse(
groups=[_to_group_schema(group) for group in dto.groups],
roles=list(dto.roles),
generated_at=datetime.now(tz=timezone.utc),
)

View File

@@ -4,23 +4,26 @@ from typing import List
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import (
get_pricing_metadata,
get_unit_of_work,
require_any_role,
require_any_role_html,
require_project_resource,
require_project_resource_html,
require_roles,
require_roles_html,
)
from models import MiningOperationType, Project, ScenarioStatus, User
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
from services.exceptions import EntityConflictError
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
from routes.template_filters import create_templates
router = APIRouter(prefix="/projects", tags=["Projects"])
templates = Jinja2Templates(directory="templates")
templates = create_templates()
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin")
@@ -79,7 +82,7 @@ def create_project(
)
def project_list_page(
request: Request,
_: User = Depends(require_any_role(*READ_ROLES)),
_: User = Depends(require_any_role_html(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
projects = _require_project_repo(uow).list(with_children=True)
@@ -101,7 +104,8 @@ def project_list_page(
name="projects.create_project_form",
)
def create_project_form(
request: Request, _: User = Depends(require_roles(*MANAGE_ROLES))
request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
@@ -122,7 +126,7 @@ def create_project_form(
)
def create_project_submit(
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
name: str = Form(...),
location: str | None = Form(None),
operation_type: str = Form(...),
@@ -221,7 +225,8 @@ def delete_project(
)
def view_project(
request: Request,
project: Project = Depends(require_project_resource()),
_: User = Depends(require_any_role_html(*READ_ROLES)),
project: Project = Depends(require_project_resource_html()),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
project = _require_project_repo(uow).get(project.id, with_children=True)
@@ -256,8 +261,9 @@ def view_project(
)
def edit_project_form(
request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
project: Project = Depends(
require_project_resource(require_manage=True)
require_project_resource_html(require_manage=True)
),
) -> HTMLResponse:
return templates.TemplateResponse(
@@ -283,8 +289,9 @@ def edit_project_form(
)
def edit_project_submit(
request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
project: Project = Depends(
require_project_resource(require_manage=True)
require_project_resource_html(require_manage=True)
),
name: str = Form(...),
location: str | None = Form(None),

View File

@@ -5,13 +5,15 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from dependencies import (
get_unit_of_work,
require_any_role,
require_any_role_html,
require_project_resource,
require_scenario_resource,
require_project_resource_html,
require_scenario_resource_html,
)
from models import Project, Scenario, User
from services.exceptions import EntityNotFoundError, ScenarioValidationError
@@ -24,11 +26,10 @@ from services.reporting import (
validate_percentiles,
)
from services.unit_of_work import UnitOfWork
from routes.template_filters import register_common_filters
from routes.template_filters import create_templates
router = APIRouter(prefix="/reports", tags=["Reports"])
templates = Jinja2Templates(directory="templates")
register_common_filters(templates)
templates = create_templates()
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin")
@@ -250,8 +251,8 @@ def scenario_distribution_report(
)
def project_summary_page(
request: Request,
project: Project = Depends(require_project_resource()),
_: User = Depends(require_any_role(*READ_ROLES)),
project: Project = Depends(require_project_resource_html()),
_: User = Depends(require_any_role_html(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
include: str | None = Query(
None,
@@ -314,8 +315,8 @@ def project_summary_page(
)
def project_scenario_comparison_page(
request: Request,
project: Project = Depends(require_project_resource()),
_: User = Depends(require_any_role(*READ_ROLES)),
project: Project = Depends(require_project_resource_html()),
_: User = Depends(require_any_role_html(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
scenario_ids: list[int] = Query(
..., alias="scenario_ids", description="Repeatable scenario identifier."),
@@ -391,8 +392,10 @@ def project_scenario_comparison_page(
)
def scenario_distribution_page(
request: Request,
scenario: Scenario = Depends(require_scenario_resource()),
_: User = Depends(require_any_role(*READ_ROLES)),
_: User = Depends(require_any_role_html(*READ_ROLES)),
scenario: Scenario = Depends(
require_scenario_resource_html()
),
uow: UnitOfWork = Depends(get_unit_of_work),
include: str | None = Query(
None,

View File

@@ -6,14 +6,16 @@ from typing import List
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import (
get_pricing_metadata,
get_unit_of_work,
require_any_role,
require_any_role_html,
require_roles,
require_roles_html,
require_scenario_resource,
require_scenario_resource_html,
)
from models import ResourceType, Scenario, ScenarioStatus, User
from schemas.scenario import (
@@ -31,9 +33,10 @@ from services.exceptions import (
)
from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork
from routes.template_filters import create_templates
router = APIRouter(tags=["Scenarios"])
templates = Jinja2Templates(directory="templates")
templates = create_templates()
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin")
@@ -170,6 +173,63 @@ def create_scenario_for_project(
return _to_read_model(created)
@router.get(
"/projects/{project_id}/scenarios/ui",
response_class=HTMLResponse,
include_in_schema=False,
name="scenarios.project_scenario_list",
)
def project_scenario_list_page(
project_id: int,
request: Request,
_: User = Depends(require_any_role_html(*READ_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
try:
project = _require_project_repo(uow).get(
project_id, with_children=True)
except EntityNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
scenarios = sorted(
project.scenarios,
key=lambda scenario: scenario.updated_at or scenario.created_at,
reverse=True,
)
scenario_totals = {
"total": len(scenarios),
"active": sum(
1 for scenario in scenarios if scenario.status == ScenarioStatus.ACTIVE
),
"draft": sum(
1 for scenario in scenarios if scenario.status == ScenarioStatus.DRAFT
),
"archived": sum(
1 for scenario in scenarios if scenario.status == ScenarioStatus.ARCHIVED
),
"latest_update": max(
(
scenario.updated_at or scenario.created_at
for scenario in scenarios
if scenario.updated_at or scenario.created_at
),
default=None,
),
}
return templates.TemplateResponse(
request,
"scenarios/list.html",
{
"project": project,
"scenarios": scenarios,
"scenario_totals": scenario_totals,
},
)
@router.get("/scenarios/{scenario_id}", response_model=ScenarioRead)
def get_scenario(
scenario: Scenario = Depends(require_scenario_resource()),
@@ -263,7 +323,7 @@ def _scenario_form_state(
def create_scenario_form(
project_id: int,
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work),
metadata: PricingMetadata = Depends(get_pricing_metadata),
) -> HTMLResponse:
@@ -301,7 +361,7 @@ def create_scenario_form(
def create_scenario_submit(
project_id: int,
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
name: str = Form(...),
description: str | None = Form(None),
status_value: str = Form(ScenarioStatus.DRAFT.value),
@@ -374,6 +434,7 @@ def create_scenario_submit(
"projects.view_project", project_id=project_id
),
"error": str(exc),
"error_field": "currency",
"default_currency": metadata.default_currency,
},
status_code=status.HTTP_400_BAD_REQUEST,
@@ -408,7 +469,8 @@ def create_scenario_submit(
"cancel_url": request.url_for(
"projects.view_project", project_id=project_id
),
"error": "Scenario could not be created.",
"error": "Scenario with this name already exists for this project.",
"error_field": "name",
"default_currency": metadata.default_currency,
},
status_code=status.HTTP_409_CONFLICT,
@@ -428,8 +490,9 @@ def create_scenario_submit(
)
def view_scenario(
request: Request,
_: User = Depends(require_any_role_html(*READ_ROLES)),
scenario: Scenario = Depends(
require_scenario_resource(with_children=True)
require_scenario_resource_html(with_children=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse:
@@ -469,8 +532,9 @@ def view_scenario(
)
def edit_scenario_form(
request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
scenario: Scenario = Depends(
require_scenario_resource(require_manage=True)
require_scenario_resource_html(require_manage=True)
),
uow: UnitOfWork = Depends(get_unit_of_work),
metadata: PricingMetadata = Depends(get_pricing_metadata),
@@ -503,8 +567,9 @@ def edit_scenario_form(
)
def edit_scenario_submit(
request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
scenario: Scenario = Depends(
require_scenario_resource(require_manage=True)
require_scenario_resource_html(require_manage=True)
),
name: str = Form(...),
description: str | None = Form(None),
@@ -569,6 +634,7 @@ def edit_scenario_submit(
"scenarios.view_scenario", scenario_id=scenario.id
),
"error": str(exc),
"error_field": "currency",
"default_currency": metadata.default_currency,
},
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -1,10 +1,19 @@
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."""
@@ -85,6 +94,47 @@ def register_common_filters(templates: Jinja2Templates) -> None:
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",
@@ -92,4 +142,6 @@ __all__ = [
"percentage_display",
"period_display",
"register_common_filters",
"register_navigation_globals",
"create_templates",
]

View File

@@ -2,13 +2,13 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from dependencies import require_any_role, require_roles
from dependencies import require_any_role_html, require_roles_html
from models import User
from routes.template_filters import create_templates
router = APIRouter(tags=["UI"])
templates = Jinja2Templates(directory="templates")
templates = create_templates()
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin")
@@ -22,7 +22,7 @@ MANAGE_ROLES = ("project_manager", "admin")
)
def simulations_dashboard(
request: Request,
_: User = Depends(require_any_role(*READ_ROLES)),
_: User = Depends(require_any_role_html(*READ_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
@@ -41,7 +41,7 @@ def simulations_dashboard(
)
def reporting_dashboard(
request: Request,
_: User = Depends(require_any_role(*READ_ROLES)),
_: User = Depends(require_any_role_html(*READ_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
@@ -60,7 +60,7 @@ def reporting_dashboard(
)
def settings_page(
request: Request,
_: User = Depends(require_any_role(*READ_ROLES)),
_: User = Depends(require_any_role_html(*READ_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
@@ -79,7 +79,7 @@ def settings_page(
)
def theme_settings_page(
request: Request,
_: User = Depends(require_any_role(*READ_ROLES)),
_: User = Depends(require_any_role_html(*READ_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,
@@ -98,7 +98,7 @@ def theme_settings_page(
)
def currencies_page(
request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)),
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
) -> HTMLResponse:
return templates.TemplateResponse(
request,