Compare commits
11 Commits
acf6f50bbd
...
522b1e4105
| Author | SHA1 | Date | |
|---|---|---|---|
| 522b1e4105 | |||
| 4f00bf0d3c | |||
| 3551b0356d | |||
| 521a8abc2d | |||
| 1feae7ff85 | |||
| 1240b08740 | |||
| d9fd82b2e3 | |||
| 6c1570a254 | |||
| b1a6df9f90 | |||
| 6d496a599e | |||
| 1199813da0 |
@@ -8,6 +8,6 @@ The system is designed to help mining companies make informed decisions by simul
|
||||
|
||||
## Documentation & quickstart
|
||||
|
||||
- Detailed developer, architecture, and operations guides live in the companion [calminer-docs](../calminer-docs/) repository.
|
||||
- Detailed developer, architecture, and operations guides live in the companion [calminer-docs](../calminer-docs/) repository. Please see the [README](../calminer-docs/README.md) there for instructions.
|
||||
- For a local run, create a `.env` (see `.env.example`), install requirements, then execute `python -m scripts.init_db` followed by `uvicorn main:app --reload`. The initializer is safe to rerun and seeds demo data automatically.
|
||||
- To wipe and recreate the schema in development, run `CALMINER_ENV=development python -m scripts.reset_db` before invoking the initializer again.
|
||||
|
||||
BIN
alembic_test.db
BIN
alembic_test.db
Binary file not shown.
21
changelog.md
21
changelog.md
@@ -1,11 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-11-13
|
||||
|
||||
- Finalised the financial data import/export templates by inventorying required fields, defining CSV column specs with validation rules, drafting Excel workbook layouts, documenting end-user workflows in `calminer-docs/userguide/data_import_export.md`, and recording stakeholder review steps alongside updated TODO/DONE tracking.
|
||||
- Scoped profitability calculator UI under the scenario hierarchy by adding `/calculations/projects/{project_id}/scenarios/{scenario_id}/profitability` GET/POST handlers, updating scenario templates and sidebar navigation to link to the new route, and extending `tests/test_project_scenario_routes.py` with coverage for the scenario path plus legacy redirect behaviour (module run: 14 passed).
|
||||
- Extended scenario frontend regression coverage by updating `tests/test_project_scenario_routes.py` to assert project/scenario breadcrumbs and calculator navigation, normalising escaped URLs, and re-running the module tests (13 passing).
|
||||
- Cleared FastAPI and Pydantic deprecation warnings by migrating `scripts/init_db.py` to `@field_validator`, replacing the `main.py` startup hook with a lifespan handler, auditing template response call signatures, confirming HTTP 422 constant usage, and re-running the full pytest suite to ensure a clean warning slate.
|
||||
- Delivered the capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`.
|
||||
- Updated UI navigation to surface the opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`.
|
||||
- Completed manual validation of the Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up.
|
||||
- Added opex calculation unit tests in `tests/services/test_calculations_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension.
|
||||
- Documented the Opex Planner workflow in `calminer-docs/userguide/opex_planner.md`, linked it from the user guide index, extended `calminer-docs/architecture/08_concepts/02_data_model.md` with snapshot coverage, and captured the completion in `.github/instructions/DONE.md`.
|
||||
- Implemented opex integration coverage in `tests/integration/test_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions.
|
||||
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the opex documentation updates.
|
||||
- Completed the navigation sidebar API migration by finalising the database-backed service, refactoring `templates/partials/sidebar_nav.html` to consume the endpoint, hydrating via `static/js/navigation_sidebar.js`, and updating HTML route dependencies (`routes/projects.py`, `routes/scenarios.py`, `routes/reports.py`, `routes/imports.py`, `routes/calculations.py`) to use redirect-aware guards so anonymous visitors receive login redirects instead of JSON errors (manual verification via curl across projects, scenarios, reports, and calculations pages).
|
||||
|
||||
## 2025-11-12
|
||||
|
||||
- Fixed critical 500 error in reporting dashboard by correcting route reference in reporting.html template - changed 'reports.project_list_page' to 'projects.project_list_page' to resolve NoMatchFound error when accessing /ui/reporting.
|
||||
- Completed navigation validation by inventorying all sidebar navigation links, identifying missing routes for simulations, reporting, settings, themes, and currencies, created new UI routes in routes/ui.py with proper authentication guards, built corresponding templates (simulations.html, reporting.html, settings.html, theme_settings.html, currencies.html), registered the UI router in main.py, updated sidebar navigation to use route names instead of hardcoded URLs, and enhanced navigation.js to use dynamic URL resolution for proper route handling.
|
||||
- Fixed critical template rendering error in sidebar_nav.html where URL objects from request.url_for() were being used with string methods, causing TypeError. Added |string filters to convert URL objects to strings for proper template rendering.
|
||||
- Integrated Plotly charting for interactive visualizations in reporting templates, added chart generation methods to ReportingService (\_generate_npv_comparison_chart, \_generate_distribution_histogram), updated project summary and scenario distribution contexts to include chart JSON data, enhanced templates with chart containers and JavaScript rendering, added chart-container CSS styling, and validated all reporting tests pass.
|
||||
- Fixed critical template rendering error in sidebar_nav.html where URL objects from `request.url_for()` were being used with string methods, causing TypeError. Added `|string` filters to convert URL objects to strings for proper template rendering.
|
||||
- Integrated Plotly charting for interactive visualizations in reporting templates, added chart generation methods to ReportingService (`generate_npv_comparison_chart`, `generate_distribution_histogram`), updated project summary and scenario distribution contexts to include chart JSON data, enhanced templates with chart containers and JavaScript rendering, added chart-container CSS styling, and validated all reporting tests pass.
|
||||
|
||||
- Completed local run verification: started application with `uvicorn main:app --reload` without errors, verified authenticated routes (/login, /, /projects/ui, /projects) load correctly with seeded data, and summarized findings for deployment pipeline readiness.
|
||||
- Fixed docker-compose.override.yml command array to remove duplicate "uvicorn" entry, enabling successful container startup with uvicorn reload in development mode.
|
||||
@@ -15,7 +30,6 @@
|
||||
- Replaced the Alembic migration workflow with the idempotent Pydantic-backed initializer (`scripts/init_db.py`), added a guarded reset utility (`scripts/reset_db.py`), removed migration artifacts/tooling (Alembic directory, config, Docker entrypoint), refreshed the container entrypoint to invoke `uvicorn` directly, and updated installation/architecture docs plus the README to direct developers to the new seeding/reset flow.
|
||||
- Eliminated Bandit hardcoded-secret findings by replacing literal JWT tokens and passwords across auth/security tests with randomized helpers drawn from `tests/utils/security.py`, ensuring fixtures still assert expected behaviours.
|
||||
- Centralized Bandit configuration in `pyproject.toml`, reran `bandit -c pyproject.toml -r calminer tests`, and verified the scan now reports zero issues.
|
||||
- Updated `.github/instructions/TODO.md` and `.github/instructions/DONE.md` to reflect the completed security scan remediation workflow.
|
||||
- Diagnosed admin bootstrap failure caused by legacy `roles` schema, added Alembic migration `20251112_00_add_roles_metadata_columns.py` to backfill `display_name`, `description`, `created_at`, and `updated_at`, and verified the migration via full pytest run in the activated `.venv`.
|
||||
- Resolved Ruff E402 warnings by moving module docstrings ahead of `from __future__ import annotations` across currency and pricing service modules, dropped the unused `HTTPException` import in `monitoring/__init__.py`, and confirmed a clean `ruff check .` run.
|
||||
- Enhanced the deploy job in `.gitea/workflows/cicache.yml` to capture Kubernetes pod, deployment, and container logs into `/logs/deployment/` for staging/production rollouts and publish them via a `deployment-logs` artifact, updating CI/CD documentation with retrieval instructions.
|
||||
@@ -30,7 +44,6 @@
|
||||
- Updated header and footer templates to consistently use `logo_big.png` image instead of text logo, with appropriate CSS styling for sizing.
|
||||
- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point.
|
||||
- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end.
|
||||
- Recorded the completed “Ensure currency is used consistently” work in `.github/instructions/DONE.md` and ran the full pytest suite (150 tests) to verify the refactor.
|
||||
- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill.
|
||||
- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency.
|
||||
- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour.
|
||||
|
||||
131
dependencies.py
131
dependencies.py
@@ -23,6 +23,7 @@ from services.session import (
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from services.importers import ImportIngestionService
|
||||
from services.pricing import PricingMetadata
|
||||
from services.navigation import NavigationService
|
||||
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||
from services.repositories import pricing_settings_to_metadata
|
||||
|
||||
@@ -64,6 +65,14 @@ def get_pricing_metadata(
|
||||
return pricing_settings_to_metadata(seed_result.settings)
|
||||
|
||||
|
||||
def get_navigation_service(
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> NavigationService:
|
||||
if not uow.navigation:
|
||||
raise RuntimeError("Navigation repository is not initialised")
|
||||
return NavigationService(uow.navigation)
|
||||
|
||||
|
||||
def get_pricing_evaluator(
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ScenarioPricingEvaluator:
|
||||
@@ -153,6 +162,28 @@ def require_authenticated_user(
|
||||
return user
|
||||
|
||||
|
||||
def require_authenticated_user_html(
|
||||
request: Request,
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
) -> User:
|
||||
"""HTML-aware authenticated dependency that redirects anonymous sessions."""
|
||||
|
||||
user = session.user
|
||||
if user is None or session.tokens.is_empty:
|
||||
login_url = str(request.url_for("auth.login_form"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
headers={"Location": login_url},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled.",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _user_role_names(user: User) -> set[str]:
|
||||
roles: Iterable[Role] = getattr(user, "roles", []) or []
|
||||
return {role.name for role in roles}
|
||||
@@ -186,12 +217,55 @@ def require_any_role(*roles: str) -> Callable[[User], User]:
|
||||
return require_roles(*roles)
|
||||
|
||||
|
||||
def require_project_resource(*, require_manage: bool = False) -> Callable[[int], Project]:
|
||||
def require_roles_html(*roles: str) -> Callable[[Request], User]:
|
||||
"""Ensure user is authenticated for HTML responses; redirect anonymous to login."""
|
||||
|
||||
required = tuple(role.strip() for role in roles if role.strip())
|
||||
if not required:
|
||||
raise ValueError("require_roles_html requires at least one role name")
|
||||
|
||||
def _dependency(
|
||||
request: Request,
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
) -> User:
|
||||
user = session.user
|
||||
if user is None:
|
||||
login_url = str(request.url_for("auth.login_form"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
headers={"Location": login_url},
|
||||
)
|
||||
|
||||
if user.is_superuser:
|
||||
return user
|
||||
|
||||
role_names = _user_role_names(user)
|
||||
if not any(role in role_names for role in required):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this action.",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_any_role_html(*roles: str) -> Callable[[Request], User]:
|
||||
"""Alias of require_roles_html for readability."""
|
||||
|
||||
return require_roles_html(*roles)
|
||||
|
||||
|
||||
def require_project_resource(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int], Project]:
|
||||
"""Dependency factory that resolves a project with authorization checks."""
|
||||
|
||||
def _dependency(
|
||||
project_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Project:
|
||||
try:
|
||||
@@ -216,13 +290,16 @@ def require_project_resource(*, require_manage: bool = False) -> Callable[[int],
|
||||
|
||||
|
||||
def require_scenario_resource(
|
||||
*, require_manage: bool = False, with_children: bool = False
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int], Scenario]:
|
||||
"""Dependency factory that resolves a scenario with authorization checks."""
|
||||
|
||||
def _dependency(
|
||||
scenario_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Scenario:
|
||||
try:
|
||||
@@ -248,14 +325,17 @@ def require_scenario_resource(
|
||||
|
||||
|
||||
def require_project_scenario_resource(
|
||||
*, require_manage: bool = False, with_children: bool = False
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int, int], Scenario]:
|
||||
"""Dependency factory ensuring a scenario belongs to the given project and is accessible."""
|
||||
|
||||
def _dependency(
|
||||
project_id: int,
|
||||
scenario_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Scenario:
|
||||
try:
|
||||
@@ -279,3 +359,42 @@ def require_project_scenario_resource(
|
||||
) from exc
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_project_resource_html(
|
||||
*, require_manage: bool = False
|
||||
) -> Callable[[int], Project]:
|
||||
"""HTML-aware project loader that redirects anonymous sessions."""
|
||||
|
||||
return require_project_resource(
|
||||
require_manage=require_manage,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
|
||||
def require_scenario_resource_html(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
) -> Callable[[int], Scenario]:
|
||||
"""HTML-aware scenario loader that redirects anonymous sessions."""
|
||||
|
||||
return require_scenario_resource(
|
||||
require_manage=require_manage,
|
||||
with_children=with_children,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
|
||||
def require_project_scenario_resource_html(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
) -> Callable[[int, int], Scenario]:
|
||||
"""HTML-aware project-scenario loader redirecting anonymous sessions."""
|
||||
|
||||
return require_project_scenario_resource(
|
||||
require_manage=require_manage,
|
||||
with_children=with_children,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
62
main.py
62
main.py
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from config.settings import get_settings
|
||||
from middleware.auth_session import AuthSessionMiddleware
|
||||
@@ -10,44 +12,26 @@ from middleware.metrics import MetricsMiddleware
|
||||
from middleware.validation import validate_json
|
||||
from routes.auth import router as auth_router
|
||||
from routes.dashboard import router as dashboard_router
|
||||
from routes.calculations import router as calculations_router
|
||||
from routes.imports import router as imports_router
|
||||
from routes.exports import router as exports_router
|
||||
from routes.projects import router as projects_router
|
||||
from routes.reports import router as reports_router
|
||||
from routes.scenarios import router as scenarios_router
|
||||
from routes.ui import router as ui_router
|
||||
from routes.navigation import router as navigation_router
|
||||
from monitoring import router as monitoring_router
|
||||
from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
|
||||
from scripts.init_db import init_db as init_db_script
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(AuthSessionMiddleware)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def json_validation(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
return await validate_json(request, call_next)
|
||||
|
||||
|
||||
@app.get("/health", summary="Container health probe")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def ensure_admin_bootstrap() -> None:
|
||||
async def _bootstrap_startup() -> None:
|
||||
settings = get_settings()
|
||||
admin_settings = settings.admin_bootstrap_settings()
|
||||
pricing_metadata = settings.pricing_metadata()
|
||||
try:
|
||||
# Ensure DB schema/types/seeds required for bootstrapping exist.
|
||||
# The initializer is idempotent and safe to run on every startup.
|
||||
try:
|
||||
init_db_script()
|
||||
except Exception:
|
||||
@@ -64,9 +48,6 @@ async def ensure_admin_bootstrap() -> None:
|
||||
admin_result.password_rotated,
|
||||
admin_result.roles_granted,
|
||||
)
|
||||
# Avoid accessing ORM-managed attributes that may be detached outside
|
||||
# of the UnitOfWork/session scope. Attempt a safe extraction and
|
||||
# fall back to minimal logging if attributes are unavailable.
|
||||
try:
|
||||
seed = pricing_result.seed
|
||||
slug = getattr(seed.settings, "slug", None) if seed and getattr(
|
||||
@@ -92,7 +73,39 @@ async def ensure_admin_bootstrap() -> None:
|
||||
"Failed to bootstrap administrator or pricing settings")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI):
|
||||
await _bootstrap_startup()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=app_lifespan)
|
||||
|
||||
app.add_middleware(AuthSessionMiddleware)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def json_validation(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
return await validate_json(request, call_next)
|
||||
|
||||
|
||||
@app.get("/health", summary="Container health probe")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> Response:
|
||||
static_directory = "static"
|
||||
favicon_img = "favicon.ico"
|
||||
return FileResponse(f"{static_directory}/{favicon_img}")
|
||||
|
||||
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(calculations_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(exports_router)
|
||||
@@ -101,5 +114,6 @@ app.include_router(scenarios_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(ui_router)
|
||||
app.include_router(monitoring_router)
|
||||
app.include_router(navigation_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -145,6 +145,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
session.user = user
|
||||
session.scopes = tuple(payload.scopes)
|
||||
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
|
||||
return True
|
||||
|
||||
def _try_refresh_token(
|
||||
@@ -166,6 +167,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
session.user = user
|
||||
session.scopes = tuple(payload.scopes)
|
||||
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
|
||||
|
||||
access_token = create_access_token(
|
||||
str(user.id),
|
||||
|
||||
@@ -27,16 +27,30 @@ from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .simulation_parameter import SimulationParameter
|
||||
from .user import Role, User, UserRole, password_context
|
||||
from .navigation import NavigationGroup, NavigationLink
|
||||
|
||||
from .profitability_snapshot import ProjectProfitability, ScenarioProfitability
|
||||
from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot
|
||||
from .opex_snapshot import (
|
||||
ProjectOpexSnapshot,
|
||||
ScenarioOpexSnapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FinancialCategory",
|
||||
"FinancialInput",
|
||||
"MiningOperationType",
|
||||
"Project",
|
||||
"ProjectProfitability",
|
||||
"ProjectCapexSnapshot",
|
||||
"ProjectOpexSnapshot",
|
||||
"PricingSettings",
|
||||
"PricingMetalSettings",
|
||||
"PricingImpuritySettings",
|
||||
"Scenario",
|
||||
"ScenarioProfitability",
|
||||
"ScenarioCapexSnapshot",
|
||||
"ScenarioOpexSnapshot",
|
||||
"ScenarioStatus",
|
||||
"DistributionType",
|
||||
"SimulationParameter",
|
||||
@@ -53,4 +67,6 @@ __all__ = [
|
||||
"UserRole",
|
||||
"password_context",
|
||||
"PerformanceMetric",
|
||||
"NavigationGroup",
|
||||
"NavigationLink",
|
||||
]
|
||||
|
||||
111
models/capex_snapshot.py
Normal file
111
models/capex_snapshot.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectCapexSnapshot(Base):
|
||||
"""Snapshot of aggregated capex metrics at the project level."""
|
||||
|
||||
__tablename__ = "project_capex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
total_capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
contingency_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
contingency_amount: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
total_with_contingency: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="capex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ProjectCapexSnapshot(id={id!r}, project_id={project_id!r}, total_capex={total_capex!r})".format(
|
||||
id=self.id, project_id=self.project_id, total_capex=self.total_capex
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ScenarioCapexSnapshot(Base):
|
||||
"""Snapshot of capex metrics for an individual scenario."""
|
||||
|
||||
__tablename__ = "scenario_capex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
total_capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
contingency_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
contingency_amount: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
total_with_contingency: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="capex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ScenarioCapexSnapshot(id={id!r}, scenario_id={scenario_id!r}, total_capex={total_capex!r})".format(
|
||||
id=self.id, scenario_id=self.scenario_id, total_capex=self.total_capex
|
||||
)
|
||||
)
|
||||
125
models/navigation.py
Normal file
125
models/navigation.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.ext.mutable import MutableList
|
||||
from sqlalchemy import JSON
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class NavigationGroup(Base):
|
||||
__tablename__ = "navigation_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slug", name="uq_navigation_groups_slug"),
|
||||
Index("ix_navigation_groups_sort_order", "sort_order"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
tooltip: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
links: Mapped[List["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
back_populates="group",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="NavigationLink.sort_order",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"NavigationGroup(id={self.id!r}, slug={self.slug!r})"
|
||||
|
||||
|
||||
class NavigationLink(Base):
|
||||
__tablename__ = "navigation_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "slug",
|
||||
name="uq_navigation_links_group_slug"),
|
||||
Index("ix_navigation_links_group_sort", "group_id", "sort_order"),
|
||||
Index("ix_navigation_links_parent_sort",
|
||||
"parent_link_id", "sort_order"),
|
||||
CheckConstraint(
|
||||
"(route_name IS NOT NULL) OR (href_override IS NOT NULL)",
|
||||
name="ck_navigation_links_route_or_href",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("navigation_groups.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
parent_link_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("navigation_links.id", ondelete="CASCADE")
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
route_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
href_override: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
match_prefix: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
tooltip: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
required_roles: Mapped[list[str]] = mapped_column(
|
||||
MutableList.as_mutable(JSON), nullable=False, default=list
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
is_external: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
group: Mapped[NavigationGroup] = relationship(
|
||||
NavigationGroup,
|
||||
back_populates="links",
|
||||
)
|
||||
parent: Mapped[Optional["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
remote_side="NavigationLink.id",
|
||||
back_populates="children",
|
||||
)
|
||||
children: Mapped[List["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
back_populates="parent",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="NavigationLink.sort_order",
|
||||
)
|
||||
|
||||
def is_visible_for_roles(self, roles: list[str]) -> bool:
|
||||
if not self.required_roles:
|
||||
return True
|
||||
role_set = set(roles)
|
||||
return any(role in role_set for role in self.required_roles)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"NavigationLink(id={self.id!r}, slug={self.slug!r})"
|
||||
123
models/opex_snapshot.py
Normal file
123
models/opex_snapshot.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectOpexSnapshot(Base):
|
||||
"""Snapshot of recurring opex metrics at the project level."""
|
||||
|
||||
__tablename__ = "project_opex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="opex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ProjectOpexSnapshot(id={id!r}, project_id={project_id!r}, overall_annual={overall_annual!r})".format(
|
||||
id=self.id,
|
||||
project_id=self.project_id,
|
||||
overall_annual=self.overall_annual,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ScenarioOpexSnapshot(Base):
|
||||
"""Snapshot of opex metrics for an individual scenario."""
|
||||
|
||||
__tablename__ = "scenario_opex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="opex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ScenarioOpexSnapshot(id={id!r}, scenario_id={scenario_id!r}, overall_annual={overall_annual!r})".format(
|
||||
id=self.id,
|
||||
scenario_id=self.scenario_id,
|
||||
overall_annual=self.overall_annual,
|
||||
)
|
||||
)
|
||||
133
models/profitability_snapshot.py
Normal file
133
models/profitability_snapshot.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectProfitability(Base):
|
||||
"""Snapshot of aggregated profitability metrics at the project level."""
|
||||
|
||||
__tablename__ = "project_profitability_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
npv: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
irr_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
payback_period_years: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 4), nullable=True
|
||||
)
|
||||
margin_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
revenue_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
opex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="profitability_snapshots")
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ProjectProfitability(id={id!r}, project_id={project_id!r}, npv={npv!r})".format(
|
||||
id=self.id, project_id=self.project_id, npv=self.npv
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ScenarioProfitability(Base):
|
||||
"""Snapshot of profitability metrics for an individual scenario."""
|
||||
|
||||
__tablename__ = "scenario_profitability_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
npv: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
irr_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
payback_period_years: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 4), nullable=True
|
||||
)
|
||||
margin_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
revenue_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
opex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="profitability_snapshots")
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ScenarioProfitability(id={id!r}, scenario_id={scenario_id!r}, npv={npv!r})".format(
|
||||
id=self.id, scenario_id=self.scenario_id, npv=self.npv
|
||||
)
|
||||
)
|
||||
@@ -4,6 +4,9 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from .enums import MiningOperationType, sql_enum
|
||||
from .profitability_snapshot import ProjectProfitability
|
||||
from .capex_snapshot import ProjectCapexSnapshot
|
||||
from .opex_snapshot import ProjectOpexSnapshot
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -51,6 +54,51 @@ class Project(Base):
|
||||
"PricingSettings",
|
||||
back_populates="projects",
|
||||
)
|
||||
profitability_snapshots: Mapped[List["ProjectProfitability"]] = relationship(
|
||||
"ProjectProfitability",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectProfitability.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
capex_snapshots: Mapped[List["ProjectCapexSnapshot"]] = relationship(
|
||||
"ProjectCapexSnapshot",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
opex_snapshots: Mapped[List["ProjectOpexSnapshot"]] = relationship(
|
||||
"ProjectOpexSnapshot",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectOpexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def latest_profitability(self) -> "ProjectProfitability | None":
|
||||
"""Return the most recent profitability snapshot, if any."""
|
||||
|
||||
if not self.profitability_snapshots:
|
||||
return None
|
||||
return self.profitability_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_capex(self) -> "ProjectCapexSnapshot | None":
|
||||
"""Return the most recent capex snapshot, if any."""
|
||||
|
||||
if not self.capex_snapshots:
|
||||
return None
|
||||
return self.capex_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_opex(self) -> "ProjectOpexSnapshot | None":
|
||||
"""Return the most recent opex snapshot, if any."""
|
||||
|
||||
if not self.opex_snapshots:
|
||||
return None
|
||||
return self.opex_snapshots[0]
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
||||
return f"Project(id={self.id!r}, name={self.name!r})"
|
||||
|
||||
@@ -19,6 +19,9 @@ from sqlalchemy.sql import func
|
||||
from config.database import Base
|
||||
from services.currency import normalise_currency
|
||||
from .enums import ResourceType, ScenarioStatus, sql_enum
|
||||
from .profitability_snapshot import ScenarioProfitability
|
||||
from .capex_snapshot import ScenarioCapexSnapshot
|
||||
from .opex_snapshot import ScenarioOpexSnapshot
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .financial_input import FinancialInput
|
||||
@@ -75,6 +78,27 @@ class Scenario(Base):
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
profitability_snapshots: Mapped[List["ScenarioProfitability"]] = relationship(
|
||||
"ScenarioProfitability",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioProfitability.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
capex_snapshots: Mapped[List["ScenarioCapexSnapshot"]] = relationship(
|
||||
"ScenarioCapexSnapshot",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
opex_snapshots: Mapped[List["ScenarioOpexSnapshot"]] = relationship(
|
||||
"ScenarioOpexSnapshot",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioOpexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@validates("currency")
|
||||
def _normalise_currency(self, key: str, value: str | None) -> str | None:
|
||||
@@ -83,3 +107,27 @@ class Scenario(Base):
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"Scenario(id={self.id!r}, name={self.name!r}, project_id={self.project_id!r})"
|
||||
|
||||
@property
|
||||
def latest_profitability(self) -> "ScenarioProfitability | None":
|
||||
"""Return the most recent profitability snapshot for this scenario."""
|
||||
|
||||
if not self.profitability_snapshots:
|
||||
return None
|
||||
return self.profitability_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_capex(self) -> "ScenarioCapexSnapshot | None":
|
||||
"""Return the most recent capex snapshot for this scenario."""
|
||||
|
||||
if not self.capex_snapshots:
|
||||
return None
|
||||
return self.capex_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_opex(self) -> "ScenarioOpexSnapshot | None":
|
||||
"""Return the most recent opex snapshot for this scenario."""
|
||||
|
||||
if not self.opex_snapshots:
|
||||
return None
|
||||
return self.opex_snapshots[0]
|
||||
|
||||
@@ -14,3 +14,4 @@ python-jose
|
||||
python-multipart
|
||||
openpyxl
|
||||
prometheus-client
|
||||
plotly
|
||||
@@ -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"
|
||||
|
||||
1785
routes/calculations.py
Normal file
1785
routes/calculations.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
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,96 +26,10 @@ from services.reporting import (
|
||||
validate_percentiles,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["Reports"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Add custom Jinja2 filters
|
||||
|
||||
|
||||
def format_datetime(value):
|
||||
"""Format a datetime object for display in templates."""
|
||||
if not isinstance(value, datetime):
|
||||
return ""
|
||||
if value.tzinfo is None:
|
||||
# Assume UTC if no timezone
|
||||
from datetime import timezone
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
# Format as readable date/time
|
||||
return value.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def currency_display(value, currency_code):
|
||||
"""Format a numeric value with currency symbol/code."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
# Format the number
|
||||
if isinstance(value, (int, float)):
|
||||
formatted_value = f"{value:,.2f}"
|
||||
else:
|
||||
formatted_value = str(value)
|
||||
|
||||
# Add currency code
|
||||
if currency_code:
|
||||
return f"{currency_code} {formatted_value}"
|
||||
return formatted_value
|
||||
|
||||
|
||||
def format_metric(value, metric_name, currency_code=None):
|
||||
"""Format metric values appropriately based on metric type."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
# For currency-related metrics, use currency formatting
|
||||
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)
|
||||
|
||||
# For percentage metrics
|
||||
percentage_metrics = {'irr', 'payback_period'}
|
||||
if metric_name in percentage_metrics:
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}%"
|
||||
return f"{value}%"
|
||||
|
||||
# Default numeric formatting
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:,.2f}"
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def percentage_display(value):
|
||||
"""Format a value as a percentage."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}%"
|
||||
|
||||
return f"{value}%"
|
||||
|
||||
|
||||
def period_display(value):
|
||||
"""Format a period value (like payback period)."""
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -335,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,
|
||||
@@ -399,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."),
|
||||
@@ -476,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,
|
||||
|
||||
147
routes/template_filters.py
Normal file
147
routes/template_filters.py
Normal file
@@ -0,0 +1,147 @@
|
||||
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",
|
||||
]
|
||||
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,
|
||||
|
||||
346
schemas/calculations.py
Normal file
346
schemas/calculations.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Pydantic schemas for calculation workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator
|
||||
|
||||
from services.pricing import PricingResult
|
||||
|
||||
|
||||
class ImpurityInput(BaseModel):
|
||||
"""Impurity configuration row supplied by the client."""
|
||||
|
||||
name: str = Field(..., min_length=1)
|
||||
value: float | None = Field(None, ge=0)
|
||||
threshold: float | None = Field(None, ge=0)
|
||||
penalty: float | None = Field(None)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _normalise_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class ProfitabilityCalculationRequest(BaseModel):
|
||||
"""Request payload for profitability calculations."""
|
||||
|
||||
metal: str = Field(..., min_length=1)
|
||||
ore_tonnage: PositiveFloat
|
||||
head_grade_pct: float = Field(..., gt=0, le=100)
|
||||
recovery_pct: float = Field(..., gt=0, le=100)
|
||||
payable_pct: float | None = Field(None, gt=0, le=100)
|
||||
reference_price: PositiveFloat
|
||||
treatment_charge: float = Field(0, ge=0)
|
||||
smelting_charge: float = Field(0, ge=0)
|
||||
moisture_pct: float = Field(0, ge=0, le=100)
|
||||
moisture_threshold_pct: float | None = Field(None, ge=0, le=100)
|
||||
moisture_penalty_per_pct: float | None = None
|
||||
premiums: float = Field(0)
|
||||
fx_rate: PositiveFloat = Field(1)
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
opex: float = Field(0, ge=0)
|
||||
sustaining_capex: float = Field(0, ge=0)
|
||||
capex: float = Field(0, ge=0)
|
||||
discount_rate: float | None = Field(None, ge=0, le=100)
|
||||
periods: int = Field(10, ge=1, le=120)
|
||||
impurities: List[ImpurityInput] = Field(default_factory=list)
|
||||
|
||||
@field_validator("currency_code")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("metal")
|
||||
@classmethod
|
||||
def _normalise_metal(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class ProfitabilityCosts(BaseModel):
|
||||
"""Aggregated cost components for profitability output."""
|
||||
|
||||
opex_total: float
|
||||
sustaining_capex_total: float
|
||||
capex: float
|
||||
|
||||
|
||||
class ProfitabilityMetrics(BaseModel):
|
||||
"""Financial KPIs yielded by the profitability calculation."""
|
||||
|
||||
npv: float | None
|
||||
irr: float | None
|
||||
payback_period: float | None
|
||||
margin: float | None
|
||||
|
||||
|
||||
class CashFlowEntry(BaseModel):
|
||||
"""Normalized cash flow row for reporting and charting."""
|
||||
|
||||
period: int
|
||||
revenue: float
|
||||
opex: float
|
||||
sustaining_capex: float
|
||||
net: float
|
||||
|
||||
|
||||
class ProfitabilityCalculationResult(BaseModel):
|
||||
"""Response body summarizing profitability calculation outputs."""
|
||||
|
||||
pricing: PricingResult
|
||||
costs: ProfitabilityCosts
|
||||
metrics: ProfitabilityMetrics
|
||||
cash_flows: list[CashFlowEntry]
|
||||
currency: str | None
|
||||
|
||||
|
||||
class CapexComponentInput(BaseModel):
|
||||
"""Capex component entry supplied by the UI."""
|
||||
|
||||
id: int | None = Field(default=None, ge=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
category: str = Field(..., min_length=1)
|
||||
amount: float = Field(..., ge=0)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
spend_year: int | None = Field(None, ge=0, le=120)
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _normalise_category(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _trim_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CapexParameters(BaseModel):
|
||||
"""Global parameters applied to capex calculations."""
|
||||
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
contingency_pct: float | None = Field(0, ge=0, le=100)
|
||||
discount_rate_pct: float | None = Field(None, ge=0, le=100)
|
||||
evaluation_horizon_years: int | None = Field(10, ge=1, le=100)
|
||||
|
||||
@field_validator("currency_code")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class CapexCalculationOptions(BaseModel):
|
||||
"""Optional behaviour flags for capex calculations."""
|
||||
|
||||
persist: bool = False
|
||||
|
||||
|
||||
class CapexCalculationRequest(BaseModel):
|
||||
"""Request payload for capex aggregation."""
|
||||
|
||||
components: List[CapexComponentInput] = Field(default_factory=list)
|
||||
parameters: CapexParameters = Field(
|
||||
default_factory=CapexParameters, # type: ignore[arg-type]
|
||||
)
|
||||
options: CapexCalculationOptions = Field(
|
||||
default_factory=CapexCalculationOptions, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class CapexCategoryBreakdown(BaseModel):
|
||||
"""Breakdown entry describing category totals."""
|
||||
|
||||
category: str
|
||||
amount: float = Field(..., ge=0)
|
||||
share: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class CapexTotals(BaseModel):
|
||||
"""Aggregated totals for capex workflows."""
|
||||
|
||||
overall: float = Field(..., ge=0)
|
||||
contingency_pct: float = Field(0, ge=0, le=100)
|
||||
contingency_amount: float = Field(..., ge=0)
|
||||
with_contingency: float = Field(..., ge=0)
|
||||
by_category: List[CapexCategoryBreakdown] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CapexTimelineEntry(BaseModel):
|
||||
"""Spend profile entry grouped by year."""
|
||||
|
||||
year: int
|
||||
spend: float = Field(..., ge=0)
|
||||
cumulative: float = Field(..., ge=0)
|
||||
|
||||
|
||||
class CapexCalculationResult(BaseModel):
|
||||
"""Response body for capex calculations."""
|
||||
|
||||
totals: CapexTotals
|
||||
timeline: List[CapexTimelineEntry] = Field(default_factory=list)
|
||||
components: List[CapexComponentInput] = Field(default_factory=list)
|
||||
parameters: CapexParameters
|
||||
options: CapexCalculationOptions
|
||||
currency: str | None
|
||||
|
||||
|
||||
class OpexComponentInput(BaseModel):
|
||||
"""opex component entry supplied by the UI."""
|
||||
|
||||
id: int | None = Field(default=None, ge=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
category: str = Field(..., min_length=1)
|
||||
unit_cost: float = Field(..., ge=0)
|
||||
quantity: float = Field(..., ge=0)
|
||||
frequency: str = Field(..., min_length=1)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
period_start: int | None = Field(None, ge=0, le=240)
|
||||
period_end: int | None = Field(None, ge=0, le=240)
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _normalise_category(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@field_validator("frequency")
|
||||
@classmethod
|
||||
def _normalise_frequency(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _trim_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class OpexParameters(BaseModel):
|
||||
"""Global parameters applied to opex calculations."""
|
||||
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
escalation_pct: float | None = Field(None, ge=0, le=100)
|
||||
discount_rate_pct: float | None = Field(None, ge=0, le=100)
|
||||
evaluation_horizon_years: int | None = Field(10, ge=1, le=100)
|
||||
apply_escalation: bool = True
|
||||
|
||||
@field_validator("currency_code")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class OpexOptions(BaseModel):
|
||||
"""Optional behaviour flags for opex calculations."""
|
||||
|
||||
persist: bool = False
|
||||
snapshot_notes: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class OpexCalculationRequest(BaseModel):
|
||||
"""Request payload for opex aggregation."""
|
||||
|
||||
components: List[OpexComponentInput] = Field(
|
||||
default_factory=list)
|
||||
parameters: OpexParameters = Field(
|
||||
default_factory=OpexParameters, # type: ignore[arg-type]
|
||||
)
|
||||
options: OpexOptions = Field(
|
||||
default_factory=OpexOptions, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class OpexCategoryBreakdown(BaseModel):
|
||||
"""Category breakdown for opex totals."""
|
||||
|
||||
category: str
|
||||
annual_cost: float = Field(..., ge=0)
|
||||
share: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class OpexTimelineEntry(BaseModel):
|
||||
"""Timeline entry representing cost over evaluation periods."""
|
||||
|
||||
period: int
|
||||
base_cost: float = Field(..., ge=0)
|
||||
escalated_cost: float | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class OpexMetrics(BaseModel):
|
||||
"""Derived KPIs for opex outputs."""
|
||||
|
||||
annual_average: float | None
|
||||
cost_per_ton: float | None
|
||||
|
||||
|
||||
class OpexTotals(BaseModel):
|
||||
"""Aggregated totals for opex."""
|
||||
|
||||
overall_annual: float = Field(..., ge=0)
|
||||
escalated_total: float | None = Field(None, ge=0)
|
||||
escalation_pct: float | None = Field(None, ge=0, le=100)
|
||||
by_category: List[OpexCategoryBreakdown] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class OpexCalculationResult(BaseModel):
|
||||
"""Response body summarising opex calculations."""
|
||||
|
||||
totals: OpexTotals
|
||||
timeline: List[OpexTimelineEntry] = Field(default_factory=list)
|
||||
metrics: OpexMetrics
|
||||
components: List[OpexComponentInput] = Field(
|
||||
default_factory=list)
|
||||
parameters: OpexParameters
|
||||
options: OpexOptions
|
||||
currency: str | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ImpurityInput",
|
||||
"ProfitabilityCalculationRequest",
|
||||
"ProfitabilityCosts",
|
||||
"ProfitabilityMetrics",
|
||||
"CashFlowEntry",
|
||||
"ProfitabilityCalculationResult",
|
||||
"CapexComponentInput",
|
||||
"CapexParameters",
|
||||
"CapexCalculationOptions",
|
||||
"CapexCalculationRequest",
|
||||
"CapexCategoryBreakdown",
|
||||
"CapexTotals",
|
||||
"CapexTimelineEntry",
|
||||
"CapexCalculationResult",
|
||||
"OpexComponentInput",
|
||||
"OpexParameters",
|
||||
"OpexOptions",
|
||||
"OpexCalculationRequest",
|
||||
"OpexCategoryBreakdown",
|
||||
"OpexTimelineEntry",
|
||||
"OpexMetrics",
|
||||
"OpexTotals",
|
||||
"OpexCalculationResult",
|
||||
"ValidationError",
|
||||
]
|
||||
36
schemas/navigation.py
Normal file
36
schemas/navigation.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NavigationLinkSchema(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
href: str
|
||||
match_prefix: str | None = Field(default=None)
|
||||
icon: str | None = Field(default=None)
|
||||
tooltip: str | None = Field(default=None)
|
||||
is_external: bool = Field(default=False)
|
||||
children: List["NavigationLinkSchema"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NavigationGroupSchema(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
icon: str | None = Field(default=None)
|
||||
tooltip: str | None = Field(default=None)
|
||||
links: List[NavigationLinkSchema] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NavigationSidebarResponse(BaseModel):
|
||||
groups: List[NavigationGroupSchema]
|
||||
roles: List[str] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
NavigationLinkSchema.model_rebuild()
|
||||
NavigationGroupSchema.model_rebuild()
|
||||
NavigationSidebarResponse.model_rebuild()
|
||||
112
scripts/_route_verification.py
Normal file
112
scripts/_route_verification.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Utility script to verify key authenticated routes respond without errors."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8000
|
||||
|
||||
cookies: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _update_cookies(headers: List[Tuple[str, str]]) -> None:
|
||||
for name, value in headers:
|
||||
if name.lower() != "set-cookie":
|
||||
continue
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(value)
|
||||
for key, morsel in cookie.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
|
||||
def _cookie_header() -> str | None:
|
||||
if not cookies:
|
||||
return None
|
||||
return "; ".join(f"{key}={value}" for key, value in cookies.items())
|
||||
|
||||
|
||||
def request(method: str, path: str, *, body: bytes | None = None, headers: Dict[str, str] | None = None) -> Tuple[int, Dict[str, str], bytes]:
|
||||
conn = HTTPConnection(HOST, PORT, timeout=10)
|
||||
prepared_headers = {"User-Agent": "route-checker"}
|
||||
if headers:
|
||||
prepared_headers.update(headers)
|
||||
cookie_header = _cookie_header()
|
||||
if cookie_header:
|
||||
prepared_headers["Cookie"] = cookie_header
|
||||
|
||||
conn.request(method, path, body=body, headers=prepared_headers)
|
||||
resp = conn.getresponse()
|
||||
payload = resp.read()
|
||||
status = resp.status
|
||||
reason = resp.reason
|
||||
response_headers = {name: value for name, value in resp.getheaders()}
|
||||
_update_cookies(list(resp.getheaders()))
|
||||
conn.close()
|
||||
print(f"{method} {path} -> {status} {reason}")
|
||||
return status, response_headers, payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
status, _, _ = request("GET", "/login")
|
||||
if status != 200:
|
||||
print("Unexpected status for GET /login", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
|
||||
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "M11ffpgm.")
|
||||
login_payload = urllib.parse.urlencode(
|
||||
{"username": admin_username, "password": admin_password}
|
||||
).encode()
|
||||
status, headers, _ = request(
|
||||
"POST",
|
||||
"/login",
|
||||
body=login_payload,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if status not in {200, 303}:
|
||||
print("Login failed", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
location = headers.get("Location", "/")
|
||||
redirect_path = urllib.parse.urlsplit(location).path or "/"
|
||||
request("GET", redirect_path)
|
||||
|
||||
request("GET", "/")
|
||||
request("GET", "/projects/ui")
|
||||
|
||||
status, headers, body = request(
|
||||
"GET",
|
||||
"/projects",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
projects: List[dict] = []
|
||||
if headers.get("Content-Type", "").startswith("application/json"):
|
||||
projects = json.loads(body.decode())
|
||||
|
||||
if projects:
|
||||
project_id = projects[0]["id"]
|
||||
request("GET", f"/projects/{project_id}/view")
|
||||
status, headers, body = request(
|
||||
"GET",
|
||||
f"/projects/{project_id}/scenarios",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
scenarios: List[dict] = []
|
||||
if headers.get("Content-Type", "").startswith("application/json"):
|
||||
scenarios = json.loads(body.decode())
|
||||
if scenarios:
|
||||
scenario_id = scenarios[0]["id"]
|
||||
request("GET", f"/scenarios/{scenario_id}/view")
|
||||
|
||||
print("Cookies:", cookies)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
15
scripts/apply_users_sequence_fix.py
Normal file
15
scripts/apply_users_sequence_fix.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
from config.database import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, future=True)
|
||||
sqls = [
|
||||
"CREATE SEQUENCE IF NOT EXISTS users_id_seq;",
|
||||
"ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq');",
|
||||
"SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1));",
|
||||
"ALTER SEQUENCE users_id_seq OWNED BY users.id;",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
for s in sqls:
|
||||
print('EXECUTING:', s)
|
||||
conn.execute(text(s))
|
||||
print('SEQUENCE fix applied')
|
||||
@@ -22,10 +22,11 @@ import os
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy import create_engine, text
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import JSON, create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.sql import bindparam
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
password_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
@@ -116,6 +117,40 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon TEXT,
|
||||
tooltip TEXT,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
|
||||
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
route_name TEXT,
|
||||
href_override TEXT,
|
||||
match_prefix TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon TEXT,
|
||||
tooltip TEXT,
|
||||
required_roles TEXT NOT NULL DEFAULT '[]',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_external INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (group_id, slug)
|
||||
);
|
||||
""",
|
||||
# pricing_settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pricing_settings (
|
||||
@@ -268,6 +303,41 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
CONSTRAINT uq_user_roles_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon VARCHAR(64),
|
||||
tooltip VARCHAR(255),
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT uq_navigation_groups_slug UNIQUE (slug)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
|
||||
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
route_name VARCHAR(128),
|
||||
href_override VARCHAR(512),
|
||||
match_prefix VARCHAR(512),
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon VARCHAR(64),
|
||||
tooltip VARCHAR(255),
|
||||
required_roles JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
is_external BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT uq_navigation_links_group_slug UNIQUE (group_id, slug)
|
||||
);
|
||||
""",
|
||||
# pricing_settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pricing_settings (
|
||||
@@ -384,6 +454,9 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
|
||||
|
||||
# Seeds
|
||||
TABLE_DDLS: List[str] = _get_table_ddls(is_sqlite=False)
|
||||
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
{"id": 1, "name": "admin", "display_name": "Administrator",
|
||||
"description": "Full platform access with user management rights."},
|
||||
@@ -451,7 +524,7 @@ class UserSeed(BaseModel):
|
||||
is_active: bool = True
|
||||
is_superuser: bool = False
|
||||
|
||||
@validator("password")
|
||||
@field_validator("password")
|
||||
def password_min_len(cls, v: str) -> str:
|
||||
if not v or len(v) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
@@ -468,6 +541,230 @@ class PricingSeed(BaseModel):
|
||||
moisture_penalty_per_pct: float
|
||||
|
||||
|
||||
class NavigationGroupSeed(BaseModel):
|
||||
slug: str
|
||||
label: str
|
||||
sort_order: int = 100
|
||||
icon: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class NavigationLinkSeed(BaseModel):
|
||||
slug: str
|
||||
group_slug: str
|
||||
label: str
|
||||
route_name: Optional[str] = None
|
||||
href_override: Optional[str] = None
|
||||
match_prefix: Optional[str] = None
|
||||
sort_order: int = 100
|
||||
icon: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
required_roles: list[str] = Field(default_factory=list)
|
||||
is_enabled: bool = True
|
||||
is_external: bool = False
|
||||
parent_slug: Optional[str] = None
|
||||
|
||||
@field_validator("required_roles", mode="after")
|
||||
def _normalise_roles(cls, value: list[str]) -> list[str]:
|
||||
normalised = []
|
||||
for role in value:
|
||||
if not role:
|
||||
continue
|
||||
slug = role.strip().lower()
|
||||
if slug and slug not in normalised:
|
||||
normalised.append(slug)
|
||||
return normalised
|
||||
|
||||
@field_validator("route_name")
|
||||
def _route_or_href(cls, value: Optional[str], info):
|
||||
href = info.data.get("href_override")
|
||||
if not value and not href:
|
||||
raise ValueError(
|
||||
"navigation link requires route_name or href_override")
|
||||
return value
|
||||
|
||||
|
||||
DEFAULT_NAVIGATION_GROUPS: list[NavigationGroupSeed] = [
|
||||
NavigationGroupSeed(
|
||||
slug="workspace",
|
||||
label="Workspace",
|
||||
sort_order=10,
|
||||
icon="briefcase",
|
||||
tooltip="Primary work hub",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="insights",
|
||||
label="Insights",
|
||||
sort_order=20,
|
||||
icon="insights",
|
||||
tooltip="Analytics and reports",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="configuration",
|
||||
label="Configuration",
|
||||
sort_order=30,
|
||||
icon="cog",
|
||||
tooltip="Administration and settings",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="account",
|
||||
label="Account",
|
||||
sort_order=40,
|
||||
icon="user",
|
||||
tooltip="Session management",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_NAVIGATION_LINKS: list[NavigationLinkSeed] = [
|
||||
NavigationLinkSeed(
|
||||
slug="dashboard",
|
||||
group_slug="workspace",
|
||||
label="Dashboard",
|
||||
route_name="dashboard.home",
|
||||
match_prefix="/",
|
||||
sort_order=10,
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="projects",
|
||||
group_slug="workspace",
|
||||
label="Projects",
|
||||
route_name="projects.project_list_page",
|
||||
match_prefix="/projects",
|
||||
sort_order=20,
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="project-create",
|
||||
group_slug="workspace",
|
||||
label="New Project",
|
||||
route_name="projects.create_project_form",
|
||||
match_prefix="/projects/create",
|
||||
sort_order=30,
|
||||
required_roles=["project_manager", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="imports",
|
||||
group_slug="workspace",
|
||||
label="Imports",
|
||||
href_override="/imports/ui",
|
||||
match_prefix="/imports",
|
||||
sort_order=40,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="profitability",
|
||||
group_slug="workspace",
|
||||
label="Profitability Calculator",
|
||||
route_name="calculations.profitability_form",
|
||||
match_prefix="/calculations/profitability",
|
||||
sort_order=50,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="opex",
|
||||
group_slug="workspace",
|
||||
label="Opex Planner",
|
||||
route_name="calculations.opex_form",
|
||||
match_prefix="/calculations/opex",
|
||||
sort_order=60,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="capex",
|
||||
group_slug="workspace",
|
||||
label="Capex Planner",
|
||||
route_name="calculations.capex_form",
|
||||
match_prefix="/calculations/capex",
|
||||
sort_order=70,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="simulations",
|
||||
group_slug="insights",
|
||||
label="Simulations",
|
||||
href_override="/ui/simulations",
|
||||
match_prefix="/ui/simulations",
|
||||
sort_order=10,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="reporting",
|
||||
group_slug="insights",
|
||||
label="Reporting",
|
||||
href_override="/ui/reporting",
|
||||
match_prefix="/ui/reporting",
|
||||
sort_order=20,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="settings",
|
||||
group_slug="configuration",
|
||||
label="Settings",
|
||||
href_override="/ui/settings",
|
||||
match_prefix="/ui/settings",
|
||||
sort_order=10,
|
||||
required_roles=["admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="themes",
|
||||
group_slug="configuration",
|
||||
label="Themes",
|
||||
href_override="/theme-settings",
|
||||
match_prefix="/theme-settings",
|
||||
sort_order=20,
|
||||
required_roles=["admin"],
|
||||
parent_slug="settings",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="currencies",
|
||||
group_slug="configuration",
|
||||
label="Currency Management",
|
||||
href_override="/ui/currencies",
|
||||
match_prefix="/ui/currencies",
|
||||
sort_order=30,
|
||||
required_roles=["admin"],
|
||||
parent_slug="settings",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="logout",
|
||||
group_slug="account",
|
||||
label="Logout",
|
||||
route_name="auth.logout",
|
||||
match_prefix="/logout",
|
||||
sort_order=10,
|
||||
required_roles=["viewer", "analyst", "project_manager", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="login",
|
||||
group_slug="account",
|
||||
label="Login",
|
||||
route_name="auth.login_form",
|
||||
match_prefix="/login",
|
||||
sort_order=10,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="register",
|
||||
group_slug="account",
|
||||
label="Register",
|
||||
route_name="auth.register_form",
|
||||
match_prefix="/register",
|
||||
sort_order=20,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="forgot-password",
|
||||
group_slug="account",
|
||||
label="Forgot Password",
|
||||
route_name="auth.password_reset_request_form",
|
||||
match_prefix="/forgot-password",
|
||||
sort_order=30,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_PROJECTS: list[ProjectSeed] = [
|
||||
ProjectSeed(
|
||||
name="Helios Copper",
|
||||
@@ -525,7 +822,7 @@ DEFAULT_FINANCIAL_INPUTS: list[FinancialInputSeed] = [
|
||||
FinancialInputSeed(
|
||||
project_name="Helios Copper",
|
||||
scenario_name="Base Case",
|
||||
name="Processing Opex",
|
||||
name="Opex",
|
||||
category="opex",
|
||||
cost_bucket="operating_variable",
|
||||
amount=Decimal("75000000"),
|
||||
@@ -784,6 +1081,198 @@ def ensure_default_pricing(engine: Engine, is_sqlite: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def seed_navigation(engine: Engine, is_sqlite: bool) -> None:
|
||||
group_insert_sql = text(
|
||||
"""
|
||||
INSERT INTO navigation_groups (slug, label, sort_order, icon, tooltip, is_enabled)
|
||||
VALUES (:slug, :label, :sort_order, :icon, :tooltip, :is_enabled)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
label = EXCLUDED.label,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
icon = EXCLUDED.icon,
|
||||
tooltip = EXCLUDED.tooltip,
|
||||
is_enabled = EXCLUDED.is_enabled
|
||||
"""
|
||||
)
|
||||
|
||||
link_insert_sql = text(
|
||||
f"""
|
||||
INSERT INTO navigation_links (
|
||||
group_id, parent_link_id, slug, label, route_name, href_override,
|
||||
match_prefix, sort_order, icon, tooltip, required_roles, is_enabled, is_external
|
||||
)
|
||||
VALUES (
|
||||
:group_id, :parent_link_id, :slug, :label, :route_name, :href_override,
|
||||
:match_prefix, :sort_order, :icon, :tooltip, :required_roles, :is_enabled, :is_external
|
||||
)
|
||||
ON CONFLICT (group_id, slug) DO UPDATE SET
|
||||
parent_link_id = EXCLUDED.parent_link_id,
|
||||
label = EXCLUDED.label,
|
||||
route_name = EXCLUDED.route_name,
|
||||
href_override = EXCLUDED.href_override,
|
||||
match_prefix = EXCLUDED.match_prefix,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
icon = EXCLUDED.icon,
|
||||
tooltip = EXCLUDED.tooltip,
|
||||
required_roles = EXCLUDED.required_roles,
|
||||
is_enabled = EXCLUDED.is_enabled,
|
||||
is_external = EXCLUDED.is_external
|
||||
"""
|
||||
)
|
||||
link_insert_sql = link_insert_sql.bindparams(
|
||||
bindparam("required_roles", type_=JSON)
|
||||
)
|
||||
|
||||
with engine.begin() as conn:
|
||||
role_rows = conn.execute(text("SELECT name FROM roles")).fetchall()
|
||||
available_roles = {row.name for row in role_rows}
|
||||
|
||||
def resolve_roles(raw_roles: list[str]) -> list[str]:
|
||||
if not raw_roles:
|
||||
return []
|
||||
|
||||
resolved: list[str] = []
|
||||
missing: list[str] = []
|
||||
for slug in raw_roles:
|
||||
if slug == "anonymous":
|
||||
if slug not in resolved:
|
||||
resolved.append(slug)
|
||||
continue
|
||||
if slug in available_roles:
|
||||
if slug not in resolved:
|
||||
resolved.append(slug)
|
||||
else:
|
||||
missing.append(slug)
|
||||
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Navigation seed roles %s are missing; defaulting link access to admin only",
|
||||
", ".join(missing),
|
||||
)
|
||||
if "admin" in available_roles and "admin" not in resolved:
|
||||
resolved.append("admin")
|
||||
|
||||
return resolved
|
||||
|
||||
group_ids: dict[str, int] = {}
|
||||
for group_seed in DEFAULT_NAVIGATION_GROUPS:
|
||||
conn.execute(
|
||||
group_insert_sql,
|
||||
group_seed.model_dump(),
|
||||
)
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM navigation_groups WHERE slug = :slug"),
|
||||
{"slug": group_seed.slug},
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
group_ids[group_seed.slug] = row.id
|
||||
|
||||
if not group_ids:
|
||||
logger.warning(
|
||||
"Navigation seeding skipped because no groups were inserted")
|
||||
return
|
||||
|
||||
link_ids: dict[tuple[str, str], int] = {}
|
||||
parent_pending: list[NavigationLinkSeed] = []
|
||||
|
||||
for link_seed in DEFAULT_NAVIGATION_LINKS:
|
||||
if link_seed.parent_slug:
|
||||
parent_pending.append(link_seed)
|
||||
continue
|
||||
|
||||
group_id = group_ids.get(link_seed.group_slug)
|
||||
if group_id is None:
|
||||
logger.warning(
|
||||
"Skipping navigation link '%s' because group '%s' is missing",
|
||||
link_seed.slug,
|
||||
link_seed.group_slug,
|
||||
)
|
||||
continue
|
||||
|
||||
resolved_roles = resolve_roles(link_seed.required_roles)
|
||||
|
||||
payload = {
|
||||
"group_id": group_id,
|
||||
"parent_link_id": None,
|
||||
"slug": link_seed.slug,
|
||||
"label": link_seed.label,
|
||||
"route_name": link_seed.route_name,
|
||||
"href_override": link_seed.href_override,
|
||||
"match_prefix": link_seed.match_prefix,
|
||||
"sort_order": link_seed.sort_order,
|
||||
"icon": link_seed.icon,
|
||||
"tooltip": link_seed.tooltip,
|
||||
"required_roles": resolved_roles,
|
||||
"is_enabled": link_seed.is_enabled,
|
||||
"is_external": link_seed.is_external,
|
||||
}
|
||||
conn.execute(link_insert_sql, payload)
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
|
||||
),
|
||||
{"group_id": group_id, "slug": link_seed.slug},
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
link_ids[(link_seed.group_slug, link_seed.slug)] = row.id
|
||||
|
||||
for link_seed in parent_pending:
|
||||
group_id = group_ids.get(link_seed.group_slug)
|
||||
if group_id is None:
|
||||
logger.warning(
|
||||
"Skipping child navigation link '%s' because group '%s' is missing",
|
||||
link_seed.slug,
|
||||
link_seed.group_slug,
|
||||
)
|
||||
continue
|
||||
|
||||
parent_key = (link_seed.group_slug, link_seed.parent_slug or "")
|
||||
parent_id = link_ids.get(parent_key)
|
||||
if parent_id is None:
|
||||
parent_row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
|
||||
),
|
||||
{"group_id": group_id, "slug": link_seed.parent_slug},
|
||||
).fetchone()
|
||||
parent_id = parent_row.id if parent_row else None
|
||||
|
||||
if parent_id is None:
|
||||
logger.warning(
|
||||
"Skipping child navigation link '%s' because parent '%s' is missing",
|
||||
link_seed.slug,
|
||||
link_seed.parent_slug,
|
||||
)
|
||||
continue
|
||||
|
||||
resolved_roles = resolve_roles(link_seed.required_roles)
|
||||
|
||||
payload = {
|
||||
"group_id": group_id,
|
||||
"parent_link_id": parent_id,
|
||||
"slug": link_seed.slug,
|
||||
"label": link_seed.label,
|
||||
"route_name": link_seed.route_name,
|
||||
"href_override": link_seed.href_override,
|
||||
"match_prefix": link_seed.match_prefix,
|
||||
"sort_order": link_seed.sort_order,
|
||||
"icon": link_seed.icon,
|
||||
"tooltip": link_seed.tooltip,
|
||||
"required_roles": resolved_roles,
|
||||
"is_enabled": link_seed.is_enabled,
|
||||
"is_external": link_seed.is_external,
|
||||
}
|
||||
conn.execute(link_insert_sql, payload)
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
|
||||
),
|
||||
{"group_id": group_id, "slug": link_seed.slug},
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
link_ids[(link_seed.group_slug, link_seed.slug)] = row.id
|
||||
|
||||
|
||||
def _project_id_by_name(conn, project_name: str) -> Optional[int]:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM projects WHERE name = :name"),
|
||||
@@ -960,6 +1449,7 @@ def init_db(database_url: Optional[str] = None) -> None:
|
||||
seed_roles(engine, is_sqlite)
|
||||
seed_admin_user(engine, is_sqlite)
|
||||
ensure_default_pricing(engine, is_sqlite)
|
||||
seed_navigation(engine, is_sqlite)
|
||||
ensure_default_projects(engine, is_sqlite)
|
||||
ensure_default_scenarios(engine, is_sqlite)
|
||||
ensure_default_financial_inputs(engine, is_sqlite)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Service layer utilities."""
|
||||
|
||||
from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult
|
||||
from .calculations import calculate_profitability
|
||||
|
||||
__all__ = [
|
||||
"calculate_pricing",
|
||||
"PricingInput",
|
||||
"PricingMetadata",
|
||||
"PricingResult",
|
||||
"calculate_profitability",
|
||||
]
|
||||
|
||||
536
services/calculations.py
Normal file
536
services/calculations.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""Service functions for financial calculations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from statistics import fmean
|
||||
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
from services.exceptions import (
|
||||
CapexValidationError,
|
||||
OpexValidationError,
|
||||
ProfitabilityValidationError,
|
||||
)
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
ConvergenceError,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
payback_period,
|
||||
)
|
||||
from services.pricing import PricingInput, PricingMetadata, PricingResult, calculate_pricing
|
||||
from schemas.calculations import (
|
||||
CapexCalculationRequest,
|
||||
CapexCalculationResult,
|
||||
CapexCategoryBreakdown,
|
||||
CapexComponentInput,
|
||||
CapexParameters,
|
||||
CapexTotals,
|
||||
CapexTimelineEntry,
|
||||
CashFlowEntry,
|
||||
OpexCalculationRequest,
|
||||
OpexCalculationResult,
|
||||
OpexCategoryBreakdown,
|
||||
OpexComponentInput,
|
||||
OpexMetrics,
|
||||
OpexParameters,
|
||||
OpexTotals,
|
||||
OpexTimelineEntry,
|
||||
ProfitabilityCalculationRequest,
|
||||
ProfitabilityCalculationResult,
|
||||
ProfitabilityCosts,
|
||||
ProfitabilityMetrics,
|
||||
)
|
||||
|
||||
|
||||
_FREQUENCY_MULTIPLIER = {
|
||||
"daily": 365,
|
||||
"weekly": 52,
|
||||
"monthly": 12,
|
||||
"quarterly": 4,
|
||||
"annually": 1,
|
||||
}
|
||||
|
||||
|
||||
def _build_pricing_input(
|
||||
request: ProfitabilityCalculationRequest,
|
||||
) -> PricingInput:
|
||||
"""Construct a pricing input instance including impurity overrides."""
|
||||
|
||||
impurity_values: dict[str, float] = {}
|
||||
impurity_thresholds: dict[str, float] = {}
|
||||
impurity_penalties: dict[str, float] = {}
|
||||
|
||||
for impurity in request.impurities:
|
||||
code = impurity.name.strip()
|
||||
if not code:
|
||||
continue
|
||||
code = code.upper()
|
||||
if impurity.value is not None:
|
||||
impurity_values[code] = float(impurity.value)
|
||||
if impurity.threshold is not None:
|
||||
impurity_thresholds[code] = float(impurity.threshold)
|
||||
if impurity.penalty is not None:
|
||||
impurity_penalties[code] = float(impurity.penalty)
|
||||
|
||||
pricing_input = PricingInput(
|
||||
metal=request.metal,
|
||||
ore_tonnage=request.ore_tonnage,
|
||||
head_grade_pct=request.head_grade_pct,
|
||||
recovery_pct=request.recovery_pct,
|
||||
payable_pct=request.payable_pct,
|
||||
reference_price=request.reference_price,
|
||||
treatment_charge=request.treatment_charge,
|
||||
smelting_charge=request.smelting_charge,
|
||||
moisture_pct=request.moisture_pct,
|
||||
moisture_threshold_pct=request.moisture_threshold_pct,
|
||||
moisture_penalty_per_pct=request.moisture_penalty_per_pct,
|
||||
impurity_ppm=impurity_values,
|
||||
impurity_thresholds=impurity_thresholds,
|
||||
impurity_penalty_per_ppm=impurity_penalties,
|
||||
premiums=request.premiums,
|
||||
fx_rate=request.fx_rate,
|
||||
currency_code=request.currency_code,
|
||||
)
|
||||
|
||||
return pricing_input
|
||||
|
||||
|
||||
def _generate_cash_flows(
|
||||
*,
|
||||
periods: int,
|
||||
net_per_period: float,
|
||||
capex: float,
|
||||
) -> tuple[list[CashFlow], list[CashFlowEntry]]:
|
||||
"""Create cash flow structures for financial metric calculations."""
|
||||
|
||||
cash_flow_models: list[CashFlow] = [
|
||||
CashFlow(amount=-capex, period_index=0)
|
||||
]
|
||||
cash_flow_entries: list[CashFlowEntry] = [
|
||||
CashFlowEntry(
|
||||
period=0,
|
||||
revenue=0.0,
|
||||
opex=0.0,
|
||||
sustaining_capex=0.0,
|
||||
net=-capex,
|
||||
)
|
||||
]
|
||||
|
||||
for period in range(1, periods + 1):
|
||||
cash_flow_models.append(
|
||||
CashFlow(amount=net_per_period, period_index=period))
|
||||
cash_flow_entries.append(
|
||||
CashFlowEntry(
|
||||
period=period,
|
||||
revenue=0.0,
|
||||
opex=0.0,
|
||||
sustaining_capex=0.0,
|
||||
net=net_per_period,
|
||||
)
|
||||
)
|
||||
|
||||
return cash_flow_models, cash_flow_entries
|
||||
|
||||
|
||||
def calculate_profitability(
|
||||
request: ProfitabilityCalculationRequest,
|
||||
*,
|
||||
metadata: PricingMetadata,
|
||||
) -> ProfitabilityCalculationResult:
|
||||
"""Calculate profitability metrics using pricing inputs and cost data."""
|
||||
|
||||
if request.periods <= 0:
|
||||
raise ProfitabilityValidationError(
|
||||
"Evaluation periods must be at least 1.", ["periods"]
|
||||
)
|
||||
|
||||
pricing_input = _build_pricing_input(request)
|
||||
try:
|
||||
pricing_result: PricingResult = calculate_pricing(
|
||||
pricing_input, metadata=metadata
|
||||
)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ProfitabilityValidationError(
|
||||
str(exc), ["currency_code"]) from exc
|
||||
|
||||
periods = request.periods
|
||||
revenue_total = float(pricing_result.net_revenue)
|
||||
revenue_per_period = revenue_total / periods
|
||||
|
||||
processing_total = float(request.opex) * periods
|
||||
sustaining_total = float(request.sustaining_capex) * periods
|
||||
capex = float(request.capex)
|
||||
|
||||
net_per_period = (
|
||||
revenue_per_period
|
||||
- float(request.opex)
|
||||
- float(request.sustaining_capex)
|
||||
)
|
||||
|
||||
cash_flow_models, cash_flow_entries = _generate_cash_flows(
|
||||
periods=periods,
|
||||
net_per_period=net_per_period,
|
||||
capex=capex,
|
||||
)
|
||||
|
||||
# Update per-period entries to include explicit costs for presentation
|
||||
for entry in cash_flow_entries[1:]:
|
||||
entry.revenue = revenue_per_period
|
||||
entry.opex = float(request.opex)
|
||||
entry.sustaining_capex = float(request.sustaining_capex)
|
||||
entry.net = net_per_period
|
||||
|
||||
discount_rate = (request.discount_rate or 0.0) / 100.0
|
||||
|
||||
npv_value = net_present_value(discount_rate, cash_flow_models)
|
||||
|
||||
try:
|
||||
irr_value = internal_rate_of_return(cash_flow_models) * 100.0
|
||||
except (ValueError, ZeroDivisionError, ConvergenceError):
|
||||
irr_value = None
|
||||
|
||||
try:
|
||||
payback_value = payback_period(cash_flow_models)
|
||||
except (ValueError, PaybackNotReachedError):
|
||||
payback_value = None
|
||||
|
||||
total_costs = processing_total + sustaining_total + capex
|
||||
total_net = revenue_total - total_costs
|
||||
|
||||
if revenue_total == 0:
|
||||
margin_value = None
|
||||
else:
|
||||
margin_value = (total_net / revenue_total) * 100.0
|
||||
|
||||
currency = request.currency_code or pricing_result.currency
|
||||
try:
|
||||
currency = normalise_currency(currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ProfitabilityValidationError(
|
||||
str(exc), ["currency_code"]) from exc
|
||||
|
||||
costs = ProfitabilityCosts(
|
||||
opex_total=processing_total,
|
||||
sustaining_capex_total=sustaining_total,
|
||||
capex=capex,
|
||||
)
|
||||
|
||||
metrics = ProfitabilityMetrics(
|
||||
npv=npv_value,
|
||||
irr=irr_value,
|
||||
payback_period=payback_value,
|
||||
margin=margin_value,
|
||||
)
|
||||
|
||||
return ProfitabilityCalculationResult(
|
||||
pricing=pricing_result,
|
||||
costs=costs,
|
||||
metrics=metrics,
|
||||
cash_flows=cash_flow_entries,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
def calculate_initial_capex(
|
||||
request: CapexCalculationRequest,
|
||||
) -> CapexCalculationResult:
|
||||
"""Aggregate capex components into totals and timelines."""
|
||||
|
||||
if not request.components:
|
||||
raise CapexValidationError(
|
||||
"At least one capex component is required for calculation.",
|
||||
["components"],
|
||||
)
|
||||
|
||||
parameters = request.parameters
|
||||
|
||||
base_currency = parameters.currency_code
|
||||
if base_currency:
|
||||
try:
|
||||
base_currency = normalise_currency(base_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
overall = 0.0
|
||||
category_totals: dict[str, float] = defaultdict(float)
|
||||
timeline_totals: dict[int, float] = defaultdict(float)
|
||||
normalised_components: list[CapexComponentInput] = []
|
||||
|
||||
for index, component in enumerate(request.components):
|
||||
amount = float(component.amount)
|
||||
overall += amount
|
||||
|
||||
category_totals[component.category] += amount
|
||||
|
||||
spend_year = component.spend_year or 0
|
||||
timeline_totals[spend_year] += amount
|
||||
|
||||
component_currency = component.currency
|
||||
if component_currency:
|
||||
try:
|
||||
component_currency = normalise_currency(component_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), [f"components[{index}].currency"]
|
||||
) from exc
|
||||
|
||||
if base_currency is None and component_currency:
|
||||
base_currency = component_currency
|
||||
elif (
|
||||
base_currency is not None
|
||||
and component_currency is not None
|
||||
and component_currency != base_currency
|
||||
):
|
||||
raise CapexValidationError(
|
||||
(
|
||||
"Component currency does not match the global currency. "
|
||||
f"Expected {base_currency}, got {component_currency}."
|
||||
),
|
||||
[f"components[{index}].currency"],
|
||||
)
|
||||
|
||||
normalised_components.append(
|
||||
CapexComponentInput(
|
||||
id=component.id,
|
||||
name=component.name,
|
||||
category=component.category,
|
||||
amount=amount,
|
||||
currency=component_currency,
|
||||
spend_year=component.spend_year,
|
||||
notes=component.notes,
|
||||
)
|
||||
)
|
||||
|
||||
contingency_pct = float(parameters.contingency_pct or 0.0)
|
||||
contingency_amount = overall * (contingency_pct / 100.0)
|
||||
grand_total = overall + contingency_amount
|
||||
|
||||
category_breakdowns: list[CapexCategoryBreakdown] = []
|
||||
if category_totals:
|
||||
for category, total in sorted(category_totals.items()):
|
||||
share = (total / overall * 100.0) if overall else None
|
||||
category_breakdowns.append(
|
||||
CapexCategoryBreakdown(
|
||||
category=category,
|
||||
amount=total,
|
||||
share=share,
|
||||
)
|
||||
)
|
||||
|
||||
cumulative = 0.0
|
||||
timeline_entries: list[CapexTimelineEntry] = []
|
||||
for year, spend in sorted(timeline_totals.items()):
|
||||
cumulative += spend
|
||||
timeline_entries.append(
|
||||
CapexTimelineEntry(year=year, spend=spend, cumulative=cumulative)
|
||||
)
|
||||
|
||||
try:
|
||||
currency = normalise_currency(base_currency) if base_currency else None
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
totals = CapexTotals(
|
||||
overall=overall,
|
||||
contingency_pct=contingency_pct,
|
||||
contingency_amount=contingency_amount,
|
||||
with_contingency=grand_total,
|
||||
by_category=category_breakdowns,
|
||||
)
|
||||
|
||||
return CapexCalculationResult(
|
||||
totals=totals,
|
||||
timeline=timeline_entries,
|
||||
components=normalised_components,
|
||||
parameters=parameters,
|
||||
options=request.options,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
def calculate_opex(
|
||||
request: OpexCalculationRequest,
|
||||
) -> OpexCalculationResult:
|
||||
"""Aggregate opex components into annual totals and timeline."""
|
||||
|
||||
if not request.components:
|
||||
raise OpexValidationError(
|
||||
"At least one opex component is required for calculation.",
|
||||
["components"],
|
||||
)
|
||||
|
||||
parameters: OpexParameters = request.parameters
|
||||
base_currency = parameters.currency_code
|
||||
if base_currency:
|
||||
try:
|
||||
base_currency = normalise_currency(base_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise OpexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
evaluation_horizon = parameters.evaluation_horizon_years or 1
|
||||
if evaluation_horizon <= 0:
|
||||
raise OpexValidationError(
|
||||
"Evaluation horizon must be at least 1 year.",
|
||||
["parameters.evaluation_horizon_years"],
|
||||
)
|
||||
|
||||
escalation_pct = float(parameters.escalation_pct or 0.0)
|
||||
apply_escalation = bool(parameters.apply_escalation)
|
||||
|
||||
category_totals: dict[str, float] = defaultdict(float)
|
||||
timeline_totals: dict[int, float] = defaultdict(float)
|
||||
timeline_escalated: dict[int, float] = defaultdict(float)
|
||||
normalised_components: list[OpexComponentInput] = []
|
||||
|
||||
max_period_end = evaluation_horizon
|
||||
|
||||
for index, component in enumerate(request.components):
|
||||
frequency = component.frequency.lower()
|
||||
multiplier = _FREQUENCY_MULTIPLIER.get(frequency)
|
||||
if multiplier is None:
|
||||
raise OpexValidationError(
|
||||
f"Unsupported frequency '{component.frequency}'.",
|
||||
[f"components[{index}].frequency"],
|
||||
)
|
||||
|
||||
unit_cost = float(component.unit_cost)
|
||||
quantity = float(component.quantity)
|
||||
annual_cost = unit_cost * quantity * multiplier
|
||||
|
||||
period_start = component.period_start or 1
|
||||
period_end = component.period_end or evaluation_horizon
|
||||
if period_end < period_start:
|
||||
raise OpexValidationError(
|
||||
(
|
||||
"Component period_end must be greater than or equal to "
|
||||
"period_start."
|
||||
),
|
||||
[f"components[{index}].period_end"],
|
||||
)
|
||||
|
||||
max_period_end = max(max_period_end, period_end)
|
||||
|
||||
component_currency = component.currency
|
||||
if component_currency:
|
||||
try:
|
||||
component_currency = normalise_currency(component_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise OpexValidationError(
|
||||
str(exc), [f"components[{index}].currency"]
|
||||
) from exc
|
||||
|
||||
if base_currency is None and component_currency:
|
||||
base_currency = component_currency
|
||||
elif (
|
||||
base_currency is not None
|
||||
and component_currency is not None
|
||||
and component_currency != base_currency
|
||||
):
|
||||
raise OpexValidationError(
|
||||
(
|
||||
"Component currency does not match the global currency. "
|
||||
f"Expected {base_currency}, got {component_currency}."
|
||||
),
|
||||
[f"components[{index}].currency"],
|
||||
)
|
||||
|
||||
category_totals[component.category] += annual_cost
|
||||
|
||||
for period in range(period_start, period_end + 1):
|
||||
timeline_totals[period] += annual_cost
|
||||
|
||||
normalised_components.append(
|
||||
OpexComponentInput(
|
||||
id=component.id,
|
||||
name=component.name,
|
||||
category=component.category,
|
||||
unit_cost=unit_cost,
|
||||
quantity=quantity,
|
||||
frequency=frequency,
|
||||
currency=component_currency,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
notes=component.notes,
|
||||
)
|
||||
)
|
||||
|
||||
evaluation_horizon = max(evaluation_horizon, max_period_end)
|
||||
|
||||
try:
|
||||
currency = normalise_currency(base_currency) if base_currency else None
|
||||
except CurrencyValidationError as exc:
|
||||
raise OpexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
timeline_entries: list[OpexTimelineEntry] = []
|
||||
escalated_values: list[float] = []
|
||||
overall_annual = timeline_totals.get(1, 0.0)
|
||||
escalated_total = 0.0
|
||||
|
||||
for period in range(1, evaluation_horizon + 1):
|
||||
base_cost = timeline_totals.get(period, 0.0)
|
||||
if apply_escalation:
|
||||
factor = (1 + escalation_pct / 100.0) ** (period - 1)
|
||||
else:
|
||||
factor = 1.0
|
||||
escalated_cost = base_cost * factor
|
||||
timeline_escalated[period] = escalated_cost
|
||||
escalated_total += escalated_cost
|
||||
timeline_entries.append(
|
||||
OpexTimelineEntry(
|
||||
period=period,
|
||||
base_cost=base_cost,
|
||||
escalated_cost=escalated_cost if apply_escalation else None,
|
||||
)
|
||||
)
|
||||
escalated_values.append(escalated_cost)
|
||||
|
||||
category_breakdowns: list[OpexCategoryBreakdown] = []
|
||||
total_base = sum(category_totals.values())
|
||||
for category, total in sorted(category_totals.items()):
|
||||
share = (total / total_base * 100.0) if total_base else None
|
||||
category_breakdowns.append(
|
||||
OpexCategoryBreakdown(
|
||||
category=category,
|
||||
annual_cost=total,
|
||||
share=share,
|
||||
)
|
||||
)
|
||||
|
||||
metrics = OpexMetrics(
|
||||
annual_average=fmean(escalated_values) if escalated_values else None,
|
||||
cost_per_ton=None,
|
||||
)
|
||||
|
||||
totals = OpexTotals(
|
||||
overall_annual=overall_annual,
|
||||
escalated_total=escalated_total if apply_escalation else None,
|
||||
escalation_pct=escalation_pct if apply_escalation else None,
|
||||
by_category=category_breakdowns,
|
||||
)
|
||||
|
||||
return OpexCalculationResult(
|
||||
totals=totals,
|
||||
timeline=timeline_entries,
|
||||
metrics=metrics,
|
||||
components=normalised_components,
|
||||
parameters=parameters,
|
||||
options=request.options,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"calculate_profitability",
|
||||
"calculate_initial_capex",
|
||||
"calculate_opex",
|
||||
]
|
||||
@@ -26,3 +26,36 @@ class ScenarioValidationError(Exception):
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class ProfitabilityValidationError(Exception):
|
||||
"""Raised when profitability calculation inputs fail domain validation."""
|
||||
|
||||
message: str
|
||||
field_errors: Sequence[str] | None = None
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class CapexValidationError(Exception):
|
||||
"""Raised when capex calculation inputs fail domain validation."""
|
||||
|
||||
message: str
|
||||
field_errors: Sequence[str] | None = None
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class OpexValidationError(Exception):
|
||||
"""Raised when opex calculation inputs fail domain validation."""
|
||||
|
||||
message: str
|
||||
field_errors: Sequence[str] | None = None
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
188
services/navigation.py
Normal file
188
services/navigation.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from models.navigation import NavigationGroup, NavigationLink
|
||||
from services.repositories import NavigationRepository
|
||||
from services.session import AuthSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationLinkDTO:
|
||||
id: int
|
||||
label: str
|
||||
href: str
|
||||
match_prefix: str | None
|
||||
icon: str | None
|
||||
tooltip: str | None
|
||||
is_external: bool
|
||||
children: List["NavigationLinkDTO"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationGroupDTO:
|
||||
id: int
|
||||
label: str
|
||||
icon: str | None
|
||||
tooltip: str | None
|
||||
links: List[NavigationLinkDTO] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationSidebarDTO:
|
||||
groups: List[NavigationGroupDTO]
|
||||
roles: tuple[str, ...]
|
||||
|
||||
|
||||
class NavigationService:
|
||||
"""Build navigation payloads filtered for the current session."""
|
||||
|
||||
def __init__(self, repository: NavigationRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def build_sidebar(
|
||||
self,
|
||||
*,
|
||||
session: AuthSession,
|
||||
request: Request | None = None,
|
||||
include_disabled: bool = False,
|
||||
) -> NavigationSidebarDTO:
|
||||
roles = self._collect_roles(session)
|
||||
groups = self._repository.list_groups_with_links(
|
||||
include_disabled=include_disabled
|
||||
)
|
||||
context = self._derive_context(request)
|
||||
|
||||
mapped_groups: List[NavigationGroupDTO] = []
|
||||
for group in groups:
|
||||
if not include_disabled and not group.is_enabled:
|
||||
continue
|
||||
mapped_links = self._map_links(
|
||||
group.links,
|
||||
roles,
|
||||
request=request,
|
||||
include_disabled=include_disabled,
|
||||
context=context,
|
||||
)
|
||||
if not mapped_links and not include_disabled:
|
||||
continue
|
||||
mapped_groups.append(
|
||||
NavigationGroupDTO(
|
||||
id=group.id,
|
||||
label=group.label,
|
||||
icon=group.icon,
|
||||
tooltip=group.tooltip,
|
||||
links=mapped_links,
|
||||
)
|
||||
)
|
||||
return NavigationSidebarDTO(groups=mapped_groups, roles=roles)
|
||||
|
||||
def _map_links(
|
||||
self,
|
||||
links: Sequence[NavigationLink],
|
||||
roles: Iterable[str],
|
||||
*,
|
||||
request: Request | None,
|
||||
include_disabled: bool,
|
||||
context: dict[str, str | None],
|
||||
) -> List[NavigationLinkDTO]:
|
||||
resolved_roles = tuple(roles)
|
||||
mapped: List[NavigationLinkDTO] = []
|
||||
for link in sorted(links, key=lambda l: (l.sort_order, l.id)):
|
||||
if not include_disabled and (not link.is_enabled):
|
||||
continue
|
||||
if not self._link_visible(link, resolved_roles, include_disabled):
|
||||
continue
|
||||
href = self._resolve_href(link, request=request, context=context)
|
||||
if not href:
|
||||
continue
|
||||
children = self._map_links(
|
||||
link.children,
|
||||
resolved_roles,
|
||||
request=request,
|
||||
include_disabled=include_disabled,
|
||||
context=context,
|
||||
)
|
||||
match_prefix = link.match_prefix or href
|
||||
mapped.append(
|
||||
NavigationLinkDTO(
|
||||
id=link.id,
|
||||
label=link.label,
|
||||
href=href,
|
||||
match_prefix=match_prefix,
|
||||
icon=link.icon,
|
||||
tooltip=link.tooltip,
|
||||
is_external=link.is_external,
|
||||
children=children,
|
||||
)
|
||||
)
|
||||
return mapped
|
||||
|
||||
@staticmethod
|
||||
def _collect_roles(session: AuthSession) -> tuple[str, ...]:
|
||||
roles = tuple((session.role_slugs or ()) if session else ())
|
||||
if session and session.is_authenticated:
|
||||
return roles
|
||||
if "anonymous" in roles:
|
||||
return roles
|
||||
return roles + ("anonymous",)
|
||||
|
||||
@staticmethod
|
||||
def _derive_context(request: Request | None) -> dict[str, str | None]:
|
||||
if request is None:
|
||||
return {"project_id": None, "scenario_id": None}
|
||||
project_id = request.path_params.get(
|
||||
"project_id") if hasattr(request, "path_params") else None
|
||||
scenario_id = request.path_params.get(
|
||||
"scenario_id") if hasattr(request, "path_params") else None
|
||||
if not project_id:
|
||||
project_id = request.query_params.get("project_id")
|
||||
if not scenario_id:
|
||||
scenario_id = request.query_params.get("scenario_id")
|
||||
return {"project_id": project_id, "scenario_id": scenario_id}
|
||||
|
||||
def _resolve_href(
|
||||
self,
|
||||
link: NavigationLink,
|
||||
*,
|
||||
request: Request | None,
|
||||
context: dict[str, str | None],
|
||||
) -> str | None:
|
||||
if link.route_name:
|
||||
if request is None:
|
||||
# Fallback to route name when no request is available
|
||||
return f"/{link.route_name.replace('.', '/')}"
|
||||
if link.slug in {"profitability", "profitability-calculator"}:
|
||||
project_id = context.get("project_id")
|
||||
scenario_id = context.get("scenario_id")
|
||||
if project_id and scenario_id:
|
||||
try:
|
||||
return request.url_for(
|
||||
link.route_name,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
try:
|
||||
return request.url_for(link.route_name)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return link.href_override
|
||||
return link.href_override
|
||||
|
||||
@staticmethod
|
||||
def _link_visible(
|
||||
link: NavigationLink,
|
||||
roles: Iterable[str],
|
||||
include_disabled: bool,
|
||||
) -> bool:
|
||||
role_tuple = tuple(roles)
|
||||
if not include_disabled and not link.is_enabled:
|
||||
return False
|
||||
if not link.required_roles:
|
||||
return True
|
||||
role_set = set(role_tuple)
|
||||
return any(role in role_set for role in link.required_roles)
|
||||
@@ -15,8 +15,16 @@ from models import (
|
||||
PricingImpuritySettings,
|
||||
PricingMetalSettings,
|
||||
PricingSettings,
|
||||
ProjectCapexSnapshot,
|
||||
ProjectProfitability,
|
||||
ProjectOpexSnapshot,
|
||||
NavigationGroup,
|
||||
NavigationLink,
|
||||
Role,
|
||||
Scenario,
|
||||
ScenarioCapexSnapshot,
|
||||
ScenarioProfitability,
|
||||
ScenarioOpexSnapshot,
|
||||
ScenarioStatus,
|
||||
SimulationParameter,
|
||||
User,
|
||||
@@ -32,6 +40,54 @@ def _enum_value(e):
|
||||
return getattr(e, "value", e)
|
||||
|
||||
|
||||
class NavigationRepository:
|
||||
"""Persistence operations for navigation metadata."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list_groups_with_links(
|
||||
self,
|
||||
*,
|
||||
include_disabled: bool = False,
|
||||
) -> Sequence[NavigationGroup]:
|
||||
stmt = (
|
||||
select(NavigationGroup)
|
||||
.options(
|
||||
selectinload(NavigationGroup.links)
|
||||
.selectinload(NavigationLink.children)
|
||||
)
|
||||
.order_by(NavigationGroup.sort_order, NavigationGroup.id)
|
||||
)
|
||||
if not include_disabled:
|
||||
stmt = stmt.where(NavigationGroup.is_enabled.is_(True))
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def get_group_by_slug(self, slug: str) -> NavigationGroup | None:
|
||||
stmt = select(NavigationGroup).where(NavigationGroup.slug == slug)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def get_link_by_slug(
|
||||
self,
|
||||
slug: str,
|
||||
*,
|
||||
group_id: int | None = None,
|
||||
) -> NavigationLink | None:
|
||||
stmt = select(NavigationLink).where(NavigationLink.slug == slug)
|
||||
if group_id is not None:
|
||||
stmt = stmt.where(NavigationLink.group_id == group_id)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def add_group(self, group: NavigationGroup) -> NavigationGroup:
|
||||
self.session.add(group)
|
||||
self.session.flush()
|
||||
return group
|
||||
|
||||
def add_link(self, link: NavigationLink) -> NavigationLink:
|
||||
self.session.add(link)
|
||||
self.session.flush()
|
||||
return link
|
||||
|
||||
class ProjectRepository:
|
||||
"""Persistence operations for Project entities."""
|
||||
|
||||
@@ -367,6 +423,310 @@ class ScenarioRepository:
|
||||
self.session.delete(scenario)
|
||||
|
||||
|
||||
class ProjectProfitabilityRepository:
|
||||
"""Persistence operations for project-level profitability snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ProjectProfitability) -> ProjectProfitability:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectProfitability]:
|
||||
stmt = (
|
||||
select(ProjectProfitability)
|
||||
.where(ProjectProfitability.project_id == project_id)
|
||||
.order_by(ProjectProfitability.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectProfitability | None:
|
||||
stmt = (
|
||||
select(ProjectProfitability)
|
||||
.where(ProjectProfitability.project_id == project_id)
|
||||
.order_by(ProjectProfitability.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectProfitability).where(
|
||||
ProjectProfitability.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project profitability snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioProfitabilityRepository:
|
||||
"""Persistence operations for scenario-level profitability snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ScenarioProfitability) -> ScenarioProfitability:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioProfitability]:
|
||||
stmt = (
|
||||
select(ScenarioProfitability)
|
||||
.where(ScenarioProfitability.scenario_id == scenario_id)
|
||||
.order_by(ScenarioProfitability.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioProfitability | None:
|
||||
stmt = (
|
||||
select(ScenarioProfitability)
|
||||
.where(ScenarioProfitability.scenario_id == scenario_id)
|
||||
.order_by(ScenarioProfitability.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioProfitability).where(
|
||||
ScenarioProfitability.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario profitability snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ProjectCapexRepository:
|
||||
"""Persistence operations for project-level capex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ProjectCapexSnapshot) -> ProjectCapexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectCapexSnapshot]:
|
||||
stmt = (
|
||||
select(ProjectCapexSnapshot)
|
||||
.where(ProjectCapexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectCapexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectCapexSnapshot | None:
|
||||
stmt = (
|
||||
select(ProjectCapexSnapshot)
|
||||
.where(ProjectCapexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectCapexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectCapexSnapshot).where(
|
||||
ProjectCapexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project capex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioCapexRepository:
|
||||
"""Persistence operations for scenario-level capex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ScenarioCapexSnapshot) -> ScenarioCapexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioCapexSnapshot]:
|
||||
stmt = (
|
||||
select(ScenarioCapexSnapshot)
|
||||
.where(ScenarioCapexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioCapexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioCapexSnapshot | None:
|
||||
stmt = (
|
||||
select(ScenarioCapexSnapshot)
|
||||
.where(ScenarioCapexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioCapexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioCapexSnapshot).where(
|
||||
ScenarioCapexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario capex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ProjectOpexRepository:
|
||||
"""Persistence operations for project-level opex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(
|
||||
self, snapshot: ProjectOpexSnapshot
|
||||
) -> ProjectOpexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectOpexSnapshot]:
|
||||
stmt = (
|
||||
select(ProjectOpexSnapshot)
|
||||
.where(ProjectOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectOpexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectOpexSnapshot | None:
|
||||
stmt = (
|
||||
select(ProjectOpexSnapshot)
|
||||
.where(ProjectOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectOpexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectOpexSnapshot).where(
|
||||
ProjectOpexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project opex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioOpexRepository:
|
||||
"""Persistence operations for scenario-level opex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(
|
||||
self, snapshot: ScenarioOpexSnapshot
|
||||
) -> ScenarioOpexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioOpexSnapshot]:
|
||||
stmt = (
|
||||
select(ScenarioOpexSnapshot)
|
||||
.where(ScenarioOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioOpexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioOpexSnapshot | None:
|
||||
stmt = (
|
||||
select(ScenarioOpexSnapshot)
|
||||
.where(ScenarioOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioOpexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioOpexSnapshot).where(
|
||||
ScenarioOpexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario opex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class FinancialInputRepository:
|
||||
"""Persistence operations for FinancialInput entities."""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hmac import compare_digest
|
||||
from typing import Any, Dict, Iterable, Literal, Type
|
||||
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
@@ -176,6 +177,14 @@ def _decode_token(
|
||||
except JWTError as exc: # pragma: no cover - jose error bubble
|
||||
raise TokenDecodeError("Unable to decode token") from exc
|
||||
|
||||
expected_token = jwt.encode(
|
||||
decoded,
|
||||
settings.secret_key,
|
||||
algorithm=settings.algorithm,
|
||||
)
|
||||
if not compare_digest(token, expected_token):
|
||||
raise TokenDecodeError("Token contents have been altered.")
|
||||
|
||||
try:
|
||||
payload = _model_validate(TokenPayload, decoded)
|
||||
except ValidationError as exc:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional, TYPE_CHECKING
|
||||
from typing import Iterable, Literal, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
@@ -67,6 +67,7 @@ class AuthSession:
|
||||
tokens: SessionTokens
|
||||
user: Optional["User"] = None
|
||||
scopes: tuple[str, ...] = ()
|
||||
role_slugs: tuple[str, ...] = ()
|
||||
issued_access_token: Optional[str] = None
|
||||
issued_refresh_token: Optional[str] = None
|
||||
clear_cookies: bool = False
|
||||
@@ -77,7 +78,10 @@ class AuthSession:
|
||||
|
||||
@classmethod
|
||||
def anonymous(cls) -> "AuthSession":
|
||||
return cls(tokens=SessionTokens(access_token=None, refresh_token=None))
|
||||
return cls(
|
||||
tokens=SessionTokens(access_token=None, refresh_token=None),
|
||||
role_slugs=(),
|
||||
)
|
||||
|
||||
def issue_tokens(
|
||||
self,
|
||||
@@ -100,6 +104,10 @@ class AuthSession:
|
||||
self.tokens = SessionTokens(access_token=None, refresh_token=None)
|
||||
self.user = None
|
||||
self.scopes = ()
|
||||
self.role_slugs = ()
|
||||
|
||||
def set_role_slugs(self, roles: Iterable[str]) -> None:
|
||||
self.role_slugs = tuple(dict.fromkeys(role.strip().lower() for role in roles if role))
|
||||
|
||||
|
||||
def extract_session_tokens(request: Request, strategy: SessionStrategy) -> SessionTokens:
|
||||
|
||||
@@ -13,14 +13,21 @@ from services.repositories import (
|
||||
PricingSettingsRepository,
|
||||
PricingSettingsSeedResult,
|
||||
ProjectRepository,
|
||||
ProjectProfitabilityRepository,
|
||||
ProjectOpexRepository,
|
||||
ProjectCapexRepository,
|
||||
RoleRepository,
|
||||
ScenarioRepository,
|
||||
ScenarioProfitabilityRepository,
|
||||
ScenarioOpexRepository,
|
||||
ScenarioCapexRepository,
|
||||
SimulationParameterRepository,
|
||||
UserRepository,
|
||||
ensure_admin_user as ensure_admin_user_record,
|
||||
ensure_default_pricing_settings,
|
||||
ensure_default_roles,
|
||||
pricing_settings_to_metadata,
|
||||
NavigationRepository,
|
||||
)
|
||||
from services.scenario_validation import ScenarioComparisonValidator
|
||||
|
||||
@@ -36,9 +43,16 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.scenarios: ScenarioRepository | None = None
|
||||
self.financial_inputs: FinancialInputRepository | None = None
|
||||
self.simulation_parameters: SimulationParameterRepository | None = None
|
||||
self.project_profitability: ProjectProfitabilityRepository | None = None
|
||||
self.project_capex: ProjectCapexRepository | None = None
|
||||
self.project_opex: ProjectOpexRepository | None = None
|
||||
self.scenario_profitability: ScenarioProfitabilityRepository | None = None
|
||||
self.scenario_capex: ScenarioCapexRepository | None = None
|
||||
self.scenario_opex: ScenarioOpexRepository | None = None
|
||||
self.users: UserRepository | None = None
|
||||
self.roles: RoleRepository | None = None
|
||||
self.pricing_settings: PricingSettingsRepository | None = None
|
||||
self.navigation: NavigationRepository | None = None
|
||||
|
||||
def __enter__(self) -> "UnitOfWork":
|
||||
self.session = self._session_factory()
|
||||
@@ -47,9 +61,21 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.financial_inputs = FinancialInputRepository(self.session)
|
||||
self.simulation_parameters = SimulationParameterRepository(
|
||||
self.session)
|
||||
self.project_profitability = ProjectProfitabilityRepository(
|
||||
self.session)
|
||||
self.project_capex = ProjectCapexRepository(self.session)
|
||||
self.project_opex = ProjectOpexRepository(
|
||||
self.session)
|
||||
self.scenario_profitability = ScenarioProfitabilityRepository(
|
||||
self.session
|
||||
)
|
||||
self.scenario_capex = ScenarioCapexRepository(self.session)
|
||||
self.scenario_opex = ScenarioOpexRepository(
|
||||
self.session)
|
||||
self.users = UserRepository(self.session)
|
||||
self.roles = RoleRepository(self.session)
|
||||
self.pricing_settings = PricingSettingsRepository(self.session)
|
||||
self.navigation = NavigationRepository(self.session)
|
||||
self._scenario_validator = ScenarioComparisonValidator()
|
||||
return self
|
||||
|
||||
@@ -65,9 +91,16 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.scenarios = None
|
||||
self.financial_inputs = None
|
||||
self.simulation_parameters = None
|
||||
self.project_profitability = None
|
||||
self.project_capex = None
|
||||
self.project_opex = None
|
||||
self.scenario_profitability = None
|
||||
self.scenario_capex = None
|
||||
self.scenario_opex = None
|
||||
self.users = None
|
||||
self.roles = None
|
||||
self.pricing_settings = None
|
||||
self.navigation = None
|
||||
|
||||
def flush(self) -> None:
|
||||
if not self.session:
|
||||
|
||||
@@ -302,6 +302,26 @@ a {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a::after {
|
||||
content: ">";
|
||||
margin-left: 0.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -347,17 +367,17 @@ a.sidebar-brand:focus {
|
||||
.sidebar-nav-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-chevron {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 1.2rem;
|
||||
font-size: 4.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -368,8 +388,9 @@ a.sidebar-brand:focus {
|
||||
|
||||
.nav-chevron:hover,
|
||||
.nav-chevron:focus {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.nav-chevron:disabled {
|
||||
@@ -1031,7 +1052,7 @@ tbody tr:nth-child(even) {
|
||||
|
||||
.site-footer {
|
||||
background-color: var(--brand);
|
||||
color: var(--color-text-invert);
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@@ -1056,6 +1077,19 @@ tbody tr:nth-child(even) {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
footer a {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
footer a:hover,
|
||||
footer a:focus {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
@@ -1155,8 +1189,16 @@ tbody tr:nth-child(even) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-nav-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-link-block {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
flex: 1 1 140px;
|
||||
flex: 1 1 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1228,10 @@ tbody tr:nth-child(even) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-open .app-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
body.sidebar-open .app-sidebar {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -1194,7 +1240,7 @@ tbody tr:nth-child(even) {
|
||||
width: min(320px, 82vw);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
z-index: 900;
|
||||
z-index: 999;
|
||||
box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4);
|
||||
}
|
||||
|
||||
@@ -1202,9 +1248,4 @@ tbody tr:nth-child(even) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.sidebar-open .app-main {
|
||||
position: relative;
|
||||
z-index: 950;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,108 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card:hover,
|
||||
.project-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 22px 45px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.project-card__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.project-card__title a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-card__title a:hover,
|
||||
.project-card__title a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-card__type {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.project-card__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-subtle);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.project-card__meta {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.project-card__meta div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.project-card__meta dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.project-card__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.project-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-card__links {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-card__links .btn-link {
|
||||
padding: 3px 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.project-metrics {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
@@ -87,6 +189,163 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.project-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list li a {
|
||||
font-weight: 600;
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick-link-list li a:hover,
|
||||
.quick-link-list li a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.quick-link-list p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
background: rgba(21, 27, 35, 0.85);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.scenario-item__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scenario-item__header a:hover,
|
||||
.scenario-item__header a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pill--draft {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-pill--active {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status-pill--archived {
|
||||
background: rgba(148, 163, 184, 0.24);
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.scenario-item__meta {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.scenario-item__meta dt {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.scenario-item__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scenario-item__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-item__actions .btn-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -151,6 +410,16 @@
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
|
||||
@@ -23,20 +23,6 @@
|
||||
background: rgba(43, 165, 143, 0.12);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -120,6 +106,76 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.scenario-form .card {
|
||||
background: rgba(21, 27, 35, 0.9);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenario-form .card h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-form .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenario-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scenario-form .form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.scenario-form .form-group input,
|
||||
.scenario-form .form-group select,
|
||||
.scenario-form .form-group textarea {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--card-border);
|
||||
background: rgba(8, 12, 19, 0.78);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.scenario-form .form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.scenario-form .form-group input:focus,
|
||||
.scenario-form .form-group select:focus,
|
||||
.scenario-form .form-group textarea:focus {
|
||||
outline: 2px solid var(--brand-2);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form-group--error input,
|
||||
.form-group--error select,
|
||||
.form-group--error textarea {
|
||||
border-color: rgba(209, 75, 75, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(209, 75, 75, 0.3);
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
width: 100%;
|
||||
@@ -179,12 +235,214 @@
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.scenario-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.quick-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list li a {
|
||||
font-weight: 600;
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick-link-list li a:hover,
|
||||
.quick-link-list li a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.quick-link-list p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.status-pill--draft {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-pill--active {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status-pill--archived {
|
||||
background: rgba(148, 163, 184, 0.24);
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.scenario-layout {
|
||||
grid-template-columns: 1.1fr 1.9fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.scenario-portfolio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.scenario-portfolio__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
background: rgba(21, 27, 35, 0.85);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.scenario-item__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scenario-item__header a:hover,
|
||||
.scenario-item__header a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scenario-item__meta {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.scenario-item__meta dt {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.scenario-item__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scenario-item__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-item__actions .btn-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.scenario-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
.scenario-context-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-context-card .definition-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-defaults {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-defaults li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.scenario-defaults li strong {
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
230
static/js/navigation_sidebar.js
Normal file
230
static/js/navigation_sidebar.js
Normal file
@@ -0,0 +1,230 @@
|
||||
(function () {
|
||||
const NAV_ENDPOINT = "/navigation/sidebar";
|
||||
const SIDEBAR_SELECTOR = ".sidebar-nav";
|
||||
const DATA_SOURCE_ATTR = "navigationSource";
|
||||
const ROLE_ATTR = "navigationRoles";
|
||||
|
||||
function onReady(callback) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", callback, { once: true });
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function isActivePath(pathname, matchPrefix) {
|
||||
if (!matchPrefix) {
|
||||
return false;
|
||||
}
|
||||
if (matchPrefix === "/") {
|
||||
return pathname === "/";
|
||||
}
|
||||
return pathname.startsWith(matchPrefix);
|
||||
}
|
||||
|
||||
function createAnchor({
|
||||
href,
|
||||
label,
|
||||
matchPrefix,
|
||||
tooltip,
|
||||
isExternal,
|
||||
isActive,
|
||||
className,
|
||||
}) {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.className = className + (isActive ? " is-active" : "");
|
||||
anchor.dataset.matchPrefix = matchPrefix || href;
|
||||
if (tooltip) {
|
||||
anchor.title = tooltip;
|
||||
}
|
||||
if (isExternal) {
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener noreferrer";
|
||||
anchor.classList.add("is-external");
|
||||
}
|
||||
anchor.textContent = label;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function buildLinkBlock(link, pathname) {
|
||||
if (!link || !link.href) {
|
||||
return null;
|
||||
}
|
||||
const matchPrefix = link.match_prefix || link.matchPrefix || link.href;
|
||||
const isActive = isActivePath(pathname, matchPrefix);
|
||||
|
||||
const block = document.createElement("div");
|
||||
block.className = "sidebar-link-block";
|
||||
if (typeof link.id === "number") {
|
||||
block.dataset.linkId = String(link.id);
|
||||
}
|
||||
|
||||
const anchor = createAnchor({
|
||||
href: link.href,
|
||||
label: link.label,
|
||||
matchPrefix,
|
||||
tooltip: link.tooltip,
|
||||
isExternal: Boolean(link.is_external ?? link.isExternal),
|
||||
isActive,
|
||||
className: "sidebar-link",
|
||||
});
|
||||
block.appendChild(anchor);
|
||||
|
||||
const children = Array.isArray(link.children) ? link.children : [];
|
||||
if (children.length > 0) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "sidebar-sublinks";
|
||||
for (const child of children) {
|
||||
if (!child || !child.href) {
|
||||
continue;
|
||||
}
|
||||
const childMatch =
|
||||
child.match_prefix || child.matchPrefix || child.href;
|
||||
const childActive = isActivePath(pathname, childMatch);
|
||||
const childAnchor = createAnchor({
|
||||
href: child.href,
|
||||
label: child.label,
|
||||
matchPrefix: childMatch,
|
||||
tooltip: child.tooltip,
|
||||
isExternal: Boolean(child.is_external ?? child.isExternal),
|
||||
isActive: childActive,
|
||||
className: "sidebar-sublink",
|
||||
});
|
||||
container.appendChild(childAnchor);
|
||||
}
|
||||
if (container.children.length > 0) {
|
||||
block.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function buildGroupSection(group, pathname) {
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const links = Array.isArray(group.links) ? group.links : [];
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section";
|
||||
if (typeof group.id === "number") {
|
||||
section.dataset.groupId = String(group.id);
|
||||
}
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = group.label;
|
||||
section.appendChild(label);
|
||||
|
||||
const linksContainer = document.createElement("div");
|
||||
linksContainer.className = "sidebar-section-links";
|
||||
|
||||
for (const link of links) {
|
||||
const block = buildLinkBlock(link, pathname);
|
||||
if (block) {
|
||||
linksContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
if (linksContainer.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
section.appendChild(linksContainer);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildEmptyState() {
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section sidebar-empty-state";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = "Navigation";
|
||||
section.appendChild(label);
|
||||
|
||||
const copyWrapper = document.createElement("div");
|
||||
copyWrapper.className = "sidebar-section-links";
|
||||
|
||||
const copy = document.createElement("p");
|
||||
copy.className = "sidebar-empty-copy";
|
||||
copy.textContent = "Navigation is unavailable.";
|
||||
copyWrapper.appendChild(copy);
|
||||
|
||||
section.appendChild(copyWrapper);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderSidebar(navContainer, payload) {
|
||||
const pathname = window.location.pathname;
|
||||
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
||||
navContainer.replaceChildren();
|
||||
|
||||
const rendered = [];
|
||||
for (const group of groups) {
|
||||
const section = buildGroupSection(group, pathname);
|
||||
if (section) {
|
||||
rendered.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
if (rendered.length === 0) {
|
||||
navContainer.appendChild(buildEmptyState());
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of rendered) {
|
||||
navContainer.appendChild(section);
|
||||
}
|
||||
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client";
|
||||
const roles = Array.isArray(payload?.roles) ? payload.roles : [];
|
||||
if (roles.length > 0) {
|
||||
navContainer.dataset[ROLE_ATTR] = roles.join(",");
|
||||
} else {
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSidebar(navContainer) {
|
||||
try {
|
||||
const response = await fetch(NAV_ENDPOINT, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
console.warn(
|
||||
"Navigation sidebar hydration failed with status",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderSidebar(navContainer, payload);
|
||||
} catch (error) {
|
||||
console.warn("Navigation sidebar hydration failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
onReady(() => {
|
||||
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
|
||||
if (!navContainer) {
|
||||
return;
|
||||
}
|
||||
hydrateSidebar(navContainer);
|
||||
});
|
||||
})();
|
||||
@@ -1,14 +1,35 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const table = document.querySelector("[data-project-table]");
|
||||
const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : [];
|
||||
const container = document.querySelector("[data-project-table]");
|
||||
const filterInput = document.querySelector("[data-project-filter]");
|
||||
|
||||
if (table && filterInput) {
|
||||
const resolveFilterItems = () => {
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.from(
|
||||
container.querySelectorAll("[data-project-entry]")
|
||||
);
|
||||
|
||||
if (entries.length) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (container.tagName === "TABLE") {
|
||||
return Array.from(container.querySelectorAll("tbody tr"));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const filterItems = resolveFilterItems();
|
||||
|
||||
if (container && filterInput && filterItems.length) {
|
||||
filterInput.addEventListener("input", () => {
|
||||
const query = filterInput.value.trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const match = row.textContent.toLowerCase().includes(query);
|
||||
row.style.display = match ? "" : "none";
|
||||
filterItems.forEach((item) => {
|
||||
const match = item.textContent.toLowerCase().includes(query);
|
||||
item.style.display = match ? "" : "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<script src="/static/js/exports.js" defer></script>
|
||||
<script src="/static/js/imports.js" defer></script>
|
||||
<script src="/static/js/notifications.js" defer></script>
|
||||
<script src="/static/js/navigation_sidebar.js" defer></script>
|
||||
<script src="/static/js/navigation.js" defer></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
<span class="brand-subtitle">Mining Planner</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="sidebar-nav-controls">
|
||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">←</button>
|
||||
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">→</button>
|
||||
</div>
|
||||
{% include "partials/sidebar_nav.html" %}
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,78 @@
|
||||
{% set dashboard_href = request.url_for('dashboard.home') if request else '/' %}
|
||||
{% set projects_href = request.url_for('projects.project_list_page') if request
|
||||
else '/projects/ui' %} {% set project_create_href =
|
||||
request.url_for('projects.create_project_form') if request else
|
||||
'/projects/create' %} {% set auth_session = request.state.auth_session if
|
||||
request else None %} {% set is_authenticated = auth_session and
|
||||
auth_session.is_authenticated %} {% if is_authenticated %} {% set logout_href =
|
||||
request.url_for('auth.logout') if request else '/logout' %} {% set account_links
|
||||
= [ {"href": logout_href, "label": "Logout", "match_prefix": "/logout"} ] %} {%
|
||||
else %} {% set login_href = request.url_for('auth.login_form') if request else
|
||||
'/login' %} {% set register_href = request.url_for('auth.register_form') if
|
||||
request else '/register' %} {% set forgot_href =
|
||||
request.url_for('auth.password_reset_request_form') if request else
|
||||
'/forgot-password' %} {% set account_links = [ {"href": login_href, "label":
|
||||
"Login", "match_prefix": "/login"}, {"href": register_href, "label": "Register",
|
||||
"match_prefix": "/register"}, {"href": forgot_href, "label": "Forgot Password",
|
||||
"match_prefix": "/forgot-password"} ] %} {% endif %} {% set nav_groups = [ {
|
||||
"label": "Workspace", "links": [ {"href": dashboard_href, "label": "Dashboard",
|
||||
"match_prefix": "/"}, {"href": projects_href, "label": "Projects",
|
||||
"match_prefix": "/projects"}, {"href": project_create_href, "label": "New
|
||||
Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label":
|
||||
"Imports", "match_prefix": "/imports"} ] }, { "label": "Insights", "links": [
|
||||
{"href": "/ui/simulations", "label": "Simulations"}, {"href": "/ui/reporting",
|
||||
"label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href":
|
||||
"/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings",
|
||||
"label": "Themes"}, {"href": "/ui/currencies", "label": "Currency Management"} ]
|
||||
} ] }, { "label": "Account", "links": account_links } ] %}
|
||||
{% set sidebar_nav = get_sidebar_navigation(request) %}
|
||||
{% set nav_groups = sidebar_nav.groups if sidebar_nav else [] %}
|
||||
{% set current_path = request.url.path if request else '' %}
|
||||
|
||||
<nav class="sidebar-nav" aria-label="Primary navigation">
|
||||
{% set current_path = request.url.path if request else '' %} {% for group in
|
||||
nav_groups %} {% if group.links %}
|
||||
<div class="sidebar-section">
|
||||
<nav
|
||||
class="sidebar-nav"
|
||||
aria-label="Primary navigation"
|
||||
data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
|
||||
>
|
||||
<div class="sidebar-nav-controls">
|
||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">ᐊ</button>
|
||||
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">ᐅ</button>
|
||||
</div>
|
||||
{% if nav_groups %}
|
||||
{% for group in nav_groups %}
|
||||
{% if group.links %}
|
||||
<div class="sidebar-section" data-group-id="{{ group.id }}">
|
||||
<div class="sidebar-section-label">{{ group.label }}</div>
|
||||
<div class="sidebar-section-links">
|
||||
{% for link in group.links %} {% set href = link.href | string %} {% set
|
||||
match_prefix = link.get('match_prefix', href) | string %} {% if
|
||||
match_prefix == '/' %} {% set is_active = current_path == '/' %} {% else
|
||||
%} {% set is_active = current_path.startswith(match_prefix) %} {% endif %}
|
||||
<div class="sidebar-link-block">
|
||||
{% for link in group.links %}
|
||||
{% set href = link.href %}
|
||||
{% if href %}
|
||||
{% set match_prefix = link.match_prefix or href %}
|
||||
{% if match_prefix == '/' %}
|
||||
{% set is_active = current_path == '/' %}
|
||||
{% else %}
|
||||
{% set is_active = current_path.startswith(match_prefix) %}
|
||||
{% endif %}
|
||||
<div class="sidebar-link-block" data-link-id="{{ link.id }}">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
class="sidebar-link{% if is_active %} is-active{% endif %}"
|
||||
class="sidebar-link{% if is_active %} is-active{% endif %}{% if link.is_external %} is-external{% endif %}"
|
||||
data-match-prefix="{{ match_prefix }}"
|
||||
{% if link.tooltip %}title="{{ link.tooltip }}"{% endif %}
|
||||
{% if link.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
{% if link.children %}
|
||||
<div class="sidebar-sublinks">
|
||||
{% for child in link.children %} {% set child_prefix =
|
||||
child.get('match_prefix', child.href) | string %} {% if child_prefix
|
||||
== '/' %} {% set child_active = current_path == '/' %} {% else %} {%
|
||||
set child_active = current_path.startswith(child_prefix) %} {% endif
|
||||
%}
|
||||
{% for child in link.children %}
|
||||
{% set child_href = child.href %}
|
||||
{% if child_href %}
|
||||
{% set child_prefix = child.match_prefix or child_href %}
|
||||
{% if child_prefix == '/' %}
|
||||
{% set child_active = current_path == '/' %}
|
||||
{% else %}
|
||||
{% set child_active = current_path.startswith(child_prefix) %}
|
||||
{% endif %}
|
||||
<a
|
||||
href="{{ child.href | string }}"
|
||||
class="sidebar-sublink{% if child_active %} is-active{% endif %}"
|
||||
href="{{ child_href }}"
|
||||
class="sidebar-sublink{% if child_active %} is-active{% endif %}{% if child.is_external %} is-external{% endif %}"
|
||||
data-match-prefix="{{ child_prefix }}"
|
||||
{% if child.tooltip %}title="{{ child.tooltip }}"{% endif %}
|
||||
{% if child.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}
|
||||
>
|
||||
{{ child.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="sidebar-section sidebar-empty-state">
|
||||
<div class="sidebar-section-label">Navigation</div>
|
||||
<div class="sidebar-section-links">
|
||||
<p class="sidebar-empty-copy">Navigation is unavailable.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
|
||||
<a class="btn" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||
</div>
|
||||
@@ -46,6 +47,7 @@
|
||||
</section>
|
||||
|
||||
<div class="project-layout">
|
||||
<div class="project-column">
|
||||
<section class="card">
|
||||
<h2>Project Overview</h2>
|
||||
<dl class="definition-list">
|
||||
@@ -72,39 +74,64 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<section class="card project-actions-card">
|
||||
<h2>Next Steps</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Capture a new scenario</a>
|
||||
<p>Create an additional assumption set under this project.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Review scenario portfolio</a>
|
||||
<p>Compare scenarios and jump into calculators with inherited context.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Update project details</a>
|
||||
<p>Revise metadata or operation type for reporting.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card project-scenarios-card">
|
||||
<header class="project-scenarios-card__header">
|
||||
<div>
|
||||
<h2>Scenarios</h2>
|
||||
<p class="text-muted">Project scenarios inherit pricing and provide entry points to profitability planning.</p>
|
||||
</div>
|
||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
</header>
|
||||
{% if scenarios %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Currency</th>
|
||||
<th>Primary Resource</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ul class="scenario-list">
|
||||
{% for scenario in scenarios %}
|
||||
<tr>
|
||||
<td>{{ scenario.name }}</td>
|
||||
<td>{{ scenario.status.value.title() }}</td>
|
||||
<td>{{ scenario.currency or '—' }}</td>
|
||||
<td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td>
|
||||
<td class="text-right">
|
||||
<a class="table-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="table-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<li class="scenario-item">
|
||||
<div class="scenario-item__body">
|
||||
<div class="scenario-item__header">
|
||||
<h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
|
||||
<span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
|
||||
</div>
|
||||
<dl class="scenario-item__meta">
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="scenario-item__actions">
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form class="form project-form" method="post" action="{{ form_action }}">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if project %}Edit Project{% else %}Create Project{% endif %}</h1>
|
||||
@@ -27,15 +31,6 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="form project-form" method="post" action="{{ form_action }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
|
||||
@@ -17,27 +17,48 @@
|
||||
class="form-control"
|
||||
placeholder="Filter projects..."
|
||||
data-project-filter
|
||||
aria-label="Filter projects"
|
||||
/>
|
||||
<a class="btn btn-primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if projects %}
|
||||
<table class="projects-table" data-project-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Type</th>
|
||||
<th>Scenarios</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<section class="projects-grid" data-project-table>
|
||||
{% for project in projects %}
|
||||
<tr>
|
||||
<td class="table-cell-actions">
|
||||
{{ project.name }}
|
||||
<article class="project-card" data-project-entry>
|
||||
<header class="project-card__header">
|
||||
<h2 class="project-card__title">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
</h2>
|
||||
<span class="project-card__type badge">{{ project.operation_type.value.replace('_', ' ') | title }}</span>
|
||||
</header>
|
||||
|
||||
<p class="project-card__description">
|
||||
{{ project.description or 'No description provided yet.' }}
|
||||
</p>
|
||||
|
||||
<dl class="project-card__meta">
|
||||
<div>
|
||||
<dt>Scenarios</dt>
|
||||
<dd><span class="badge badge-pill">{{ project.scenario_count }}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<footer class="project-card__footer">
|
||||
<div class="project-card__links">
|
||||
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View Project</a>
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
<a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
data-export-trigger
|
||||
@@ -47,18 +68,10 @@
|
||||
<span aria-hidden="true">⇩</span>
|
||||
<span class="sr-only">Export</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ project.location or '—' }}</td>
|
||||
<td>{{ project.operation_type.value.replace('_', ' ') | title }}</td>
|
||||
<td>{{ project.scenario_count }}</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View</a>
|
||||
<a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
</footer>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% else %}
|
||||
<p>No projects yet. <a href="{{ url_for('projects.create_project_form') }}">Create your first project.</a></p>
|
||||
{% endif %}
|
||||
|
||||
271
templates/scenarios/capex.html
Normal file
271
templates/scenarios/capex.html
Normal file
@@ -0,0 +1,271 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Capex Planner · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
{% if project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
{% endif %}
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Capex Planner</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Capex Planner</h1>
|
||||
<p class="text-muted">Plan capital requirements for {{ scenario.name if scenario else 'this scenario' }}.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if scenario_url %}
|
||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit" form="capex-form">Save & Calculate</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<h2 class="sr-only">Submission errors</h2>
|
||||
<ul>
|
||||
{% for message in errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notices %}
|
||||
<div class="alert alert-info">
|
||||
<ul>
|
||||
{% for message in notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="capex-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
||||
<input type="hidden" name="options[persist]" value="{{ '1' if options is defined and options and options.persist else '' }}" />
|
||||
|
||||
<div class="layout-two-column stackable">
|
||||
<section class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Capex Components</h2>
|
||||
<p class="section-subtitle">Break down initial capital items with category, amount, and timing.</p>
|
||||
</header>
|
||||
|
||||
<div class="table-actions">
|
||||
<button class="btn secondary" type="button" data-action="add-component">Add Component</button>
|
||||
</div>
|
||||
|
||||
{% if component_errors is defined and component_errors %}
|
||||
<div class="alert alert-error slim" role="alert" aria-live="polite">
|
||||
<h3 class="sr-only">Component issues</h3>
|
||||
<ul>
|
||||
{% for message in component_errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if component_notices is defined and component_notices %}
|
||||
<div class="alert alert-info slim" role="status" aria-live="polite">
|
||||
<ul>
|
||||
{% for message in component_notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive horizontal-scroll">
|
||||
<table class="input-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Component</th>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Currency</th>
|
||||
<th scope="col">Spend Year</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th class="sr-only" scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set component_entries = (components if components is defined and components else [{}]) %}
|
||||
{% for component in component_entries %}
|
||||
<tr data-row-index="{{ loop.index0 }}">
|
||||
<td>
|
||||
<input type="hidden" name="components[{{ loop.index0 }}][id]" value="{{ component.id or '' }}" />
|
||||
<select name="components[{{ loop.index0 }}][category]">
|
||||
{% for option in category_options %}
|
||||
<option value="{{ option.value }}" {% if component.category == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="components[{{ loop.index0 }}][name]" value="{{ component.name or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][amount]" value="{{ component.amount or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" maxlength="3" name="components[{{ loop.index0 }}][currency]" value="{{ component.currency or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="1" name="components[{{ loop.index0 }}][spend_year]" value="{{ component.spend_year or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="components[{{ loop.index0 }}][notes]" value="{{ component.notes or '' }}" />
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn link" type="button" data-action="remove-component">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="muted">Add rows for each capital item. Amounts should reflect pre-contingency values.</p>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Global Parameters</h2>
|
||||
<p class="section-subtitle">Configure defaults applied across components.</p>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="capex_currency_code">Default Currency</label>
|
||||
<input id="capex_currency_code" name="parameters[currency_code]" type="text" maxlength="3" value="{{ (parameters.currency_code if parameters is defined and parameters else None) or (currency_code if currency_code is defined else None) or (scenario.currency if scenario else None) or (project.currency if project else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contingency_pct">Contingency (%)</label>
|
||||
<input id="contingency_pct" name="parameters[contingency_pct]" type="number" min="0" max="100" step="0.01" value="{{ (parameters.contingency_pct if parameters is defined and parameters else '') }}" />
|
||||
<p class="field-help">Applied across component totals.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discount_rate">Discount Rate (%)</label>
|
||||
<input id="discount_rate" name="parameters[discount_rate_pct]" type="number" min="0" max="100" step="0.01" value="{{ (parameters.discount_rate_pct if parameters is defined and parameters else None) or (scenario.discount_rate if scenario else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="evaluation_horizon_years">Evaluation Horizon (years)</label>
|
||||
<input id="evaluation_horizon_years" name="parameters[evaluation_horizon_years]" type="number" min="1" step="1" value="{{ (parameters.evaluation_horizon_years if parameters is defined and parameters else None) or (default_horizon if default_horizon is defined else '') }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Assumptions</h3>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Categories Configured</dt>
|
||||
<dd>{{ category_options | length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ last_updated_at or '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="muted">Defaults reflect scenario preferences. Adjust before calculating.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Capex Summary</h2>
|
||||
<p class="section-subtitle">Calculated totals and categorized breakdowns.</p>
|
||||
</header>
|
||||
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Total Capex</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.overall | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Contingency Applied</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.contingency_amount | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Grand Total</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.with_contingency | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{% if result.totals.by_category %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in result.totals.by_category %}
|
||||
<tr>
|
||||
<th scope="row">{{ row.category }}</th>
|
||||
<td>{{ row.amount | currency_display(result.currency) }}</td>
|
||||
<td>{{ row.share | percentage_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.timeline %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Year</th>
|
||||
<th scope="col">Spend</th>
|
||||
<th scope="col">Cumulative</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.timeline %}
|
||||
<tr>
|
||||
<th scope="row">{{ entry.year }}</th>
|
||||
<td>{{ entry.spend | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.cumulative | currency_display(result.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">Provide component details and calculate to see capex totals.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Visualizations</h2>
|
||||
<p class="section-subtitle">Charts render after calculations complete.</p>
|
||||
</header>
|
||||
<div id="capex-category-chart" class="chart-container"></div>
|
||||
<div id="capex-timeline-chart" class="chart-container"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -13,17 +13,36 @@
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
{% set profitability_href = '/calculations/profitability' %}
|
||||
{% set opex_href = url_for('calculations.opex_form') %}
|
||||
{% set capex_href = url_for('calculations.capex_form') %}
|
||||
{% set scenario_list_href = url_for('scenarios.project_scenario_list', project_id=project.id) %}
|
||||
{% if project and scenario %}
|
||||
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||
{% set opex_href = opex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1>{{ scenario.name }}</h1>
|
||||
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
|
||||
<p class="text-muted">
|
||||
Part of <a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a>
|
||||
<a class="btn" href="{{ scenario_list_href }}">Scenario Portfolio</a>
|
||||
<a class="btn" href="{{ profitability_href }}">Profitability Calculator</a>
|
||||
<a class="btn" href="{{ opex_href }}">Opex Planner</a>
|
||||
<a class="btn" href="{{ capex_href }}">Capex Planner</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scenario-metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Status</h2>
|
||||
<p class="metric-value status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</p>
|
||||
<span class="metric-caption">Lifecycle state</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Financial Inputs</h2>
|
||||
<p class="metric-value">{{ scenario_metrics.financial_count }}</p>
|
||||
@@ -39,16 +58,12 @@
|
||||
<p class="metric-value">{{ scenario_metrics.currency or '—' }}</p>
|
||||
<span class="metric-caption">Financial reporting</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Primary Resource</h2>
|
||||
<p class="metric-value">{{ scenario_metrics.primary_resource or '—' }}</p>
|
||||
<span class="metric-caption">Scenario focus</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="scenario-layout">
|
||||
<div class="scenario-column">
|
||||
<section class="card">
|
||||
<h2>Scenario Details</h2>
|
||||
<h2>Scenario Overview</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
@@ -56,14 +71,16 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>
|
||||
{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}
|
||||
</dd>
|
||||
<dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Discount Rate</dt>
|
||||
<dd>{{ scenario.discount_rate or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario_metrics.primary_resource or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
@@ -71,6 +88,25 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card quick-actions-card">
|
||||
<h2>Next Steps</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ profitability_href }}">Run profitability analysis</a>
|
||||
<p>Uses this scenario’s assumptions as defaults.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ scenario_list_href }}">Browse all project scenarios</a>
|
||||
<p>Compare assumption sets and launch calculators in context.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Update scenario assumptions</a>
|
||||
<p>Adjust dates, status, or drivers before recalculations.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Financial Inputs</h2>
|
||||
{% if financial_inputs %}
|
||||
|
||||
@@ -16,10 +16,20 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% set error = error | default(None) %}
|
||||
{% set error_field = error_field | default(None) %}
|
||||
{% set currency_error = error if error_field == 'currency' else None %}
|
||||
{% set name_error = error if error_field == 'name' else None %}
|
||||
|
||||
{% if error and not error_field %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
|
||||
<p class="text-muted">Configure assumptions and metadata for this scenario.</p>
|
||||
<p class="text-muted">Scenarios inherit pricing defaults from <strong>{{ project.name }}</strong>.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
@@ -27,15 +37,50 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<section class="card scenario-context-card">
|
||||
<h2>Project Context</h2>
|
||||
<p class="field-help">Defaults below come from project pricing. Leave optional fields blank to reuse shared assumptions.</p>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Project</dt>
|
||||
<dd><a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type.value.replace('_', ' ') | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default Currency</dt>
|
||||
<dd>{{ default_currency or 'Not configured' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ul class="scenario-defaults">
|
||||
<li>
|
||||
<strong>Status Guidance</strong>
|
||||
<span>Draft scenarios remain internal; switch to Active once this represents your baseline, and Archive when retiring assumptions.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Baseline Reminder</strong>
|
||||
<span>Keep a single Active scenario to serve as the default baseline when launching profitability or planner workflows.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Currency Tip</strong>
|
||||
<span>If you leave currency empty, CalMiner applies the project default shown above.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<section class="card">
|
||||
<h2>Scenario Overview</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<div class="form-group{% if name_error %} form-group--error{% endif %}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" required value="{{ scenario.name if scenario else '' }}" />
|
||||
<input id="name" name="name" type="text" required value="{{ scenario.name if scenario else '' }}" {% if name_error %}aria-invalid="true"{% endif %} />
|
||||
{% if name_error %}
|
||||
<p class="field-error">{{ name_error }}</p>
|
||||
{% else %}
|
||||
<p class="field-help">Name must be unique within {{ project.name }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -45,14 +90,17 @@
|
||||
<option value="{{ value }}" {% if scenario and scenario.status.value == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Use Draft while iterating, set Active for your go-to baseline, and Archive to keep historical context.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group{% if currency_error %} form-group--error{% endif %}">
|
||||
<label for="currency">Currency</label>
|
||||
{% set currency_prefill = scenario.currency if scenario and scenario.currency else default_currency %}
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{{ currency_prefill or '' }}" placeholder="{{ default_currency or '' }}" />
|
||||
{% if default_currency %}
|
||||
<p class="field-help">Defaults to {{ default_currency }} when left blank.</p>
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{{ currency_prefill or '' }}" placeholder="{{ default_currency or '' }}" {% if currency_error %}aria-invalid="true"{% endif %} />
|
||||
{% if currency_error %}
|
||||
<p class="field-error">{{ currency_error }}</p>
|
||||
{% else %}
|
||||
<p class="field-help">Use a three-letter ISO code (e.g., USD). Defaults to {{ default_currency or 'the project currency' }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -64,28 +112,37 @@
|
||||
<option value="{{ value }}" {% if scenario and scenario.primary_resource and scenario.primary_resource.value == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Optional. Helps planners prioritise inputs tied to this commodity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Assumptions & Timeline</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="start_date">Start Date</label>
|
||||
<input id="start_date" name="start_date" type="date" value="{{ scenario.start_date if scenario else '' }}" />
|
||||
<p class="field-help">Optional. Use to align calculations with anticipated project kickoff.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_date">End Date</label>
|
||||
<input id="end_date" name="end_date" type="date" value="{{ scenario.end_date if scenario else '' }}" />
|
||||
<p class="field-help">Optional. Leave blank for open-ended scenarios.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discount_rate">Discount Rate (%)</label>
|
||||
<input id="discount_rate" name="discount_rate" type="number" step="0.01" value="{{ scenario.discount_rate if scenario else '' }}" />
|
||||
<p class="field-help">Leave empty to reuse the project default during profitability calculations.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="5">{{ scenario.description if scenario else '' }}</textarea>
|
||||
<textarea id="description" name="description" rows="5" placeholder="Describe the key drivers or differences for this scenario.">{{ scenario.description if scenario else '' }}</textarea>
|
||||
<p class="field-help">Summarise what distinguishes this scenario for collaborators and future audits.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
|
||||
142
templates/scenarios/list.html
Normal file
142
templates/scenarios/list.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Scenarios · {{ project.name }} · CalMiner{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/scenarios.css" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
<span aria-current="page">Scenarios</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Scenarios</h1>
|
||||
<p class="text-muted">Assumption sets and calculators for {{ project.name }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scenario-metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Total Scenarios</h2>
|
||||
<p class="metric-value">{{ scenario_totals.total }}</p>
|
||||
<span class="metric-caption">Across this project</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Active</h2>
|
||||
<p class="metric-value">{{ scenario_totals.active }}</p>
|
||||
<span class="metric-caption">Currently live analyses</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Draft</h2>
|
||||
<p class="metric-value">{{ scenario_totals.draft }}</p>
|
||||
<span class="metric-caption">Awaiting validation</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Archived</h2>
|
||||
<p class="metric-value">{{ scenario_totals.archived }}</p>
|
||||
<span class="metric-caption">Historical references</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="scenario-layout">
|
||||
<div class="scenario-column">
|
||||
<section class="card">
|
||||
<h2>Project Context</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Project</dt>
|
||||
<dd>{{ project.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type.value.replace('_', ' ') | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest Update</dt>
|
||||
<dd>{{ scenario_totals.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_totals.latest_update else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card quick-actions-card">
|
||||
<h2>Quick Actions</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Capture a new scenario</a>
|
||||
<p>Add additional assumption sets for profitability planning.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">Review project overview</a>
|
||||
<p>Cross-check project metadata before running calculators.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card scenario-portfolio">
|
||||
<header class="scenario-portfolio__header">
|
||||
<div>
|
||||
<h2>Scenario Portfolio</h2>
|
||||
<p class="text-muted">Each scenario below inherits pricing defaults and links directly into calculators.</p>
|
||||
</div>
|
||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
</header>
|
||||
{% if scenarios %}
|
||||
<ul class="scenario-list">
|
||||
{% for scenario in scenarios %}
|
||||
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||
{% set opex_href = url_for('calculations.opex_form') ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% set capex_href = url_for('calculations.capex_form') ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
<li class="scenario-item">
|
||||
<div class="scenario-item__body">
|
||||
<div class="scenario-item__header">
|
||||
<h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
|
||||
<span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
|
||||
</div>
|
||||
<dl class="scenario-item__meta">
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ (scenario.updated_at or scenario.created_at).strftime('%Y-%m-%d %H:%M') if scenario.updated_at or scenario.created_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="scenario-item__actions">
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
<a class="btn btn-link" href="{{ profitability_href }}">Profitability</a>
|
||||
<a class="btn btn-link" href="{{ opex_href }}">Opex</a>
|
||||
<a class="btn btn-link" href="{{ capex_href }}">Capex</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
312
templates/scenarios/opex.html
Normal file
312
templates/scenarios/opex.html
Normal file
@@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Opex Planner · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
{% if project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
{% endif %}
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Opex Planner</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Opex Planner</h1>
|
||||
<p class="text-muted">Capture recurring operational costs and review annual totals with escalation assumptions.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if scenario_url %}
|
||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit" form="opex-form">Save & Calculate</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<h2 class="sr-only">Submission errors</h2>
|
||||
<ul>
|
||||
{% for message in errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notices %}
|
||||
<div class="alert alert-info">
|
||||
<ul>
|
||||
{% for message in notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="opex-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
||||
<input type="hidden" name="options[persist]" value="{{ '1' if options and options.persist else '' }}" />
|
||||
|
||||
<div class="layout-two-column stackable">
|
||||
<section class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Opex Components</h2>
|
||||
<p class="section-subtitle">List recurring cost items with frequency, unit cost, and quantities.</p>
|
||||
</header>
|
||||
|
||||
<div class="table-actions">
|
||||
<button class="btn secondary" type="button" data-action="add-opex-component">Add Component</button>
|
||||
</div>
|
||||
|
||||
{% if component_errors %}
|
||||
<div class="alert alert-error slim" role="alert" aria-live="polite">
|
||||
<h3 class="sr-only">Component issues</h3>
|
||||
<ul>
|
||||
{% for message in component_errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if component_notices %}
|
||||
<div class="alert alert-info slim" role="status" aria-live="polite">
|
||||
<ul>
|
||||
{% for message in component_notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive horizontal-scroll">
|
||||
<table class="input-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Component</th>
|
||||
<th scope="col">Unit Cost</th>
|
||||
<th scope="col">Quantity</th>
|
||||
<th scope="col">Frequency</th>
|
||||
<th scope="col">Currency</th>
|
||||
<th scope="col">Start Period</th>
|
||||
<th scope="col">End Period</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set component_entries = (components if components else [{}]) %}
|
||||
{% for component in component_entries %}
|
||||
<tr data-row-index="{{ loop.index0 }}">
|
||||
<td>
|
||||
<input type="hidden" name="components[{{ loop.index0 }}][id]" value="{{ component.id or '' }}" />
|
||||
<select name="components[{{ loop.index0 }}][category]">
|
||||
{% for option in category_options %}
|
||||
<option value="{{ option.value }}" {% if component.category == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="components[{{ loop.index0 }}][name]" value="{{ component.name or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][unit_cost]" value="{{ component.unit_cost or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][quantity]" value="{{ component.quantity or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<select name="components[{{ loop.index0 }}][frequency]">
|
||||
{% for option in frequency_options %}
|
||||
<option value="{{ option.value }}" {% if component.frequency == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" maxlength="3" name="components[{{ loop.index0 }}][currency]" value="{{ component.currency or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_start]" value="{{ component.period_start or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_end]" value="{{ component.period_end or '' }}" />
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn link" type="button" data-action="remove-opex-component">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="muted">Use start and end periods to indicate when the cost applies within the evaluation horizon.</p>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Global Parameters</h2>
|
||||
<p class="section-subtitle">Control escalation and discount assumptions applied to totals.</p>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="opex_currency_code">Default Currency</label>
|
||||
<input id="opex_currency_code" name="parameters[currency_code]" type="text" maxlength="3" value="{{ parameters.currency_code or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex_escalation_pct">Escalation (%)</label>
|
||||
<input id="opex_escalation_pct" name="parameters[escalation_pct]" type="number" min="0" max="100" step="0.01" value="{{ parameters.escalation_pct if parameters.escalation_pct is not none else '' }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex_discount_rate_pct">Discount Rate (%)</label>
|
||||
<input id="opex_discount_rate_pct" name="parameters[discount_rate_pct]" type="number" min="0" max="100" step="0.01" value="{{ parameters.discount_rate_pct if parameters.discount_rate_pct is not none else (scenario.discount_rate if scenario else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex_horizon_years">Evaluation Horizon (years)</label>
|
||||
<input id="opex_horizon_years" name="parameters[evaluation_horizon_years]" type="number" min="1" step="1" value="{{ parameters.evaluation_horizon_years if parameters.evaluation_horizon_years is not none else default_horizon }}" />
|
||||
</div>
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="parameters[apply_escalation]" value="1" {% if parameters.apply_escalation %}checked{% endif %} />
|
||||
Apply escalation to timeline totals
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="snapshot_notes">Snapshot Notes</label>
|
||||
<textarea id="snapshot_notes" name="options[snapshot_notes]" rows="3">{{ options.snapshot_notes or '' }}</textarea>
|
||||
<p class="field-help">Optional. Appears alongside persisted snapshots.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Assumptions</h3>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Categories Configured</dt>
|
||||
<dd>{{ category_options | length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Frequencies Supported</dt>
|
||||
<dd>{{ frequency_options | length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ last_updated_at or '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="muted">Defaults reflect scenario preferences. Adjust before calculating.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Opex Summary</h2>
|
||||
<p class="section-subtitle">Annual totals, escalation impacts, and category breakdowns.</p>
|
||||
</header>
|
||||
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Annual Opex Total</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.overall_annual | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Escalated Total</h3>
|
||||
<p class="metric">
|
||||
<strong>
|
||||
{% if result.totals.escalated_total is not none %}
|
||||
{{ result.totals.escalated_total | currency_display(result.currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Annual Average (Escalated)</h3>
|
||||
<p class="metric">
|
||||
<strong>
|
||||
{% if result.metrics.annual_average is not none %}
|
||||
{{ result.metrics.annual_average | currency_display(result.currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</strong>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Category Breakdown</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Annual Cost</th>
|
||||
<th scope="col">Share (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.totals.by_category %}
|
||||
<tr>
|
||||
<td>{{ entry.category | title }}</td>
|
||||
<td>{{ entry.annual_cost | currency_display(result.currency) }}</td>
|
||||
<td>{% if entry.share is not none %}{{ entry.share | round(2) }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Timeline</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Period</th>
|
||||
<th scope="col">Base Cost</th>
|
||||
<th scope="col">Escalated Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.timeline %}
|
||||
<tr>
|
||||
<td>{{ entry.period }}</td>
|
||||
<td>{{ entry.base_cost | currency_display(result.currency) }}</td>
|
||||
<td>
|
||||
{% if entry.escalated_cost is not none %}
|
||||
{{ entry.escalated_cost | currency_display(result.currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Run the calculation to populate summary metrics and timeline insights.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
345
templates/scenarios/profitability.html
Normal file
345
templates/scenarios/profitability.html
Normal file
@@ -0,0 +1,345 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Profitability Calculator · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
{% if project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
{% endif %}
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Profitability Calculator</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Profitability Calculator</h1>
|
||||
<p class="text-muted">Evaluate revenue, costs, and key financial metrics for a scenario.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if scenario_url %}
|
||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit" form="profitability-form">Run Calculation</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<h2 class="sr-only">Submission errors</h2>
|
||||
<ul>
|
||||
{% for message in errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notices %}
|
||||
<div class="alert alert-info">
|
||||
<ul>
|
||||
{% for message in notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout-two-column">
|
||||
<section class="panel">
|
||||
<h2>Input Parameters</h2>
|
||||
<form id="profitability-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="metal">Commodity</label>
|
||||
<select id="metal" name="metal" required>
|
||||
{% for metal in supported_metals %}
|
||||
<option value="{{ metal.value }}" {% if data.metal == metal.value %}selected{% endif %}>{{ metal.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ore_tonnage">Ore Tonnage (t)</label>
|
||||
<input id="ore_tonnage" name="ore_tonnage" type="number" min="0" step="0.01" value="{{ data.ore_tonnage }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="head_grade_pct">Head Grade (%)</label>
|
||||
<input id="head_grade_pct" name="head_grade_pct" type="number" min="0" max="100" step="0.01" value="{{ data.head_grade_pct }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="recovery_pct">Recovery (%)</label>
|
||||
<input id="recovery_pct" name="recovery_pct" type="number" min="0" max="100" step="0.01" value="{{ data.recovery_pct }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payable_pct">Payable (%)</label>
|
||||
<input id="payable_pct" name="payable_pct" type="number" min="0" max="100" step="0.01" value="{{ data.payable_pct or metadata.default_payable_pct }}" />
|
||||
<p class="field-help">Default {{ metadata.default_payable_pct or 100 }}% if blank.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="reference_price">Reference Price</label>
|
||||
<input id="reference_price" name="reference_price" type="number" min="0" step="0.01" value="{{ data.reference_price }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fx_rate">FX Rate</label>
|
||||
<input id="fx_rate" name="fx_rate" type="number" min="0" step="0.0001" value="{{ data.fx_rate or 1 }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="currency_code">Scenario Currency</label>
|
||||
<input id="currency_code" name="currency_code" type="text" maxlength="3" value="{{ data.currency_code or scenario.currency or project.currency }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="form-fieldset">
|
||||
<legend>Processing Charges</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="treatment_charge">Treatment Charge</label>
|
||||
<input id="treatment_charge" name="treatment_charge" type="number" min="0" step="0.01" value="{{ data.treatment_charge }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smelting_charge">Smelting Charge</label>
|
||||
<input id="smelting_charge" name="smelting_charge" type="number" min="0" step="0.01" value="{{ data.smelting_charge }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex">Opex (per period)</label>
|
||||
<input id="opex" name="opex" type="number" min="0" step="0.01" value="{{ data.opex }}" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-fieldset">
|
||||
<legend>Penalties & Premiums</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="moisture_pct">Moisture (%)</label>
|
||||
<input id="moisture_pct" name="moisture_pct" type="number" min="0" max="100" step="0.01" value="{{ data.moisture_pct }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="moisture_threshold_pct">Moisture Threshold (%)</label>
|
||||
<input id="moisture_threshold_pct" name="moisture_threshold_pct" type="number" min="0" max="100" step="0.01" value="{{ data.moisture_threshold_pct or metadata.moisture_threshold_pct }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="moisture_penalty_per_pct">Moisture Penalty / %</label>
|
||||
<input id="moisture_penalty_per_pct" name="moisture_penalty_per_pct" type="number" step="0.01" value="{{ data.moisture_penalty_per_pct or metadata.moisture_penalty_per_pct }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="premiums">Premiums / Credits</label>
|
||||
<input id="premiums" name="premiums" type="number" step="0.01" value="{{ data.premiums }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="impurity-table">
|
||||
<label>Impurities</label>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Content (ppm)</th>
|
||||
<th scope="col">Threshold (ppm)</th>
|
||||
<th scope="col">Penalty / ppm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set impurity_entries = data.impurities or metadata_impurities %}
|
||||
{% for impurity in impurity_entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" name="impurities[{{ loop.index0 }}][name]" value="{{ impurity.name }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][value]" value="{{ impurity.value }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][threshold]" value="{{ impurity.threshold }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][penalty]" value="{{ impurity.penalty }}" />
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="muted">No impurity penalties configured.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-fieldset">
|
||||
<legend>Capital & Discounting</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="capex">Capex</label>
|
||||
<input id="capex" name="capex" type="number" min="0" step="0.01" value="{{ data.capex }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sustaining_capex">Sustaining Capex (per period)</label>
|
||||
<input id="sustaining_capex" name="sustaining_capex" type="number" min="0" step="0.01" value="{{ data.sustaining_capex }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discount_rate">Discount Rate (%)</label>
|
||||
<input id="discount_rate" name="discount_rate" type="number" min="0" max="100" step="0.01" value="{{ data.discount_rate or scenario.discount_rate }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="periods">Evaluation Periods</label>
|
||||
<input id="periods" name="periods" type="number" min="1" step="1" value="{{ data.periods or default_periods }}" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<h2>Assumption Summary</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Default Payable</dt>
|
||||
<dd>{{ metadata.default_payable_pct or 100 }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Moisture Threshold</dt>
|
||||
<dd>{{ metadata.moisture_threshold_pct or 0 }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Moisture Penalty</dt>
|
||||
<dd>{{ metadata.moisture_penalty_per_pct or 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Base Currency</dt>
|
||||
<dd>{{ metadata.default_currency or "—" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{% if metadata_impurities %}
|
||||
<h3>Configured Impurities</h3>
|
||||
<ul class="metric-list compact">
|
||||
{% for impurity in metadata_impurities %}
|
||||
<li>
|
||||
<span>{{ impurity.name }}</span>
|
||||
<strong>Threshold {{ impurity.threshold }} ppm · Penalty {{ impurity.penalty }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted">
|
||||
Adjust values to reflect contract terms. Leave fields blank to use defaults sourced from pricing metadata.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Calculation Results</h2>
|
||||
<p class="section-subtitle">Outputs reflect the latest submission.</p>
|
||||
</header>
|
||||
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Revenue Summary</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Payable Metal</span>
|
||||
<strong>{{ result.pricing.payable_metal_tonnes | default('—') }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Gross Revenue</span>
|
||||
<strong>{{ result.pricing.gross_revenue | currency_display(result.pricing.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Net Revenue</span>
|
||||
<strong>{{ result.pricing.net_revenue | currency_display(result.pricing.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h3>Cost Breakdown</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Opex</span>
|
||||
<strong>{{ result.costs.opex_total | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Sustaining Capex</span>
|
||||
<strong>{{ result.costs.sustaining_capex_total | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Capex</span>
|
||||
<strong>{{ result.costs.capex | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h3>Key Metrics</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>NPV</span>
|
||||
<strong>{{ result.metrics.npv | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>IRR</span>
|
||||
<strong>{{ result.metrics.irr | percentage_display }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Payback</span>
|
||||
<strong>{{ result.metrics.payback_period | period_display }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Margin</span>
|
||||
<strong>{{ result.metrics.margin | percentage_display }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{% if result.cash_flows %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Period</th>
|
||||
<th scope="col">Revenue</th>
|
||||
<th scope="col">Opex</th>
|
||||
<th scope="col">Sustaining Capex</th>
|
||||
<th scope="col">Net Cash Flow</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.cash_flows %}
|
||||
<tr>
|
||||
<th scope="row">{{ entry.period }}</th>
|
||||
<td>{{ entry.revenue | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.opex | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.sustaining_capex | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.net | currency_display(result.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">Provide inputs and run the profitability calculator to see scenario metrics.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Visualisations</h2>
|
||||
<p class="section-subtitle">Charts render after calculations complete.</p>
|
||||
</header>
|
||||
<div id="profitability-chart" class="chart-container"></div>
|
||||
<div id="cashflow-chart" class="chart-container"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -17,11 +17,13 @@ from dependencies import get_auth_session, get_import_ingestion_service, get_uni
|
||||
from models import User
|
||||
from routes.auth import router as auth_router
|
||||
from routes.dashboard import router as dashboard_router
|
||||
from routes.calculations import router as calculations_router
|
||||
from routes.projects import router as projects_router
|
||||
from routes.scenarios import router as scenarios_router
|
||||
from routes.imports import router as imports_router
|
||||
from routes.exports import router as exports_router
|
||||
from routes.reports import router as reports_router
|
||||
from routes.ui import router as ui_router
|
||||
from services.importers import ImportIngestionService
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from services.session import AuthSession, SessionTokens
|
||||
@@ -56,11 +58,13 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(auth_router)
|
||||
application.include_router(dashboard_router)
|
||||
application.include_router(calculations_router)
|
||||
application.include_router(projects_router)
|
||||
application.include_router(scenarios_router)
|
||||
application.include_router(imports_router)
|
||||
application.include_router(exports_router)
|
||||
application.include_router(reports_router)
|
||||
application.include_router(ui_router)
|
||||
|
||||
def _override_uow() -> Iterator[UnitOfWork]:
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
|
||||
193
tests/integration/test_capex_calculations.py
Normal file
193
tests/integration/test_capex_calculations.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str) -> int:
|
||||
response = client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"name": name,
|
||||
"location": "Nevada",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Project for capex testing",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": name,
|
||||
"description": "Capex scenario",
|
||||
"status": "draft",
|
||||
"currency": "usd",
|
||||
"primary_resource": "diesel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_capex_calculation_html_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Capex HTML Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Capex HTML Scenario")
|
||||
|
||||
form_page = client.get(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}"
|
||||
)
|
||||
assert form_page.status_code == 200
|
||||
assert "Capex Planner" in form_page.text
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Crusher",
|
||||
"components[0][category]": "equipment",
|
||||
"components[0][amount]": "1200000",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][spend_year]": "0",
|
||||
"components[1][name]": "Conveyor",
|
||||
"components[1][category]": "equipment",
|
||||
"components[1][amount]": "800000",
|
||||
"components[1][currency]": "USD",
|
||||
"components[1][spend_year]": "1",
|
||||
"parameters[currency_code]": "USD",
|
||||
"parameters[contingency_pct]": "5",
|
||||
"parameters[discount_rate_pct]": "8",
|
||||
"parameters[evaluation_horizon_years]": "5",
|
||||
"options[persist]": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Capex calculation completed successfully." in response.text
|
||||
assert "Capex Summary" in response.text
|
||||
assert "$1,200,000.00" in response.text or "1,200,000" in response.text
|
||||
assert "USD" in response.text
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_capex is not None
|
||||
assert uow.scenario_capex is not None
|
||||
|
||||
project_snapshots = uow.project_capex.list_for_project(project_id)
|
||||
scenario_snapshots = uow.scenario_capex.list_for_scenario(scenario_id)
|
||||
|
||||
assert len(project_snapshots) == 1
|
||||
assert len(scenario_snapshots) == 1
|
||||
|
||||
project_snapshot = project_snapshots[0]
|
||||
scenario_snapshot = scenario_snapshots[0]
|
||||
|
||||
assert project_snapshot.total_capex is not None
|
||||
assert project_snapshot.contingency_pct is not None
|
||||
assert project_snapshot.total_with_contingency is not None
|
||||
assert float(project_snapshot.total_capex) == pytest.approx(2_000_000)
|
||||
assert float(project_snapshot.contingency_pct) == pytest.approx(5.0)
|
||||
assert float(project_snapshot.total_with_contingency) == pytest.approx(
|
||||
2_100_000)
|
||||
assert project_snapshot.component_count == 2
|
||||
assert project_snapshot.currency_code == "USD"
|
||||
|
||||
assert scenario_snapshot.total_capex is not None
|
||||
assert scenario_snapshot.contingency_amount is not None
|
||||
assert scenario_snapshot.total_with_contingency is not None
|
||||
assert float(scenario_snapshot.total_capex) == pytest.approx(2_000_000)
|
||||
assert float(
|
||||
scenario_snapshot.contingency_amount) == pytest.approx(100_000)
|
||||
assert float(scenario_snapshot.total_with_contingency) == pytest.approx(
|
||||
2_100_000)
|
||||
assert scenario_snapshot.component_count == 2
|
||||
assert scenario_snapshot.currency_code == "USD"
|
||||
assert scenario_snapshot.payload is not None
|
||||
|
||||
|
||||
def test_capex_calculation_json_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Capex JSON Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Capex JSON Scenario")
|
||||
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Camp",
|
||||
"category": "infrastructure",
|
||||
"amount": 600000,
|
||||
"currency": "USD",
|
||||
"spend_year": 0,
|
||||
},
|
||||
{
|
||||
"name": "Power",
|
||||
"category": "infrastructure",
|
||||
"amount": 400000,
|
||||
"currency": "USD",
|
||||
"spend_year": 1,
|
||||
},
|
||||
],
|
||||
"parameters": {
|
||||
"currency_code": "USD",
|
||||
"contingency_pct": 12.5,
|
||||
"discount_rate_pct": 6.5,
|
||||
"evaluation_horizon_years": 4,
|
||||
},
|
||||
"options": {"persist": True},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["currency"] == "USD"
|
||||
assert data["totals"]["overall"] == 1_000_000
|
||||
assert data["totals"]["contingency_pct"] == pytest.approx(12.5)
|
||||
assert data["totals"]["contingency_amount"] == pytest.approx(125000)
|
||||
assert data["totals"]["with_contingency"] == pytest.approx(1_125_000)
|
||||
|
||||
by_category = {row["category"]: row for row in data["totals"]["by_category"]}
|
||||
assert by_category["infrastructure"]["amount"] == pytest.approx(1_000_000)
|
||||
assert by_category["infrastructure"]["share"] == pytest.approx(100)
|
||||
|
||||
assert len(data["timeline"]) == 2
|
||||
assert data["timeline"][0]["year"] == 0
|
||||
assert data["timeline"][0]["spend"] == pytest.approx(600_000)
|
||||
assert data["timeline"][1]["cumulative"] == pytest.approx(1_000_000)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_capex is not None
|
||||
assert uow.scenario_capex is not None
|
||||
|
||||
scenario_snapshot = uow.scenario_capex.latest_for_scenario(scenario_id)
|
||||
project_snapshot = uow.project_capex.latest_for_project(project_id)
|
||||
|
||||
assert scenario_snapshot is not None
|
||||
assert project_snapshot is not None
|
||||
|
||||
assert scenario_snapshot.total_capex is not None
|
||||
assert project_snapshot.total_with_contingency is not None
|
||||
assert float(scenario_snapshot.total_capex) == pytest.approx(1_000_000)
|
||||
assert float(project_snapshot.total_with_contingency) == pytest.approx(
|
||||
1_125_000)
|
||||
|
||||
assert scenario_snapshot.payload is not None
|
||||
payload = scenario_snapshot.payload or {}
|
||||
result_payload = payload.get("result", {})
|
||||
assert result_payload.get("currency") == "USD"
|
||||
assert result_payload.get("totals", {}).get(
|
||||
"with_contingency") == pytest.approx(1_125_000)
|
||||
assert result_payload.get("totals", {}).get(
|
||||
"overall") == pytest.approx(1_000_000)
|
||||
310
tests/integration/test_opex_calculations.py
Normal file
310
tests/integration/test_opex_calculations.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str) -> int:
|
||||
response = client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"name": name,
|
||||
"location": "Nevada",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Project for opex testing",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json={
|
||||
"name": name,
|
||||
"description": "Processing opex scenario",
|
||||
"status": "draft",
|
||||
"currency": "usd",
|
||||
"primary_resource": "diesel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_opex_calculation_html_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Opex HTML Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario")
|
||||
|
||||
form_page = client.get(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}"
|
||||
)
|
||||
assert form_page.status_code == 200
|
||||
assert "Opex Planner" in form_page.text
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Power",
|
||||
"components[0][category]": "energy",
|
||||
"components[0][unit_cost]": "1000",
|
||||
"components[0][quantity]": "1",
|
||||
"components[0][frequency]": "monthly",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][period_start]": "1",
|
||||
"components[0][period_end]": "3",
|
||||
"components[1][name]": "Maintenance",
|
||||
"components[1][category]": "maintenance",
|
||||
"components[1][unit_cost]": "2500",
|
||||
"components[1][quantity]": "1",
|
||||
"components[1][frequency]": "quarterly",
|
||||
"components[1][currency]": "USD",
|
||||
"components[1][period_start]": "1",
|
||||
"components[1][period_end]": "2",
|
||||
"parameters[currency_code]": "USD",
|
||||
"parameters[escalation_pct]": "5",
|
||||
"parameters[discount_rate_pct]": "3",
|
||||
"parameters[evaluation_horizon_years]": "3",
|
||||
"parameters[apply_escalation]": "1",
|
||||
"options[persist]": "1",
|
||||
"options[snapshot_notes]": "Opex HTML flow",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Opex calculation completed successfully." in response.text
|
||||
assert "Opex Summary" in response.text
|
||||
assert "$22,000.00" in response.text or "22,000" in response.text
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_opex is not None
|
||||
assert uow.scenario_opex is not None
|
||||
|
||||
project_snapshots = uow.project_opex.list_for_project(
|
||||
project_id)
|
||||
scenario_snapshots = uow.scenario_opex.list_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert len(project_snapshots) == 1
|
||||
assert len(scenario_snapshots) == 1
|
||||
|
||||
project_snapshot = project_snapshots[0]
|
||||
scenario_snapshot = scenario_snapshots[0]
|
||||
|
||||
assert project_snapshot.overall_annual is not None
|
||||
assert float(
|
||||
project_snapshot.overall_annual) == pytest.approx(22_000.0)
|
||||
assert project_snapshot.escalated_total is not None
|
||||
assert float(
|
||||
project_snapshot.escalated_total) == pytest.approx(58_330.0)
|
||||
assert project_snapshot.apply_escalation is True
|
||||
assert project_snapshot.component_count == 2
|
||||
assert project_snapshot.currency_code == "USD"
|
||||
|
||||
assert scenario_snapshot.overall_annual is not None
|
||||
assert float(
|
||||
scenario_snapshot.overall_annual) == pytest.approx(22_000.0)
|
||||
assert scenario_snapshot.escalated_total is not None
|
||||
assert float(
|
||||
scenario_snapshot.escalated_total) == pytest.approx(58_330.0)
|
||||
assert scenario_snapshot.apply_escalation is True
|
||||
assert scenario_snapshot.component_count == 2
|
||||
assert scenario_snapshot.currency_code == "USD"
|
||||
|
||||
|
||||
def test_opex_calculation_json_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Opex JSON Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Opex JSON Scenario")
|
||||
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Reagents",
|
||||
"category": "materials",
|
||||
"unit_cost": 400,
|
||||
"quantity": 10,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 3,
|
||||
},
|
||||
{
|
||||
"name": "Labor",
|
||||
"category": "labor",
|
||||
"unit_cost": 1500,
|
||||
"quantity": 4,
|
||||
"frequency": "weekly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 3,
|
||||
},
|
||||
{
|
||||
"name": "Maintenance",
|
||||
"category": "maintenance",
|
||||
"unit_cost": 12000,
|
||||
"quantity": 1,
|
||||
"frequency": "annually",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 3,
|
||||
},
|
||||
],
|
||||
"parameters": {
|
||||
"currency_code": "USD",
|
||||
"escalation_pct": 4,
|
||||
"discount_rate_pct": 2,
|
||||
"evaluation_horizon_years": 3,
|
||||
"apply_escalation": True,
|
||||
},
|
||||
"options": {"persist": True, "snapshot_notes": "Processing opex JSON flow"},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["currency"] == "USD"
|
||||
|
||||
expected_overall = 372_000.0
|
||||
escalation_factor = 1 + (payload["parameters"]["escalation_pct"] / 100.0)
|
||||
expected_timeline = [
|
||||
expected_overall * (escalation_factor ** i) for i in range(payload["parameters"]["evaluation_horizon_years"])
|
||||
]
|
||||
expected_escalated_total = sum(expected_timeline)
|
||||
expected_average = expected_escalated_total / len(expected_timeline)
|
||||
|
||||
assert data["totals"]["overall_annual"] == pytest.approx(expected_overall)
|
||||
assert data["totals"]["escalated_total"] == pytest.approx(
|
||||
expected_escalated_total)
|
||||
assert data["totals"]["escalation_pct"] == pytest.approx(4.0)
|
||||
|
||||
by_category = {entry["category"] : entry for entry in data["totals"]["by_category"]}
|
||||
assert by_category["materials"]["annual_cost"] == pytest.approx(48_000.0)
|
||||
assert by_category["labor"]["annual_cost"] == pytest.approx(312_000.0)
|
||||
assert by_category["maintenance"]["annual_cost"] == pytest.approx(12_000.0)
|
||||
|
||||
assert len(data["timeline"]) == 3
|
||||
for index, entry in enumerate(data["timeline"], start=0):
|
||||
assert entry["period"] == index + 1
|
||||
assert entry["base_cost"] == pytest.approx(expected_overall)
|
||||
assert entry["escalated_cost"] == pytest.approx(
|
||||
expected_timeline[index])
|
||||
|
||||
assert data["metrics"]["annual_average"] == pytest.approx(expected_average)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_opex is not None
|
||||
assert uow.scenario_opex is not None
|
||||
|
||||
project_snapshot = uow.project_opex.latest_for_project(
|
||||
project_id)
|
||||
scenario_snapshot = uow.scenario_opex.latest_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert project_snapshot is not None
|
||||
assert scenario_snapshot is not None
|
||||
|
||||
assert project_snapshot.overall_annual is not None
|
||||
assert float(project_snapshot.overall_annual) == pytest.approx(
|
||||
expected_overall)
|
||||
assert project_snapshot.escalated_total is not None
|
||||
assert float(project_snapshot.escalated_total) == pytest.approx(
|
||||
expected_escalated_total)
|
||||
assert project_snapshot.apply_escalation is True
|
||||
|
||||
assert scenario_snapshot.annual_average is not None
|
||||
assert float(scenario_snapshot.annual_average) == pytest.approx(
|
||||
expected_average)
|
||||
assert scenario_snapshot.apply_escalation is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["form", "json"])
|
||||
def test_opex_calculation_currency_mismatch(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
content_type: str,
|
||||
) -> None:
|
||||
project_id = _create_project(
|
||||
client, f"Opex {content_type.title()} Error Project")
|
||||
scenario_id = _create_scenario(
|
||||
client, project_id, f"Opex {content_type.title()} Error Scenario")
|
||||
|
||||
if content_type == "json":
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Power",
|
||||
"category": "energy",
|
||||
"unit_cost": 500,
|
||||
"quantity": 1,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 2,
|
||||
}
|
||||
],
|
||||
"parameters": {"currency_code": "CAD"},
|
||||
"options": {"persist": True},
|
||||
}
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert "Component currency does not match" in body.get("message", "")
|
||||
assert any(
|
||||
"components[0].currency" in entry for entry in body.get("errors", []))
|
||||
else:
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Power",
|
||||
"components[0][category]": "energy",
|
||||
"components[0][unit_cost]": "500",
|
||||
"components[0][quantity]": "1",
|
||||
"components[0][frequency]": "monthly",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][period_start]": "1",
|
||||
"components[0][period_end]": "2",
|
||||
"parameters[currency_code]": "CAD",
|
||||
"options[persist]": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert hasattr(response, "context")
|
||||
context = getattr(response, "context", {}) or {}
|
||||
combined_errors = [
|
||||
str(entry)
|
||||
for entry in (
|
||||
(context.get("errors") or [])
|
||||
+ (context.get("component_errors") or [])
|
||||
)
|
||||
]
|
||||
assert any(
|
||||
"components[0].currency" in entry for entry in combined_errors)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_opex is not None
|
||||
assert uow.scenario_opex is not None
|
||||
|
||||
project_snapshots = uow.project_opex.list_for_project(
|
||||
project_id)
|
||||
scenario_snapshots = uow.scenario_opex.list_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert project_snapshots == []
|
||||
assert scenario_snapshots == []
|
||||
93
tests/services/test_calculations_capex.py
Normal file
93
tests/services/test_calculations_capex.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import pytest
|
||||
|
||||
from schemas.calculations import (
|
||||
CapexCalculationOptions,
|
||||
CapexCalculationRequest,
|
||||
CapexComponentInput,
|
||||
CapexParameters,
|
||||
)
|
||||
from services.calculations import calculate_initial_capex
|
||||
from services.exceptions import CapexValidationError
|
||||
|
||||
|
||||
def _component(**kwargs) -> CapexComponentInput:
|
||||
defaults = {
|
||||
"id": None,
|
||||
"name": "Component",
|
||||
"category": "equipment",
|
||||
"amount": 1_000_000.0,
|
||||
"currency": "USD",
|
||||
"spend_year": 0,
|
||||
"notes": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return CapexComponentInput(**defaults)
|
||||
|
||||
|
||||
def test_calculate_initial_capex_success():
|
||||
request = CapexCalculationRequest(
|
||||
components=[
|
||||
_component(name="Crusher", category="equipment",
|
||||
amount=1_200_000, spend_year=0),
|
||||
_component(name="Conveyor", category="equipment",
|
||||
amount=800_000, spend_year=1),
|
||||
_component(name="Camp", category="infrastructure",
|
||||
amount=600_000, spend_year=1, currency="usd"),
|
||||
],
|
||||
parameters=CapexParameters(
|
||||
currency_code="USD",
|
||||
contingency_pct=10,
|
||||
discount_rate_pct=8,
|
||||
evaluation_horizon_years=5,
|
||||
),
|
||||
options=CapexCalculationOptions(persist=True),
|
||||
)
|
||||
|
||||
result = calculate_initial_capex(request)
|
||||
|
||||
assert result.currency == "USD"
|
||||
assert result.options.persist is True
|
||||
|
||||
assert result.totals.overall == pytest.approx(2_600_000)
|
||||
assert result.totals.contingency_pct == pytest.approx(10)
|
||||
assert result.totals.contingency_amount == pytest.approx(260_000)
|
||||
assert result.totals.with_contingency == pytest.approx(2_860_000)
|
||||
|
||||
by_category = {row.category: row for row in result.totals.by_category}
|
||||
assert by_category["equipment"].amount == pytest.approx(2_000_000)
|
||||
assert by_category["infrastructure"].amount == pytest.approx(600_000)
|
||||
assert by_category["equipment"].share == pytest.approx(76.923, rel=1e-3)
|
||||
assert by_category["infrastructure"].share == pytest.approx(
|
||||
23.077, rel=1e-3)
|
||||
|
||||
timeline = {(entry.year, entry.spend): entry.cumulative for entry in result.timeline}
|
||||
assert timeline[(0, 1_200_000)] == pytest.approx(1_200_000)
|
||||
assert timeline[(1, 1_400_000)] == pytest.approx(2_600_000)
|
||||
|
||||
assert len(result.components) == 3
|
||||
assert result.components[2].currency == "USD"
|
||||
|
||||
|
||||
def test_calculate_initial_capex_currency_mismatch_raises():
|
||||
request = CapexCalculationRequest(
|
||||
components=[
|
||||
_component(amount=500_000, currency="USD"),
|
||||
],
|
||||
parameters=CapexParameters(currency_code="CAD"),
|
||||
)
|
||||
|
||||
with pytest.raises(CapexValidationError) as exc:
|
||||
calculate_initial_capex(request)
|
||||
|
||||
assert "Component currency does not match" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_initial_capex_requires_components():
|
||||
request = CapexCalculationRequest(components=[])
|
||||
|
||||
with pytest.raises(CapexValidationError) as exc:
|
||||
calculate_initial_capex(request)
|
||||
|
||||
assert "At least one capex component" in exc.value.message
|
||||
assert exc.value.field_errors and "components" in exc.value.field_errors[0]
|
||||
159
tests/services/test_calculations_opex.py
Normal file
159
tests/services/test_calculations_opex.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
|
||||
from schemas.calculations import (
|
||||
OpexCalculationRequest,
|
||||
OpexComponentInput,
|
||||
OpexOptions,
|
||||
OpexParameters,
|
||||
)
|
||||
from services.calculations import calculate_opex
|
||||
from services.exceptions import OpexValidationError
|
||||
|
||||
|
||||
def _component(**overrides) -> OpexComponentInput:
|
||||
defaults = {
|
||||
"id": None,
|
||||
"name": "Component",
|
||||
"category": "energy",
|
||||
"unit_cost": 1000.0,
|
||||
"quantity": 1.0,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 1,
|
||||
"notes": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return OpexComponentInput(**defaults)
|
||||
|
||||
|
||||
def test_calculate_opex_success():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(
|
||||
name="Power",
|
||||
category="energy",
|
||||
unit_cost=1000.0,
|
||||
quantity=1,
|
||||
frequency="monthly",
|
||||
period_start=1,
|
||||
period_end=3,
|
||||
),
|
||||
_component(
|
||||
name="Maintenance",
|
||||
category="maintenance",
|
||||
unit_cost=2500.0,
|
||||
quantity=1,
|
||||
frequency="quarterly",
|
||||
period_start=1,
|
||||
period_end=2,
|
||||
),
|
||||
],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=5,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
apply_escalation=True,
|
||||
),
|
||||
options=OpexOptions(persist=True, snapshot_notes=None),
|
||||
)
|
||||
|
||||
result = calculate_opex(request)
|
||||
|
||||
assert result.currency == "USD"
|
||||
assert result.options.persist is True
|
||||
|
||||
assert result.totals.overall_annual == pytest.approx(22_000.0)
|
||||
assert result.totals.escalated_total == pytest.approx(58_330.0, rel=1e-4)
|
||||
assert result.totals.escalation_pct == pytest.approx(5.0)
|
||||
|
||||
categories = {entry.category: entry for entry in result.totals.by_category}
|
||||
assert categories["energy"].annual_cost == pytest.approx(12_000.0)
|
||||
assert categories["maintenance"].annual_cost == pytest.approx(10_000.0)
|
||||
|
||||
assert len(result.timeline) == 3
|
||||
timeline = {entry.period: entry for entry in result.timeline}
|
||||
assert timeline[1].base_cost == pytest.approx(22_000.0)
|
||||
assert timeline[2].base_cost == pytest.approx(22_000.0)
|
||||
assert timeline[3].base_cost == pytest.approx(12_000.0)
|
||||
assert timeline[1].escalated_cost == pytest.approx(22_000.0)
|
||||
assert timeline[2].escalated_cost == pytest.approx(23_100.0, rel=1e-4)
|
||||
assert timeline[3].escalated_cost == pytest.approx(13_230.0, rel=1e-4)
|
||||
|
||||
assert result.metrics.annual_average == pytest.approx(
|
||||
19_443.3333, rel=1e-4)
|
||||
|
||||
assert len(result.components) == 2
|
||||
assert result.components[0].frequency == "monthly"
|
||||
assert result.components[1].frequency == "quarterly"
|
||||
|
||||
|
||||
def test_calculate_opex_currency_mismatch():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(currency="USD")],
|
||||
parameters=OpexParameters(
|
||||
currency_code="CAD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=10,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Component currency does not match" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_unsupported_frequency():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(frequency="biweekly")],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Unsupported frequency" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_requires_components():
|
||||
request = OpexCalculationRequest(components=[])
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "At least one opex component" in exc.value.message
|
||||
assert exc.value.field_errors and "components" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_extends_evaluation_horizon():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(period_start=1, period_end=4),
|
||||
],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
discount_rate_pct=0,
|
||||
escalation_pct=0,
|
||||
evaluation_horizon_years=2,
|
||||
apply_escalation=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = calculate_opex(request)
|
||||
|
||||
assert len(result.timeline) == 4
|
||||
assert result.timeline[-1].period == 4
|
||||
assert all(entry.escalated_cost is None for entry in result.timeline)
|
||||
assert result.timeline[-1].base_cost == pytest.approx(12_000.0)
|
||||
assert result.metrics.annual_average == pytest.approx(
|
||||
12_000.0, rel=1e-4)
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models import MiningOperationType
|
||||
|
||||
|
||||
class TestDashboardRoute:
|
||||
def test_renders_empty_state(self, client: TestClient) -> None:
|
||||
@@ -17,9 +19,18 @@ class TestDashboardRoute:
|
||||
|
||||
class TestProjectUIRoutes:
|
||||
def test_projects_ui_page_resolves(self, client: TestClient) -> None:
|
||||
create_payload = {
|
||||
"name": "UI Project",
|
||||
"location": "Peru",
|
||||
"operation_type": MiningOperationType.OPEN_PIT.value,
|
||||
"description": "Project for UI validation",
|
||||
}
|
||||
client.post("/projects", json=create_payload)
|
||||
|
||||
response = client.get("/projects/ui")
|
||||
assert response.status_code == 200
|
||||
assert "Projects" in response.text
|
||||
assert "project-card" in response.text
|
||||
|
||||
def test_projects_create_form_resolves(self, client: TestClient) -> None:
|
||||
response = client.get("/projects/create")
|
||||
|
||||
142
tests/test_project_scenario_models.py
Normal file
142
tests/test_project_scenario_models.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from config.database import Base
|
||||
from models import (
|
||||
MiningOperationType,
|
||||
Project,
|
||||
ProjectProfitability,
|
||||
Scenario,
|
||||
ScenarioProfitability,
|
||||
ScenarioStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine() -> Iterator:
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(engine) -> Iterator[Session]:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
session = TestingSession()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_project_scenario_cascade_deletes(session: Session) -> None:
|
||||
project = Project(name="Cascade Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario_a = Scenario(
|
||||
name="Base Case", status=ScenarioStatus.DRAFT, project=project)
|
||||
scenario_b = Scenario(
|
||||
name="Expansion", status=ScenarioStatus.DRAFT, project=project)
|
||||
|
||||
session.add(project)
|
||||
session.commit()
|
||||
|
||||
assert session.scalar(select(Project).where(
|
||||
Project.id == project.id)) is not None
|
||||
assert len(session.scalars(select(Scenario).where(
|
||||
Scenario.project_id == project.id)).all()) == 2
|
||||
|
||||
session.delete(project)
|
||||
session.commit()
|
||||
|
||||
assert session.scalar(select(Project).where(
|
||||
Project.id == project.id)) is None
|
||||
assert session.scalars(select(Scenario)).first() is None
|
||||
|
||||
|
||||
def test_scenario_unique_name_per_project(session: Session) -> None:
|
||||
project = Project(name="Uniqueness Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario = Scenario(name="Duplicated",
|
||||
status=ScenarioStatus.DRAFT, project=project)
|
||||
session.add_all([project, scenario])
|
||||
session.commit()
|
||||
|
||||
duplicate = Scenario(
|
||||
name="Duplicated", status=ScenarioStatus.DRAFT, project=project)
|
||||
session.add(duplicate)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
session.rollback()
|
||||
|
||||
|
||||
def test_latest_profitability_helpers(session: Session) -> None:
|
||||
project = Project(name="Hierarchy Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario = Scenario(name="Economic Model",
|
||||
status=ScenarioStatus.DRAFT, project=project)
|
||||
session.add_all([project, scenario])
|
||||
session.commit()
|
||||
|
||||
base_time = datetime.now(timezone.utc)
|
||||
scenario_snapshot_old = ScenarioProfitability(
|
||||
scenario=scenario,
|
||||
npv=1_000_000,
|
||||
calculated_at=base_time,
|
||||
)
|
||||
scenario_snapshot_new = ScenarioProfitability(
|
||||
scenario=scenario,
|
||||
npv=2_500_000,
|
||||
calculated_at=base_time + timedelta(hours=6),
|
||||
)
|
||||
project_snapshot_old = ProjectProfitability(
|
||||
project=project,
|
||||
npv=5_000_000,
|
||||
calculated_at=base_time,
|
||||
)
|
||||
project_snapshot_new = ProjectProfitability(
|
||||
project=project,
|
||||
npv=7_500_000,
|
||||
calculated_at=base_time + timedelta(hours=12),
|
||||
)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
scenario_snapshot_old,
|
||||
scenario_snapshot_new,
|
||||
project_snapshot_old,
|
||||
project_snapshot_new,
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.refresh(scenario)
|
||||
session.refresh(project)
|
||||
|
||||
assert scenario.latest_profitability is scenario_snapshot_new
|
||||
assert project.latest_profitability is project_snapshot_new
|
||||
|
||||
|
||||
def test_currency_normalisation_on_model(session: Session) -> None:
|
||||
project = Project(name="Forex Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario = Scenario(
|
||||
name="Currency Case", status=ScenarioStatus.DRAFT, project=project, currency="usd")
|
||||
|
||||
session.add_all([project, scenario])
|
||||
session.commit()
|
||||
|
||||
session.refresh(scenario)
|
||||
|
||||
assert scenario.currency == "USD"
|
||||
337
tests/test_project_scenario_routes.py
Normal file
337
tests/test_project_scenario_routes.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str = "Alpha Project") -> dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"location": "Chile",
|
||||
"operation_type": MiningOperationType.OPEN_PIT.value,
|
||||
"description": "Initial feasibility study",
|
||||
}
|
||||
response = client.post("/projects", json=payload)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def _create_scenario(
|
||||
client: TestClient,
|
||||
project_id: int,
|
||||
*,
|
||||
name: str = "Scenario A",
|
||||
status: ScenarioStatus = ScenarioStatus.DRAFT,
|
||||
currency: str | None = "USD",
|
||||
primary_resource: ResourceType | None = ResourceType.DIESEL,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"status": status.value,
|
||||
}
|
||||
if currency:
|
||||
payload["currency"] = currency
|
||||
if primary_resource:
|
||||
payload["primary_resource"] = primary_resource.value
|
||||
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_project_crud_cycle(client: TestClient) -> None:
|
||||
project = _create_project(client)
|
||||
project_id = project["id"]
|
||||
|
||||
fetch_response = client.get(f"/projects/{project_id}")
|
||||
assert fetch_response.status_code == 200
|
||||
assert fetch_response.json()["name"] == "Alpha Project"
|
||||
|
||||
list_response = client.get("/projects")
|
||||
project_ids = {item["id"] for item in list_response.json()}
|
||||
assert project_id in project_ids
|
||||
|
||||
update_payload = {
|
||||
"description": "Updated project description", "location": "Peru"}
|
||||
update_response = client.put(
|
||||
f"/projects/{project_id}", json=update_payload)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json(
|
||||
)["description"] == "Updated project description"
|
||||
assert update_response.json()["location"] == "Peru"
|
||||
|
||||
delete_response = client.delete(f"/projects/{project_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
missing_response = client.get(f"/projects/{project_id}")
|
||||
assert missing_response.status_code == 404
|
||||
|
||||
|
||||
def test_project_creation_conflict_returns_409(client: TestClient) -> None:
|
||||
_create_project(client, name="Conflict Project")
|
||||
conflict_payload = {
|
||||
"name": "Conflict Project",
|
||||
"location": "Canada",
|
||||
"operation_type": MiningOperationType.OTHER.value,
|
||||
"description": "Duplicate entry",
|
||||
}
|
||||
response = client.post("/projects", json=conflict_payload)
|
||||
assert response.status_code == 409
|
||||
assert "violates" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_project_requires_valid_operation_type(client: TestClient) -> None:
|
||||
invalid_payload = {
|
||||
"name": "Invalid Operation",
|
||||
"location": "Australia",
|
||||
"operation_type": "INVALID",
|
||||
"description": "Bad op type",
|
||||
}
|
||||
response = client.post("/projects", json=invalid_payload)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["detail"][0]["loc"][-1] == "operation_type"
|
||||
|
||||
|
||||
def test_scenario_crud_cycle(client: TestClient) -> None:
|
||||
project = _create_project(client)
|
||||
project_id = project["id"]
|
||||
|
||||
creation_payload = {
|
||||
"name": "Scenario A",
|
||||
"description": "Base case assumptions",
|
||||
"status": ScenarioStatus.DRAFT.value,
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2026-01-01",
|
||||
"discount_rate": 8.5,
|
||||
"currency": "usd",
|
||||
"primary_resource": ResourceType.DIESEL.value,
|
||||
}
|
||||
create_response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json=creation_payload,
|
||||
)
|
||||
assert create_response.status_code == 201, create_response.text
|
||||
scenario = create_response.json()
|
||||
scenario_id = scenario["id"]
|
||||
assert scenario["currency"] == "USD"
|
||||
assert scenario["project_id"] == project_id
|
||||
|
||||
list_response = client.get(f"/projects/{project_id}/scenarios")
|
||||
assert list_response.status_code == 200
|
||||
listed_ids = {item["id"] for item in list_response.json()}
|
||||
assert scenario_id in listed_ids
|
||||
|
||||
update_payload = {"description": "Revised assumptions",
|
||||
"status": ScenarioStatus.ACTIVE.value}
|
||||
update_response = client.put(
|
||||
f"/scenarios/{scenario_id}", json=update_payload)
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["description"] == "Revised assumptions"
|
||||
assert updated["status"] == ScenarioStatus.ACTIVE.value
|
||||
|
||||
delete_response = client.delete(f"/scenarios/{scenario_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
missing_response = client.get(f"/scenarios/{scenario_id}")
|
||||
assert missing_response.status_code == 404
|
||||
|
||||
|
||||
def test_create_scenario_requires_existing_project(client: TestClient) -> None:
|
||||
payload = {
|
||||
"name": "Orphan Scenario",
|
||||
"status": ScenarioStatus.DRAFT.value,
|
||||
}
|
||||
response = client.post("/projects/999/scenarios", json=payload)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_scenario_conflict_returns_409(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Scenario Container")
|
||||
project_id = project["id"]
|
||||
|
||||
payload = {"name": "Duplicate Scenario"}
|
||||
first_response = client.post(
|
||||
f"/projects/{project_id}/scenarios", json=payload)
|
||||
assert first_response.status_code == 201
|
||||
|
||||
conflict_response = client.post(
|
||||
f"/projects/{project_id}/scenarios", json=payload)
|
||||
assert conflict_response.status_code == 409
|
||||
assert "constraints" in conflict_response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_scenario_invalid_currency_returns_422(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Currency Project")
|
||||
project_id = project["id"]
|
||||
payload = {
|
||||
"name": "Bad Currency",
|
||||
"currency": "zz",
|
||||
}
|
||||
response = client.post(f"/projects/{project_id}/scenarios", json=payload)
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"][0]
|
||||
assert detail["loc"][-1] == "currency"
|
||||
assert "invalid currency code" in detail["msg"].lower()
|
||||
|
||||
|
||||
def test_list_scenarios_missing_project_returns_404(client: TestClient) -> None:
|
||||
response = client.get("/projects/424242/scenarios")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_project_detail_page_renders_scenario_list(client: TestClient) -> None:
|
||||
project = _create_project(client, name="UI Detail Project")
|
||||
project_id = project["id"]
|
||||
|
||||
_create_scenario(
|
||||
client,
|
||||
project_id,
|
||||
name="Scenario UI",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(f"/projects/{project_id}/view")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "scenario-list" in body
|
||||
assert "status-pill--active" in body
|
||||
|
||||
|
||||
def test_scenario_list_page_shows_calculator_shortcuts(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Portfolio Project")
|
||||
project_id = project["id"]
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project_id,
|
||||
name="Portfolio Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(f"/projects/{project_id}/scenarios/ui")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
unescaped = html.unescape(body)
|
||||
|
||||
assert "Scenario Portfolio" in body
|
||||
assert project["name"] in body
|
||||
assert scenario["name"] in body
|
||||
assert f"projects/{project_id}/view" in unescaped
|
||||
assert f"scenarios/{scenario['id']}/view" in unescaped
|
||||
expected_calc_fragment = (
|
||||
f"calculations/projects/{project_id}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert expected_calc_fragment in unescaped
|
||||
|
||||
|
||||
def test_scenario_detail_page_links_back_to_portfolio(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Detail Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Detail Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
primary_resource=ResourceType.ELECTRICITY,
|
||||
)
|
||||
|
||||
response = client.get(f"/scenarios/{scenario['id']}/view")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
unescaped = html.unescape(body)
|
||||
|
||||
assert project["name"] in body
|
||||
assert scenario["name"] in body
|
||||
assert "Scenario Overview" in body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in unescaped
|
||||
assert (
|
||||
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
in unescaped
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_form_includes_project_context_guidance(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Form Project")
|
||||
|
||||
response = client.get(f"/projects/{project['id']}/scenarios/new")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert "Project Context" in body
|
||||
assert project["name"] in body
|
||||
assert "Status Guidance" in body
|
||||
assert "Baseline Reminder" in body
|
||||
assert "Defaults to" in body
|
||||
|
||||
|
||||
def test_calculator_headers_surface_scenario_navigation(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Calc Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Calc Scenario",
|
||||
status=ScenarioStatus.DRAFT,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
profitability = client.get(
|
||||
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert profitability.status_code == 200
|
||||
profitability_body = html.unescape(profitability.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in profitability_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in profitability_body
|
||||
assert (
|
||||
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
in profitability_body
|
||||
)
|
||||
|
||||
capex = client.get(
|
||||
f"/calculations/capex?project_id={project['id']}&scenario_id={scenario['id']}"
|
||||
)
|
||||
assert capex.status_code == 200
|
||||
capex_body = html.unescape(capex.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in capex_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in capex_body
|
||||
|
||||
opex = client.get(
|
||||
f"/calculations/opex?project_id={project['id']}&scenario_id={scenario['id']}"
|
||||
)
|
||||
assert opex.status_code == 200
|
||||
opex_body = html.unescape(opex.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in opex_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in opex_body
|
||||
|
||||
|
||||
def test_profitability_legacy_endpoint_redirects_to_scenario_path(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Redirect Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Redirect Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/profitability?project_id={project['id']}&scenario_id={scenario['id']}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
|
||||
location = response.headers.get("location")
|
||||
assert location is not None
|
||||
expected_path = (
|
||||
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert expected_path in location
|
||||
Reference in New Issue
Block a user