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.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
63
routes/navigation.py
Normal 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),
|
||||
)
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
16
routes/ui.py
16
routes/ui.py
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user