feat: add scenarios list page with metrics and quick actions
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped

- Introduced a new template for listing scenarios associated with a project.
- Added metrics for total, active, draft, and archived scenarios.
- Implemented quick actions for creating new scenarios and reviewing project overview.
- Enhanced navigation with breadcrumbs for better user experience.

refactor: update Opex and Profitability templates for consistency

- Changed titles and button labels for clarity in Opex and Profitability templates.
- Updated form IDs and action URLs for better alignment with new naming conventions.
- Improved navigation links to include scenario and project overviews.

test: add integration tests for Opex calculations

- Created new tests for Opex calculation HTML and JSON flows.
- Validated successful calculations and ensured correct data persistence.
- Implemented tests for currency mismatch and unsupported frequency scenarios.

test: enhance project and scenario route tests

- Added tests to verify scenario list rendering and calculator shortcuts.
- Ensured scenario detail pages link back to the portfolio correctly.
- Validated project detail pages show associated scenarios accurately.
This commit is contained in:
2025-11-13 16:21:36 +01:00
parent 4f00bf0d3c
commit 522b1e4105
54 changed files with 3419 additions and 700 deletions

View File

@@ -8,6 +8,6 @@ The system is designed to help mining companies make informed decisions by simul
## Documentation & quickstart ## 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. - 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. - To wipe and recreate the schema in development, run `CALMINER_ENV=development python -m scripts.reset_db` before invoking the initializer again.

Binary file not shown.

View File

@@ -2,14 +2,18 @@
## 2025-11-13 ## 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. - 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 initial 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`. - 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 processing opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`. - 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 Initial Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/initial_capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up. - 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 processing opex calculation unit tests in `tests/services/test_calculations_processing_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension. - 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 Processing Opex Planner workflow in `calminer-docs/userguide/processing_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`. - 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 processing opex integration coverage in `tests/integration/test_processing_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions. - 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 processing opex documentation updates. - 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 ## 2025-11-12

View File

@@ -23,6 +23,7 @@ from services.session import (
from services.unit_of_work import UnitOfWork from services.unit_of_work import UnitOfWork
from services.importers import ImportIngestionService from services.importers import ImportIngestionService
from services.pricing import PricingMetadata from services.pricing import PricingMetadata
from services.navigation import NavigationService
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
from services.repositories import pricing_settings_to_metadata from services.repositories import pricing_settings_to_metadata
@@ -64,6 +65,14 @@ def get_pricing_metadata(
return pricing_settings_to_metadata(seed_result.settings) 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( def get_pricing_evaluator(
metadata: PricingMetadata = Depends(get_pricing_metadata), metadata: PricingMetadata = Depends(get_pricing_metadata),
) -> ScenarioPricingEvaluator: ) -> ScenarioPricingEvaluator:
@@ -153,6 +162,28 @@ def require_authenticated_user(
return 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]: def _user_role_names(user: User) -> set[str]:
roles: Iterable[Role] = getattr(user, "roles", []) or [] roles: Iterable[Role] = getattr(user, "roles", []) or []
return {role.name for role in roles} return {role.name for role in roles}
@@ -186,12 +217,55 @@ def require_any_role(*roles: str) -> Callable[[User], User]:
return require_roles(*roles) 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.""" """Dependency factory that resolves a project with authorization checks."""
def _dependency( def _dependency(
project_id: int, project_id: int,
user: User = Depends(require_authenticated_user), user: User = Depends(user_dependency),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
) -> Project: ) -> Project:
try: try:
@@ -216,13 +290,16 @@ def require_project_resource(*, require_manage: bool = False) -> Callable[[int],
def require_scenario_resource( 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]: ) -> Callable[[int], Scenario]:
"""Dependency factory that resolves a scenario with authorization checks.""" """Dependency factory that resolves a scenario with authorization checks."""
def _dependency( def _dependency(
scenario_id: int, scenario_id: int,
user: User = Depends(require_authenticated_user), user: User = Depends(user_dependency),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
) -> Scenario: ) -> Scenario:
try: try:
@@ -248,14 +325,17 @@ def require_scenario_resource(
def require_project_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]: ) -> Callable[[int, int], Scenario]:
"""Dependency factory ensuring a scenario belongs to the given project and is accessible.""" """Dependency factory ensuring a scenario belongs to the given project and is accessible."""
def _dependency( def _dependency(
project_id: int, project_id: int,
scenario_id: int, scenario_id: int,
user: User = Depends(require_authenticated_user), user: User = Depends(user_dependency),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
) -> Scenario: ) -> Scenario:
try: try:
@@ -279,3 +359,42 @@ def require_project_scenario_resource(
) from exc ) from exc
return _dependency 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,
)

View File

@@ -19,6 +19,7 @@ from routes.projects import router as projects_router
from routes.reports import router as reports_router from routes.reports import router as reports_router
from routes.scenarios import router as scenarios_router from routes.scenarios import router as scenarios_router
from routes.ui import router as ui_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 monitoring import router as monitoring_router
from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
from scripts.init_db import init_db as init_db_script from scripts.init_db import init_db as init_db_script
@@ -113,5 +114,6 @@ app.include_router(scenarios_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(ui_router) app.include_router(ui_router)
app.include_router(monitoring_router) app.include_router(monitoring_router)
app.include_router(navigation_router)
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -145,6 +145,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
session.user = user session.user = user
session.scopes = tuple(payload.scopes) session.scopes = tuple(payload.scopes)
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
return True return True
def _try_refresh_token( def _try_refresh_token(
@@ -166,6 +167,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
session.user = user session.user = user
session.scopes = tuple(payload.scopes) session.scopes = tuple(payload.scopes)
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
access_token = create_access_token( access_token = create_access_token(
str(user.id), str(user.id),

View File

@@ -27,11 +27,13 @@ from .project import Project
from .scenario import Scenario from .scenario import Scenario
from .simulation_parameter import SimulationParameter from .simulation_parameter import SimulationParameter
from .user import Role, User, UserRole, password_context from .user import Role, User, UserRole, password_context
from .navigation import NavigationGroup, NavigationLink
from .profitability_snapshot import ProjectProfitability, ScenarioProfitability from .profitability_snapshot import ProjectProfitability, ScenarioProfitability
from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot
from .processing_opex_snapshot import ( from .opex_snapshot import (
ProjectProcessingOpexSnapshot, ProjectOpexSnapshot,
ScenarioProcessingOpexSnapshot, ScenarioOpexSnapshot,
) )
__all__ = [ __all__ = [
@@ -41,14 +43,14 @@ __all__ = [
"Project", "Project",
"ProjectProfitability", "ProjectProfitability",
"ProjectCapexSnapshot", "ProjectCapexSnapshot",
"ProjectProcessingOpexSnapshot", "ProjectOpexSnapshot",
"PricingSettings", "PricingSettings",
"PricingMetalSettings", "PricingMetalSettings",
"PricingImpuritySettings", "PricingImpuritySettings",
"Scenario", "Scenario",
"ScenarioProfitability", "ScenarioProfitability",
"ScenarioCapexSnapshot", "ScenarioCapexSnapshot",
"ScenarioProcessingOpexSnapshot", "ScenarioOpexSnapshot",
"ScenarioStatus", "ScenarioStatus",
"DistributionType", "DistributionType",
"SimulationParameter", "SimulationParameter",
@@ -65,4 +67,6 @@ __all__ = [
"UserRole", "UserRole",
"password_context", "password_context",
"PerformanceMetric", "PerformanceMetric",
"NavigationGroup",
"NavigationLink",
] ]

View File

@@ -16,7 +16,7 @@ if TYPE_CHECKING: # pragma: no cover
class ProjectCapexSnapshot(Base): class ProjectCapexSnapshot(Base):
"""Snapshot of aggregated initial capex metrics at the project level.""" """Snapshot of aggregated capex metrics at the project level."""
__tablename__ = "project_capex_snapshots" __tablename__ = "project_capex_snapshots"
@@ -64,7 +64,7 @@ class ProjectCapexSnapshot(Base):
class ScenarioCapexSnapshot(Base): class ScenarioCapexSnapshot(Base):
"""Snapshot of initial capex metrics for an individual scenario.""" """Snapshot of capex metrics for an individual scenario."""
__tablename__ = "scenario_capex_snapshots" __tablename__ = "scenario_capex_snapshots"

125
models/navigation.py Normal file
View 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})"

View File

@@ -15,10 +15,10 @@ if TYPE_CHECKING: # pragma: no cover
from .user import User from .user import User
class ProjectProcessingOpexSnapshot(Base): class ProjectOpexSnapshot(Base):
"""Snapshot of recurring processing opex metrics at the project level.""" """Snapshot of recurring opex metrics at the project level."""
__tablename__ = "project_processing_opex_snapshots" __tablename__ = "project_opex_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column( project_id: Mapped[int] = mapped_column(
@@ -27,17 +27,24 @@ class ProjectProcessingOpexSnapshot(Base):
created_by_id: Mapped[int | None] = mapped_column( created_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
) )
calculation_source: Mapped[str | None] = mapped_column(String(64), nullable=True) calculation_source: Mapped[str | None] = mapped_column(
String(64), nullable=True)
calculated_at: Mapped[datetime] = mapped_column( calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True) currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
overall_annual: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) overall_annual: Mapped[float | None] = mapped_column(
escalated_total: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) Numeric(18, 2), nullable=True)
annual_average: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) escalated_total: Mapped[float | None] = mapped_column(
evaluation_horizon_years: Mapped[int | None] = mapped_column(Integer, nullable=True) Numeric(18, 2), nullable=True)
escalation_pct: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True) annual_average: Mapped[float | None] = mapped_column(
apply_escalation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) 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) component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
@@ -48,13 +55,13 @@ class ProjectProcessingOpexSnapshot(Base):
) )
project: Mapped[Project] = relationship( project: Mapped[Project] = relationship(
"Project", back_populates="processing_opex_snapshots" "Project", back_populates="opex_snapshots"
) )
created_by: Mapped[User | None] = relationship("User") created_by: Mapped[User | None] = relationship("User")
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return ( return (
"ProjectProcessingOpexSnapshot(id={id!r}, project_id={project_id!r}, overall_annual={overall_annual!r})".format( "ProjectOpexSnapshot(id={id!r}, project_id={project_id!r}, overall_annual={overall_annual!r})".format(
id=self.id, id=self.id,
project_id=self.project_id, project_id=self.project_id,
overall_annual=self.overall_annual, overall_annual=self.overall_annual,
@@ -62,10 +69,10 @@ class ProjectProcessingOpexSnapshot(Base):
) )
class ScenarioProcessingOpexSnapshot(Base): class ScenarioOpexSnapshot(Base):
"""Snapshot of processing opex metrics for an individual scenario.""" """Snapshot of opex metrics for an individual scenario."""
__tablename__ = "scenario_processing_opex_snapshots" __tablename__ = "scenario_opex_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
scenario_id: Mapped[int] = mapped_column( scenario_id: Mapped[int] = mapped_column(
@@ -74,17 +81,24 @@ class ScenarioProcessingOpexSnapshot(Base):
created_by_id: Mapped[int | None] = mapped_column( created_by_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
) )
calculation_source: Mapped[str | None] = mapped_column(String(64), nullable=True) calculation_source: Mapped[str | None] = mapped_column(
String(64), nullable=True)
calculated_at: Mapped[datetime] = mapped_column( calculated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True) currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
overall_annual: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) overall_annual: Mapped[float | None] = mapped_column(
escalated_total: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) Numeric(18, 2), nullable=True)
annual_average: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True) escalated_total: Mapped[float | None] = mapped_column(
evaluation_horizon_years: Mapped[int | None] = mapped_column(Integer, nullable=True) Numeric(18, 2), nullable=True)
escalation_pct: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True) annual_average: Mapped[float | None] = mapped_column(
apply_escalation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) 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) component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
@@ -95,13 +109,13 @@ class ScenarioProcessingOpexSnapshot(Base):
) )
scenario: Mapped[Scenario] = relationship( scenario: Mapped[Scenario] = relationship(
"Scenario", back_populates="processing_opex_snapshots" "Scenario", back_populates="opex_snapshots"
) )
created_by: Mapped[User | None] = relationship("User") created_by: Mapped[User | None] = relationship("User")
def __repr__(self) -> str: # pragma: no cover def __repr__(self) -> str: # pragma: no cover
return ( return (
"ScenarioProcessingOpexSnapshot(id={id!r}, scenario_id={scenario_id!r}, overall_annual={overall_annual!r})".format( "ScenarioOpexSnapshot(id={id!r}, scenario_id={scenario_id!r}, overall_annual={overall_annual!r})".format(
id=self.id, id=self.id,
scenario_id=self.scenario_id, scenario_id=self.scenario_id,
overall_annual=self.overall_annual, overall_annual=self.overall_annual,

View File

@@ -43,13 +43,13 @@ class ProjectProfitability(Base):
Numeric(12, 6), nullable=True) Numeric(12, 6), nullable=True)
revenue_total: Mapped[float | None] = mapped_column( revenue_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True) Numeric(18, 2), nullable=True)
processing_opex_total: Mapped[float | None] = mapped_column( opex_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True Numeric(18, 2), nullable=True
) )
sustaining_capex_total: Mapped[float | None] = mapped_column( sustaining_capex_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True Numeric(18, 2), nullable=True
) )
initial_capex: Mapped[float | None] = mapped_column( capex: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True) Numeric(18, 2), nullable=True)
net_cash_flow_total: Mapped[float | None] = mapped_column( net_cash_flow_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True Numeric(18, 2), nullable=True
@@ -102,13 +102,13 @@ class ScenarioProfitability(Base):
Numeric(12, 6), nullable=True) Numeric(12, 6), nullable=True)
revenue_total: Mapped[float | None] = mapped_column( revenue_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True) Numeric(18, 2), nullable=True)
processing_opex_total: Mapped[float | None] = mapped_column( opex_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True Numeric(18, 2), nullable=True
) )
sustaining_capex_total: Mapped[float | None] = mapped_column( sustaining_capex_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True Numeric(18, 2), nullable=True
) )
initial_capex: Mapped[float | None] = mapped_column( capex: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True) Numeric(18, 2), nullable=True)
net_cash_flow_total: Mapped[float | None] = mapped_column( net_cash_flow_total: Mapped[float | None] = mapped_column(
Numeric(18, 2), nullable=True Numeric(18, 2), nullable=True

View File

@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, List
from .enums import MiningOperationType, sql_enum from .enums import MiningOperationType, sql_enum
from .profitability_snapshot import ProjectProfitability from .profitability_snapshot import ProjectProfitability
from .capex_snapshot import ProjectCapexSnapshot from .capex_snapshot import ProjectCapexSnapshot
from .processing_opex_snapshot import ProjectProcessingOpexSnapshot from .opex_snapshot import ProjectOpexSnapshot
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -68,11 +68,11 @@ class Project(Base):
order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(), order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(),
passive_deletes=True, passive_deletes=True,
) )
processing_opex_snapshots: Mapped[List["ProjectProcessingOpexSnapshot"]] = relationship( opex_snapshots: Mapped[List["ProjectOpexSnapshot"]] = relationship(
"ProjectProcessingOpexSnapshot", "ProjectOpexSnapshot",
back_populates="project", back_populates="project",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by=lambda: ProjectProcessingOpexSnapshot.calculated_at.desc(), order_by=lambda: ProjectOpexSnapshot.calculated_at.desc(),
passive_deletes=True, passive_deletes=True,
) )
@@ -93,12 +93,12 @@ class Project(Base):
return self.capex_snapshots[0] return self.capex_snapshots[0]
@property @property
def latest_processing_opex(self) -> "ProjectProcessingOpexSnapshot | None": def latest_opex(self) -> "ProjectOpexSnapshot | None":
"""Return the most recent processing opex snapshot, if any.""" """Return the most recent opex snapshot, if any."""
if not self.processing_opex_snapshots: if not self.opex_snapshots:
return None return None
return self.processing_opex_snapshots[0] return self.opex_snapshots[0]
def __repr__(self) -> str: # pragma: no cover - helpful for debugging def __repr__(self) -> str: # pragma: no cover - helpful for debugging
return f"Project(id={self.id!r}, name={self.name!r})" return f"Project(id={self.id!r}, name={self.name!r})"

View File

@@ -21,7 +21,7 @@ from services.currency import normalise_currency
from .enums import ResourceType, ScenarioStatus, sql_enum from .enums import ResourceType, ScenarioStatus, sql_enum
from .profitability_snapshot import ScenarioProfitability from .profitability_snapshot import ScenarioProfitability
from .capex_snapshot import ScenarioCapexSnapshot from .capex_snapshot import ScenarioCapexSnapshot
from .processing_opex_snapshot import ScenarioProcessingOpexSnapshot from .opex_snapshot import ScenarioOpexSnapshot
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from .financial_input import FinancialInput from .financial_input import FinancialInput
@@ -92,11 +92,11 @@ class Scenario(Base):
order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(), order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(),
passive_deletes=True, passive_deletes=True,
) )
processing_opex_snapshots: Mapped[List["ScenarioProcessingOpexSnapshot"]] = relationship( opex_snapshots: Mapped[List["ScenarioOpexSnapshot"]] = relationship(
"ScenarioProcessingOpexSnapshot", "ScenarioOpexSnapshot",
back_populates="scenario", back_populates="scenario",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by=lambda: ScenarioProcessingOpexSnapshot.calculated_at.desc(), order_by=lambda: ScenarioOpexSnapshot.calculated_at.desc(),
passive_deletes=True, passive_deletes=True,
) )
@@ -125,9 +125,9 @@ class Scenario(Base):
return self.capex_snapshots[0] return self.capex_snapshots[0]
@property @property
def latest_processing_opex(self) -> "ScenarioProcessingOpexSnapshot | None": def latest_opex(self) -> "ScenarioOpexSnapshot | None":
"""Return the most recent processing opex snapshot for this scenario.""" """Return the most recent opex snapshot for this scenario."""
if not self.processing_opex_snapshots: if not self.opex_snapshots:
return None return None
return self.processing_opex_snapshots[0] return self.opex_snapshots[0]

View File

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

View File

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

View File

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

View File

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

View File

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

63
routes/navigation.py Normal file
View File

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

View File

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

View File

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

View File

@@ -6,14 +6,16 @@ from typing import List
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from dependencies import ( from dependencies import (
get_pricing_metadata, get_pricing_metadata,
get_unit_of_work, get_unit_of_work,
require_any_role, require_any_role,
require_any_role_html,
require_roles, require_roles,
require_roles_html,
require_scenario_resource, require_scenario_resource,
require_scenario_resource_html,
) )
from models import ResourceType, Scenario, ScenarioStatus, User from models import ResourceType, Scenario, ScenarioStatus, User
from schemas.scenario import ( from schemas.scenario import (
@@ -31,9 +33,10 @@ from services.exceptions import (
) )
from services.pricing import PricingMetadata from services.pricing import PricingMetadata
from services.unit_of_work import UnitOfWork from services.unit_of_work import UnitOfWork
from routes.template_filters import create_templates
router = APIRouter(tags=["Scenarios"]) router = APIRouter(tags=["Scenarios"])
templates = Jinja2Templates(directory="templates") templates = create_templates()
READ_ROLES = ("viewer", "analyst", "project_manager", "admin") READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
MANAGE_ROLES = ("project_manager", "admin") MANAGE_ROLES = ("project_manager", "admin")
@@ -170,6 +173,63 @@ def create_scenario_for_project(
return _to_read_model(created) 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) @router.get("/scenarios/{scenario_id}", response_model=ScenarioRead)
def get_scenario( def get_scenario(
scenario: Scenario = Depends(require_scenario_resource()), scenario: Scenario = Depends(require_scenario_resource()),
@@ -263,7 +323,7 @@ def _scenario_form_state(
def create_scenario_form( def create_scenario_form(
project_id: int, project_id: int,
request: Request, request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)), _: User = Depends(require_roles_html(*MANAGE_ROLES)),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
metadata: PricingMetadata = Depends(get_pricing_metadata), metadata: PricingMetadata = Depends(get_pricing_metadata),
) -> HTMLResponse: ) -> HTMLResponse:
@@ -301,7 +361,7 @@ def create_scenario_form(
def create_scenario_submit( def create_scenario_submit(
project_id: int, project_id: int,
request: Request, request: Request,
_: User = Depends(require_roles(*MANAGE_ROLES)), _: User = Depends(require_roles_html(*MANAGE_ROLES)),
name: str = Form(...), name: str = Form(...),
description: str | None = Form(None), description: str | None = Form(None),
status_value: str = Form(ScenarioStatus.DRAFT.value), status_value: str = Form(ScenarioStatus.DRAFT.value),
@@ -374,6 +434,7 @@ def create_scenario_submit(
"projects.view_project", project_id=project_id "projects.view_project", project_id=project_id
), ),
"error": str(exc), "error": str(exc),
"error_field": "currency",
"default_currency": metadata.default_currency, "default_currency": metadata.default_currency,
}, },
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@@ -408,7 +469,8 @@ def create_scenario_submit(
"cancel_url": request.url_for( "cancel_url": request.url_for(
"projects.view_project", project_id=project_id "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, "default_currency": metadata.default_currency,
}, },
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
@@ -428,8 +490,9 @@ def create_scenario_submit(
) )
def view_scenario( def view_scenario(
request: Request, request: Request,
_: User = Depends(require_any_role_html(*READ_ROLES)),
scenario: Scenario = Depends( scenario: Scenario = Depends(
require_scenario_resource(with_children=True) require_scenario_resource_html(with_children=True)
), ),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
) -> HTMLResponse: ) -> HTMLResponse:
@@ -469,8 +532,9 @@ def view_scenario(
) )
def edit_scenario_form( def edit_scenario_form(
request: Request, request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
scenario: Scenario = Depends( scenario: Scenario = Depends(
require_scenario_resource(require_manage=True) require_scenario_resource_html(require_manage=True)
), ),
uow: UnitOfWork = Depends(get_unit_of_work), uow: UnitOfWork = Depends(get_unit_of_work),
metadata: PricingMetadata = Depends(get_pricing_metadata), metadata: PricingMetadata = Depends(get_pricing_metadata),
@@ -503,8 +567,9 @@ def edit_scenario_form(
) )
def edit_scenario_submit( def edit_scenario_submit(
request: Request, request: Request,
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
scenario: Scenario = Depends( scenario: Scenario = Depends(
require_scenario_resource(require_manage=True) require_scenario_resource_html(require_manage=True)
), ),
name: str = Form(...), name: str = Form(...),
description: str | None = Form(None), description: str | None = Form(None),
@@ -569,6 +634,7 @@ def edit_scenario_submit(
"scenarios.view_scenario", scenario_id=scenario.id "scenarios.view_scenario", scenario_id=scenario.id
), ),
"error": str(exc), "error": str(exc),
"error_field": "currency",
"default_currency": metadata.default_currency, "default_currency": metadata.default_currency,
}, },
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -1,10 +1,19 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import Request
from fastapi.templating import Jinja2Templates 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: def format_datetime(value: Any) -> str:
"""Render datetime values consistently for templates.""" """Render datetime values consistently for templates."""
@@ -85,6 +94,47 @@ def register_common_filters(templates: Jinja2Templates) -> None:
templates.env.filters["period_display"] = period_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__ = [ __all__ = [
"format_datetime", "format_datetime",
"currency_display", "currency_display",
@@ -92,4 +142,6 @@ __all__ = [
"percentage_display", "percentage_display",
"period_display", "period_display",
"register_common_filters", "register_common_filters",
"register_navigation_globals",
"create_templates",
] ]

View File

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

View File

@@ -40,9 +40,9 @@ class ProfitabilityCalculationRequest(BaseModel):
premiums: float = Field(0) premiums: float = Field(0)
fx_rate: PositiveFloat = Field(1) fx_rate: PositiveFloat = Field(1)
currency_code: str | None = Field(None, min_length=3, max_length=3) currency_code: str | None = Field(None, min_length=3, max_length=3)
processing_opex: float = Field(0, ge=0) opex: float = Field(0, ge=0)
sustaining_capex: float = Field(0, ge=0) sustaining_capex: float = Field(0, ge=0)
initial_capex: float = Field(0, ge=0) capex: float = Field(0, ge=0)
discount_rate: float | None = Field(None, ge=0, le=100) discount_rate: float | None = Field(None, ge=0, le=100)
periods: int = Field(10, ge=1, le=120) periods: int = Field(10, ge=1, le=120)
impurities: List[ImpurityInput] = Field(default_factory=list) impurities: List[ImpurityInput] = Field(default_factory=list)
@@ -63,9 +63,9 @@ class ProfitabilityCalculationRequest(BaseModel):
class ProfitabilityCosts(BaseModel): class ProfitabilityCosts(BaseModel):
"""Aggregated cost components for profitability output.""" """Aggregated cost components for profitability output."""
processing_opex_total: float opex_total: float
sustaining_capex_total: float sustaining_capex_total: float
initial_capex: float capex: float
class ProfitabilityMetrics(BaseModel): class ProfitabilityMetrics(BaseModel):
@@ -82,7 +82,7 @@ class CashFlowEntry(BaseModel):
period: int period: int
revenue: float revenue: float
processing_opex: float opex: float
sustaining_capex: float sustaining_capex: float
net: float net: float
@@ -197,8 +197,8 @@ class CapexCalculationResult(BaseModel):
currency: str | None currency: str | None
class ProcessingOpexComponentInput(BaseModel): class OpexComponentInput(BaseModel):
"""Processing opex component entry supplied by the UI.""" """opex component entry supplied by the UI."""
id: int | None = Field(default=None, ge=1) id: int | None = Field(default=None, ge=1)
name: str = Field(..., min_length=1) name: str = Field(..., min_length=1)
@@ -234,8 +234,8 @@ class ProcessingOpexComponentInput(BaseModel):
return value.strip() return value.strip()
class ProcessingOpexParameters(BaseModel): class OpexParameters(BaseModel):
"""Global parameters applied to processing opex calculations.""" """Global parameters applied to opex calculations."""
currency_code: str | None = Field(None, min_length=3, max_length=3) currency_code: str | None = Field(None, min_length=3, max_length=3)
escalation_pct: float | None = Field(None, ge=0, le=100) escalation_pct: float | None = Field(None, ge=0, le=100)
@@ -251,35 +251,35 @@ class ProcessingOpexParameters(BaseModel):
return value.strip().upper() return value.strip().upper()
class ProcessingOpexOptions(BaseModel): class OpexOptions(BaseModel):
"""Optional behaviour flags for opex calculations.""" """Optional behaviour flags for opex calculations."""
persist: bool = False persist: bool = False
snapshot_notes: str | None = Field(None, max_length=500) snapshot_notes: str | None = Field(None, max_length=500)
class ProcessingOpexCalculationRequest(BaseModel): class OpexCalculationRequest(BaseModel):
"""Request payload for processing opex aggregation.""" """Request payload for opex aggregation."""
components: List[ProcessingOpexComponentInput] = Field( components: List[OpexComponentInput] = Field(
default_factory=list) default_factory=list)
parameters: ProcessingOpexParameters = Field( parameters: OpexParameters = Field(
default_factory=ProcessingOpexParameters, # type: ignore[arg-type] default_factory=OpexParameters, # type: ignore[arg-type]
) )
options: ProcessingOpexOptions = Field( options: OpexOptions = Field(
default_factory=ProcessingOpexOptions, # type: ignore[arg-type] default_factory=OpexOptions, # type: ignore[arg-type]
) )
class ProcessingOpexCategoryBreakdown(BaseModel): class OpexCategoryBreakdown(BaseModel):
"""Category breakdown for processing opex totals.""" """Category breakdown for opex totals."""
category: str category: str
annual_cost: float = Field(..., ge=0) annual_cost: float = Field(..., ge=0)
share: float | None = Field(None, ge=0, le=100) share: float | None = Field(None, ge=0, le=100)
class ProcessingOpexTimelineEntry(BaseModel): class OpexTimelineEntry(BaseModel):
"""Timeline entry representing cost over evaluation periods.""" """Timeline entry representing cost over evaluation periods."""
period: int period: int
@@ -287,34 +287,34 @@ class ProcessingOpexTimelineEntry(BaseModel):
escalated_cost: float | None = Field(None, ge=0) escalated_cost: float | None = Field(None, ge=0)
class ProcessingOpexMetrics(BaseModel): class OpexMetrics(BaseModel):
"""Derived KPIs for processing opex outputs.""" """Derived KPIs for opex outputs."""
annual_average: float | None annual_average: float | None
cost_per_ton: float | None cost_per_ton: float | None
class ProcessingOpexTotals(BaseModel): class OpexTotals(BaseModel):
"""Aggregated totals for processing opex.""" """Aggregated totals for opex."""
overall_annual: float = Field(..., ge=0) overall_annual: float = Field(..., ge=0)
escalated_total: float | None = Field(None, ge=0) escalated_total: float | None = Field(None, ge=0)
escalation_pct: float | None = Field(None, ge=0, le=100) escalation_pct: float | None = Field(None, ge=0, le=100)
by_category: List[ProcessingOpexCategoryBreakdown] = Field( by_category: List[OpexCategoryBreakdown] = Field(
default_factory=list default_factory=list
) )
class ProcessingOpexCalculationResult(BaseModel): class OpexCalculationResult(BaseModel):
"""Response body summarising processing opex calculations.""" """Response body summarising opex calculations."""
totals: ProcessingOpexTotals totals: OpexTotals
timeline: List[ProcessingOpexTimelineEntry] = Field(default_factory=list) timeline: List[OpexTimelineEntry] = Field(default_factory=list)
metrics: ProcessingOpexMetrics metrics: OpexMetrics
components: List[ProcessingOpexComponentInput] = Field( components: List[OpexComponentInput] = Field(
default_factory=list) default_factory=list)
parameters: ProcessingOpexParameters parameters: OpexParameters
options: ProcessingOpexOptions options: OpexOptions
currency: str | None currency: str | None
@@ -333,14 +333,14 @@ __all__ = [
"CapexTotals", "CapexTotals",
"CapexTimelineEntry", "CapexTimelineEntry",
"CapexCalculationResult", "CapexCalculationResult",
"ProcessingOpexComponentInput", "OpexComponentInput",
"ProcessingOpexParameters", "OpexParameters",
"ProcessingOpexOptions", "OpexOptions",
"ProcessingOpexCalculationRequest", "OpexCalculationRequest",
"ProcessingOpexCategoryBreakdown", "OpexCategoryBreakdown",
"ProcessingOpexTimelineEntry", "OpexTimelineEntry",
"ProcessingOpexMetrics", "OpexMetrics",
"ProcessingOpexTotals", "OpexTotals",
"ProcessingOpexCalculationResult", "OpexCalculationResult",
"ValidationError", "ValidationError",
] ]

36
schemas/navigation.py Normal file
View 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()

View File

@@ -23,9 +23,10 @@ import logging
from decimal import Decimal from decimal import Decimal
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import create_engine, text from sqlalchemy import JSON, create_engine, text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.sql import bindparam
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
password_context = CryptContext(schemes=["argon2"], deprecated="auto") 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) 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 # pricing_settings
""" """
CREATE TABLE IF NOT EXISTS 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) 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 # pricing_settings
""" """
CREATE TABLE IF NOT EXISTS pricing_settings ( CREATE TABLE IF NOT EXISTS pricing_settings (
@@ -471,6 +541,230 @@ class PricingSeed(BaseModel):
moisture_penalty_per_pct: float 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] = [ DEFAULT_PROJECTS: list[ProjectSeed] = [
ProjectSeed( ProjectSeed(
name="Helios Copper", name="Helios Copper",
@@ -528,7 +822,7 @@ DEFAULT_FINANCIAL_INPUTS: list[FinancialInputSeed] = [
FinancialInputSeed( FinancialInputSeed(
project_name="Helios Copper", project_name="Helios Copper",
scenario_name="Base Case", scenario_name="Base Case",
name="Processing Opex", name="Opex",
category="opex", category="opex",
cost_bucket="operating_variable", cost_bucket="operating_variable",
amount=Decimal("75000000"), amount=Decimal("75000000"),
@@ -787,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]: def _project_id_by_name(conn, project_name: str) -> Optional[int]:
row = conn.execute( row = conn.execute(
text("SELECT id FROM projects WHERE name = :name"), text("SELECT id FROM projects WHERE name = :name"),
@@ -963,6 +1449,7 @@ def init_db(database_url: Optional[str] = None) -> None:
seed_roles(engine, is_sqlite) seed_roles(engine, is_sqlite)
seed_admin_user(engine, is_sqlite) seed_admin_user(engine, is_sqlite)
ensure_default_pricing(engine, is_sqlite) ensure_default_pricing(engine, is_sqlite)
seed_navigation(engine, is_sqlite)
ensure_default_projects(engine, is_sqlite) ensure_default_projects(engine, is_sqlite)
ensure_default_scenarios(engine, is_sqlite) ensure_default_scenarios(engine, is_sqlite)
ensure_default_financial_inputs(engine, is_sqlite) ensure_default_financial_inputs(engine, is_sqlite)

View File

@@ -29,14 +29,14 @@ from schemas.calculations import (
CapexTotals, CapexTotals,
CapexTimelineEntry, CapexTimelineEntry,
CashFlowEntry, CashFlowEntry,
ProcessingOpexCalculationRequest, OpexCalculationRequest,
ProcessingOpexCalculationResult, OpexCalculationResult,
ProcessingOpexCategoryBreakdown, OpexCategoryBreakdown,
ProcessingOpexComponentInput, OpexComponentInput,
ProcessingOpexMetrics, OpexMetrics,
ProcessingOpexParameters, OpexParameters,
ProcessingOpexTotals, OpexTotals,
ProcessingOpexTimelineEntry, OpexTimelineEntry,
ProfitabilityCalculationRequest, ProfitabilityCalculationRequest,
ProfitabilityCalculationResult, ProfitabilityCalculationResult,
ProfitabilityCosts, ProfitabilityCosts,
@@ -101,20 +101,20 @@ def _generate_cash_flows(
*, *,
periods: int, periods: int,
net_per_period: float, net_per_period: float,
initial_capex: float, capex: float,
) -> tuple[list[CashFlow], list[CashFlowEntry]]: ) -> tuple[list[CashFlow], list[CashFlowEntry]]:
"""Create cash flow structures for financial metric calculations.""" """Create cash flow structures for financial metric calculations."""
cash_flow_models: list[CashFlow] = [ cash_flow_models: list[CashFlow] = [
CashFlow(amount=-initial_capex, period_index=0) CashFlow(amount=-capex, period_index=0)
] ]
cash_flow_entries: list[CashFlowEntry] = [ cash_flow_entries: list[CashFlowEntry] = [
CashFlowEntry( CashFlowEntry(
period=0, period=0,
revenue=0.0, revenue=0.0,
processing_opex=0.0, opex=0.0,
sustaining_capex=0.0, sustaining_capex=0.0,
net=-initial_capex, net=-capex,
) )
] ]
@@ -125,7 +125,7 @@ def _generate_cash_flows(
CashFlowEntry( CashFlowEntry(
period=period, period=period,
revenue=0.0, revenue=0.0,
processing_opex=0.0, opex=0.0,
sustaining_capex=0.0, sustaining_capex=0.0,
net=net_per_period, net=net_per_period,
) )
@@ -159,26 +159,26 @@ def calculate_profitability(
revenue_total = float(pricing_result.net_revenue) revenue_total = float(pricing_result.net_revenue)
revenue_per_period = revenue_total / periods revenue_per_period = revenue_total / periods
processing_total = float(request.processing_opex) * periods processing_total = float(request.opex) * periods
sustaining_total = float(request.sustaining_capex) * periods sustaining_total = float(request.sustaining_capex) * periods
initial_capex = float(request.initial_capex) capex = float(request.capex)
net_per_period = ( net_per_period = (
revenue_per_period revenue_per_period
- float(request.processing_opex) - float(request.opex)
- float(request.sustaining_capex) - float(request.sustaining_capex)
) )
cash_flow_models, cash_flow_entries = _generate_cash_flows( cash_flow_models, cash_flow_entries = _generate_cash_flows(
periods=periods, periods=periods,
net_per_period=net_per_period, net_per_period=net_per_period,
initial_capex=initial_capex, capex=capex,
) )
# Update per-period entries to include explicit costs for presentation # Update per-period entries to include explicit costs for presentation
for entry in cash_flow_entries[1:]: for entry in cash_flow_entries[1:]:
entry.revenue = revenue_per_period entry.revenue = revenue_per_period
entry.processing_opex = float(request.processing_opex) entry.opex = float(request.opex)
entry.sustaining_capex = float(request.sustaining_capex) entry.sustaining_capex = float(request.sustaining_capex)
entry.net = net_per_period entry.net = net_per_period
@@ -196,7 +196,7 @@ def calculate_profitability(
except (ValueError, PaybackNotReachedError): except (ValueError, PaybackNotReachedError):
payback_value = None payback_value = None
total_costs = processing_total + sustaining_total + initial_capex total_costs = processing_total + sustaining_total + capex
total_net = revenue_total - total_costs total_net = revenue_total - total_costs
if revenue_total == 0: if revenue_total == 0:
@@ -212,9 +212,9 @@ def calculate_profitability(
str(exc), ["currency_code"]) from exc str(exc), ["currency_code"]) from exc
costs = ProfitabilityCosts( costs = ProfitabilityCosts(
processing_opex_total=processing_total, opex_total=processing_total,
sustaining_capex_total=sustaining_total, sustaining_capex_total=sustaining_total,
initial_capex=initial_capex, capex=capex,
) )
metrics = ProfitabilityMetrics( metrics = ProfitabilityMetrics(
@@ -354,18 +354,18 @@ def calculate_initial_capex(
) )
def calculate_processing_opex( def calculate_opex(
request: ProcessingOpexCalculationRequest, request: OpexCalculationRequest,
) -> ProcessingOpexCalculationResult: ) -> OpexCalculationResult:
"""Aggregate processing opex components into annual totals and timeline.""" """Aggregate opex components into annual totals and timeline."""
if not request.components: if not request.components:
raise OpexValidationError( raise OpexValidationError(
"At least one processing opex component is required for calculation.", "At least one opex component is required for calculation.",
["components"], ["components"],
) )
parameters: ProcessingOpexParameters = request.parameters parameters: OpexParameters = request.parameters
base_currency = parameters.currency_code base_currency = parameters.currency_code
if base_currency: if base_currency:
try: try:
@@ -388,7 +388,7 @@ def calculate_processing_opex(
category_totals: dict[str, float] = defaultdict(float) category_totals: dict[str, float] = defaultdict(float)
timeline_totals: dict[int, float] = defaultdict(float) timeline_totals: dict[int, float] = defaultdict(float)
timeline_escalated: dict[int, float] = defaultdict(float) timeline_escalated: dict[int, float] = defaultdict(float)
normalised_components: list[ProcessingOpexComponentInput] = [] normalised_components: list[OpexComponentInput] = []
max_period_end = evaluation_horizon max_period_end = evaluation_horizon
@@ -448,7 +448,7 @@ def calculate_processing_opex(
timeline_totals[period] += annual_cost timeline_totals[period] += annual_cost
normalised_components.append( normalised_components.append(
ProcessingOpexComponentInput( OpexComponentInput(
id=component.id, id=component.id,
name=component.name, name=component.name,
category=component.category, category=component.category,
@@ -471,7 +471,7 @@ def calculate_processing_opex(
str(exc), ["parameters.currency_code"] str(exc), ["parameters.currency_code"]
) from exc ) from exc
timeline_entries: list[ProcessingOpexTimelineEntry] = [] timeline_entries: list[OpexTimelineEntry] = []
escalated_values: list[float] = [] escalated_values: list[float] = []
overall_annual = timeline_totals.get(1, 0.0) overall_annual = timeline_totals.get(1, 0.0)
escalated_total = 0.0 escalated_total = 0.0
@@ -486,7 +486,7 @@ def calculate_processing_opex(
timeline_escalated[period] = escalated_cost timeline_escalated[period] = escalated_cost
escalated_total += escalated_cost escalated_total += escalated_cost
timeline_entries.append( timeline_entries.append(
ProcessingOpexTimelineEntry( OpexTimelineEntry(
period=period, period=period,
base_cost=base_cost, base_cost=base_cost,
escalated_cost=escalated_cost if apply_escalation else None, escalated_cost=escalated_cost if apply_escalation else None,
@@ -494,31 +494,31 @@ def calculate_processing_opex(
) )
escalated_values.append(escalated_cost) escalated_values.append(escalated_cost)
category_breakdowns: list[ProcessingOpexCategoryBreakdown] = [] category_breakdowns: list[OpexCategoryBreakdown] = []
total_base = sum(category_totals.values()) total_base = sum(category_totals.values())
for category, total in sorted(category_totals.items()): for category, total in sorted(category_totals.items()):
share = (total / total_base * 100.0) if total_base else None share = (total / total_base * 100.0) if total_base else None
category_breakdowns.append( category_breakdowns.append(
ProcessingOpexCategoryBreakdown( OpexCategoryBreakdown(
category=category, category=category,
annual_cost=total, annual_cost=total,
share=share, share=share,
) )
) )
metrics = ProcessingOpexMetrics( metrics = OpexMetrics(
annual_average=fmean(escalated_values) if escalated_values else None, annual_average=fmean(escalated_values) if escalated_values else None,
cost_per_ton=None, cost_per_ton=None,
) )
totals = ProcessingOpexTotals( totals = OpexTotals(
overall_annual=overall_annual, overall_annual=overall_annual,
escalated_total=escalated_total if apply_escalation else None, escalated_total=escalated_total if apply_escalation else None,
escalation_pct=escalation_pct if apply_escalation else None, escalation_pct=escalation_pct if apply_escalation else None,
by_category=category_breakdowns, by_category=category_breakdowns,
) )
return ProcessingOpexCalculationResult( return OpexCalculationResult(
totals=totals, totals=totals,
timeline=timeline_entries, timeline=timeline_entries,
metrics=metrics, metrics=metrics,
@@ -532,5 +532,5 @@ def calculate_processing_opex(
__all__ = [ __all__ = [
"calculate_profitability", "calculate_profitability",
"calculate_initial_capex", "calculate_initial_capex",
"calculate_processing_opex", "calculate_opex",
] ]

188
services/navigation.py Normal file
View 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)

View File

@@ -17,12 +17,14 @@ from models import (
PricingSettings, PricingSettings,
ProjectCapexSnapshot, ProjectCapexSnapshot,
ProjectProfitability, ProjectProfitability,
ProjectProcessingOpexSnapshot, ProjectOpexSnapshot,
NavigationGroup,
NavigationLink,
Role, Role,
Scenario, Scenario,
ScenarioCapexSnapshot, ScenarioCapexSnapshot,
ScenarioProfitability, ScenarioProfitability,
ScenarioProcessingOpexSnapshot, ScenarioOpexSnapshot,
ScenarioStatus, ScenarioStatus,
SimulationParameter, SimulationParameter,
User, User,
@@ -38,6 +40,54 @@ def _enum_value(e):
return getattr(e, "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: class ProjectRepository:
"""Persistence operations for Project entities.""" """Persistence operations for Project entities."""
@@ -573,15 +623,15 @@ class ScenarioCapexRepository:
self.session.delete(entity) self.session.delete(entity)
class ProjectProcessingOpexRepository: class ProjectOpexRepository:
"""Persistence operations for project-level processing opex snapshots.""" """Persistence operations for project-level opex snapshots."""
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
self.session = session self.session = session
def create( def create(
self, snapshot: ProjectProcessingOpexSnapshot self, snapshot: ProjectOpexSnapshot
) -> ProjectProcessingOpexSnapshot: ) -> ProjectOpexSnapshot:
self.session.add(snapshot) self.session.add(snapshot)
self.session.flush() self.session.flush()
return snapshot return snapshot
@@ -591,11 +641,11 @@ class ProjectProcessingOpexRepository:
project_id: int, project_id: int,
*, *,
limit: int | None = None, limit: int | None = None,
) -> Sequence[ProjectProcessingOpexSnapshot]: ) -> Sequence[ProjectOpexSnapshot]:
stmt = ( stmt = (
select(ProjectProcessingOpexSnapshot) select(ProjectOpexSnapshot)
.where(ProjectProcessingOpexSnapshot.project_id == project_id) .where(ProjectOpexSnapshot.project_id == project_id)
.order_by(ProjectProcessingOpexSnapshot.calculated_at.desc()) .order_by(ProjectOpexSnapshot.calculated_at.desc())
) )
if limit is not None: if limit is not None:
stmt = stmt.limit(limit) stmt = stmt.limit(limit)
@@ -604,36 +654,36 @@ class ProjectProcessingOpexRepository:
def latest_for_project( def latest_for_project(
self, self,
project_id: int, project_id: int,
) -> ProjectProcessingOpexSnapshot | None: ) -> ProjectOpexSnapshot | None:
stmt = ( stmt = (
select(ProjectProcessingOpexSnapshot) select(ProjectOpexSnapshot)
.where(ProjectProcessingOpexSnapshot.project_id == project_id) .where(ProjectOpexSnapshot.project_id == project_id)
.order_by(ProjectProcessingOpexSnapshot.calculated_at.desc()) .order_by(ProjectOpexSnapshot.calculated_at.desc())
.limit(1) .limit(1)
) )
return self.session.execute(stmt).scalar_one_or_none() return self.session.execute(stmt).scalar_one_or_none()
def delete(self, snapshot_id: int) -> None: def delete(self, snapshot_id: int) -> None:
stmt = select(ProjectProcessingOpexSnapshot).where( stmt = select(ProjectOpexSnapshot).where(
ProjectProcessingOpexSnapshot.id == snapshot_id ProjectOpexSnapshot.id == snapshot_id
) )
entity = self.session.execute(stmt).scalar_one_or_none() entity = self.session.execute(stmt).scalar_one_or_none()
if entity is None: if entity is None:
raise EntityNotFoundError( raise EntityNotFoundError(
f"Project processing opex snapshot {snapshot_id} not found" f"Project opex snapshot {snapshot_id} not found"
) )
self.session.delete(entity) self.session.delete(entity)
class ScenarioProcessingOpexRepository: class ScenarioOpexRepository:
"""Persistence operations for scenario-level processing opex snapshots.""" """Persistence operations for scenario-level opex snapshots."""
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:
self.session = session self.session = session
def create( def create(
self, snapshot: ScenarioProcessingOpexSnapshot self, snapshot: ScenarioOpexSnapshot
) -> ScenarioProcessingOpexSnapshot: ) -> ScenarioOpexSnapshot:
self.session.add(snapshot) self.session.add(snapshot)
self.session.flush() self.session.flush()
return snapshot return snapshot
@@ -643,11 +693,11 @@ class ScenarioProcessingOpexRepository:
scenario_id: int, scenario_id: int,
*, *,
limit: int | None = None, limit: int | None = None,
) -> Sequence[ScenarioProcessingOpexSnapshot]: ) -> Sequence[ScenarioOpexSnapshot]:
stmt = ( stmt = (
select(ScenarioProcessingOpexSnapshot) select(ScenarioOpexSnapshot)
.where(ScenarioProcessingOpexSnapshot.scenario_id == scenario_id) .where(ScenarioOpexSnapshot.scenario_id == scenario_id)
.order_by(ScenarioProcessingOpexSnapshot.calculated_at.desc()) .order_by(ScenarioOpexSnapshot.calculated_at.desc())
) )
if limit is not None: if limit is not None:
stmt = stmt.limit(limit) stmt = stmt.limit(limit)
@@ -656,23 +706,23 @@ class ScenarioProcessingOpexRepository:
def latest_for_scenario( def latest_for_scenario(
self, self,
scenario_id: int, scenario_id: int,
) -> ScenarioProcessingOpexSnapshot | None: ) -> ScenarioOpexSnapshot | None:
stmt = ( stmt = (
select(ScenarioProcessingOpexSnapshot) select(ScenarioOpexSnapshot)
.where(ScenarioProcessingOpexSnapshot.scenario_id == scenario_id) .where(ScenarioOpexSnapshot.scenario_id == scenario_id)
.order_by(ScenarioProcessingOpexSnapshot.calculated_at.desc()) .order_by(ScenarioOpexSnapshot.calculated_at.desc())
.limit(1) .limit(1)
) )
return self.session.execute(stmt).scalar_one_or_none() return self.session.execute(stmt).scalar_one_or_none()
def delete(self, snapshot_id: int) -> None: def delete(self, snapshot_id: int) -> None:
stmt = select(ScenarioProcessingOpexSnapshot).where( stmt = select(ScenarioOpexSnapshot).where(
ScenarioProcessingOpexSnapshot.id == snapshot_id ScenarioOpexSnapshot.id == snapshot_id
) )
entity = self.session.execute(stmt).scalar_one_or_none() entity = self.session.execute(stmt).scalar_one_or_none()
if entity is None: if entity is None:
raise EntityNotFoundError( raise EntityNotFoundError(
f"Scenario processing opex snapshot {snapshot_id} not found" f"Scenario opex snapshot {snapshot_id} not found"
) )
self.session.delete(entity) self.session.delete(entity)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal, Optional, TYPE_CHECKING from typing import Iterable, Literal, Optional, TYPE_CHECKING
from fastapi import Request, Response from fastapi import Request, Response
@@ -67,6 +67,7 @@ class AuthSession:
tokens: SessionTokens tokens: SessionTokens
user: Optional["User"] = None user: Optional["User"] = None
scopes: tuple[str, ...] = () scopes: tuple[str, ...] = ()
role_slugs: tuple[str, ...] = ()
issued_access_token: Optional[str] = None issued_access_token: Optional[str] = None
issued_refresh_token: Optional[str] = None issued_refresh_token: Optional[str] = None
clear_cookies: bool = False clear_cookies: bool = False
@@ -77,7 +78,10 @@ class AuthSession:
@classmethod @classmethod
def anonymous(cls) -> "AuthSession": 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( def issue_tokens(
self, self,
@@ -100,6 +104,10 @@ class AuthSession:
self.tokens = SessionTokens(access_token=None, refresh_token=None) self.tokens = SessionTokens(access_token=None, refresh_token=None)
self.user = None self.user = None
self.scopes = () 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: def extract_session_tokens(request: Request, strategy: SessionStrategy) -> SessionTokens:

View File

@@ -14,12 +14,12 @@ from services.repositories import (
PricingSettingsSeedResult, PricingSettingsSeedResult,
ProjectRepository, ProjectRepository,
ProjectProfitabilityRepository, ProjectProfitabilityRepository,
ProjectProcessingOpexRepository, ProjectOpexRepository,
ProjectCapexRepository, ProjectCapexRepository,
RoleRepository, RoleRepository,
ScenarioRepository, ScenarioRepository,
ScenarioProfitabilityRepository, ScenarioProfitabilityRepository,
ScenarioProcessingOpexRepository, ScenarioOpexRepository,
ScenarioCapexRepository, ScenarioCapexRepository,
SimulationParameterRepository, SimulationParameterRepository,
UserRepository, UserRepository,
@@ -27,6 +27,7 @@ from services.repositories import (
ensure_default_pricing_settings, ensure_default_pricing_settings,
ensure_default_roles, ensure_default_roles,
pricing_settings_to_metadata, pricing_settings_to_metadata,
NavigationRepository,
) )
from services.scenario_validation import ScenarioComparisonValidator from services.scenario_validation import ScenarioComparisonValidator
@@ -44,13 +45,14 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
self.simulation_parameters: SimulationParameterRepository | None = None self.simulation_parameters: SimulationParameterRepository | None = None
self.project_profitability: ProjectProfitabilityRepository | None = None self.project_profitability: ProjectProfitabilityRepository | None = None
self.project_capex: ProjectCapexRepository | None = None self.project_capex: ProjectCapexRepository | None = None
self.project_processing_opex: ProjectProcessingOpexRepository | None = None self.project_opex: ProjectOpexRepository | None = None
self.scenario_profitability: ScenarioProfitabilityRepository | None = None self.scenario_profitability: ScenarioProfitabilityRepository | None = None
self.scenario_capex: ScenarioCapexRepository | None = None self.scenario_capex: ScenarioCapexRepository | None = None
self.scenario_processing_opex: ScenarioProcessingOpexRepository | None = None self.scenario_opex: ScenarioOpexRepository | None = None
self.users: UserRepository | None = None self.users: UserRepository | None = None
self.roles: RoleRepository | None = None self.roles: RoleRepository | None = None
self.pricing_settings: PricingSettingsRepository | None = None self.pricing_settings: PricingSettingsRepository | None = None
self.navigation: NavigationRepository | None = None
def __enter__(self) -> "UnitOfWork": def __enter__(self) -> "UnitOfWork":
self.session = self._session_factory() self.session = self._session_factory()
@@ -62,17 +64,18 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
self.project_profitability = ProjectProfitabilityRepository( self.project_profitability = ProjectProfitabilityRepository(
self.session) self.session)
self.project_capex = ProjectCapexRepository(self.session) self.project_capex = ProjectCapexRepository(self.session)
self.project_processing_opex = ProjectProcessingOpexRepository( self.project_opex = ProjectOpexRepository(
self.session) self.session)
self.scenario_profitability = ScenarioProfitabilityRepository( self.scenario_profitability = ScenarioProfitabilityRepository(
self.session self.session
) )
self.scenario_capex = ScenarioCapexRepository(self.session) self.scenario_capex = ScenarioCapexRepository(self.session)
self.scenario_processing_opex = ScenarioProcessingOpexRepository( self.scenario_opex = ScenarioOpexRepository(
self.session) self.session)
self.users = UserRepository(self.session) self.users = UserRepository(self.session)
self.roles = RoleRepository(self.session) self.roles = RoleRepository(self.session)
self.pricing_settings = PricingSettingsRepository(self.session) self.pricing_settings = PricingSettingsRepository(self.session)
self.navigation = NavigationRepository(self.session)
self._scenario_validator = ScenarioComparisonValidator() self._scenario_validator = ScenarioComparisonValidator()
return self return self
@@ -90,13 +93,14 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
self.simulation_parameters = None self.simulation_parameters = None
self.project_profitability = None self.project_profitability = None
self.project_capex = None self.project_capex = None
self.project_processing_opex = None self.project_opex = None
self.scenario_profitability = None self.scenario_profitability = None
self.scenario_capex = None self.scenario_capex = None
self.scenario_processing_opex = None self.scenario_opex = None
self.users = None self.users = None
self.roles = None self.roles = None
self.pricing_settings = None self.pricing_settings = None
self.navigation = None
def flush(self) -> None: def flush(self) -> None:
if not self.session: if not self.session:

View File

@@ -367,17 +367,17 @@ a.sidebar-brand:focus {
.sidebar-nav-controls { .sidebar-nav-controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px; gap: 1rem;
margin: 0; margin: 0;
} }
.nav-chevron { .nav-chevron {
width: 80px; width: 5rem;
height: 80px; height: 5rem;
border: none; border: none;
background: rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.5);
color: rgba(255, 255, 255, 0.88); color: rgba(255, 255, 255, 0.88);
font-size: 1.2rem; font-size: 4.5rem;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
@@ -388,8 +388,9 @@ a.sidebar-brand:focus {
.nav-chevron:hover, .nav-chevron:hover,
.nav-chevron:focus { .nav-chevron:focus {
background: rgba(255, 255, 255, 0.2); background: rgba(0, 0, 0, 0.1);
transform: scale(1.05); color: rgba(255, 255, 255, 1);
transform: scale(0.9);
} }
.nav-chevron:disabled { .nav-chevron:disabled {
@@ -1188,8 +1189,16 @@ footer a:focus {
justify-content: center; justify-content: center;
} }
.sidebar-nav-controls {
display: none;
}
.sidebar-link-block {
align-items: center;
}
.sidebar-link { .sidebar-link {
flex: 1 1 140px; flex: 1 1 40px;
justify-content: center; justify-content: center;
} }
@@ -1219,6 +1228,10 @@ footer a:focus {
overflow: hidden; overflow: hidden;
} }
body.sidebar-open .app-main {
position: relative;
z-index: 1;
}
body.sidebar-open .app-sidebar { body.sidebar-open .app-sidebar {
display: block; display: block;
position: fixed; position: fixed;
@@ -1227,7 +1240,7 @@ footer a:focus {
width: min(320px, 82vw); width: min(320px, 82vw);
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: auto;
z-index: 900; z-index: 999;
box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4); box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4);
} }
@@ -1235,9 +1248,4 @@ footer a:focus {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
body.sidebar-open .app-main {
position: relative;
z-index: 950;
}
} }

View File

@@ -11,6 +11,108 @@
justify-content: flex-end; 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 { .project-metrics {
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
@@ -87,6 +189,163 @@
margin-bottom: 2rem; 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 { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -151,6 +410,16 @@
.header-actions { .header-actions {
justify-content: flex-start; justify-content: flex-start;
} }
.scenario-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.scenario-item__body {
max-width: 70%;
}
} }
.form { .form {

View File

@@ -106,6 +106,76 @@
flex-direction: column; flex-direction: column;
gap: 1.5rem; 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 { .table-responsive {
width: 100%; width: 100%;
@@ -165,12 +235,214 @@
display: grid; display: grid;
gap: 1.5rem; 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 { .empty-state {
color: var(--muted); color: var(--muted);
font-style: italic; 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) { @media (min-width: 960px) {
.header-actions { .header-actions {
justify-content: flex-start; justify-content: flex-start;

View 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);
});
})();

View File

@@ -1,14 +1,35 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const table = document.querySelector("[data-project-table]"); const container = document.querySelector("[data-project-table]");
const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : [];
const filterInput = document.querySelector("[data-project-filter]"); 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", () => { filterInput.addEventListener("input", () => {
const query = filterInput.value.trim().toLowerCase(); const query = filterInput.value.trim().toLowerCase();
rows.forEach((row) => { filterItems.forEach((item) => {
const match = row.textContent.toLowerCase().includes(query); const match = item.textContent.toLowerCase().includes(query);
row.style.display = match ? "" : "none"; item.style.display = match ? "" : "none";
}); });
}); });
} }

View File

@@ -41,6 +41,7 @@
<script src="/static/js/exports.js" defer></script> <script src="/static/js/exports.js" defer></script>
<script src="/static/js/imports.js" defer></script> <script src="/static/js/imports.js" defer></script>
<script src="/static/js/notifications.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/navigation.js" defer></script>
<script src="/static/js/theme.js"></script> <script src="/static/js/theme.js"></script>
</body> </body>

View File

@@ -6,9 +6,5 @@
<span class="brand-subtitle">Mining Planner</span> <span class="brand-subtitle">Mining Planner</span>
</div> </div>
</a> </a>
<div class="sidebar-nav-controls">
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">&larr;</button>
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">&rarr;</button>
</div>
{% include "partials/sidebar_nav.html" %} {% include "partials/sidebar_nav.html" %}
</div> </div>

View File

@@ -1,67 +1,78 @@
{% set dashboard_href = request.url_for('dashboard.home') if request else '/' %} {% set sidebar_nav = get_sidebar_navigation(request) %}
{% set projects_href = request.url_for('projects.project_list_page') if request {% set nav_groups = sidebar_nav.groups if sidebar_nav else [] %}
else '/projects/ui' %} {% set project_create_href = {% set current_path = request.url.path if request else '' %}
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"}, {"href": request.url_for('calculations.profitability_form') if request else '/calculations/profitability', "label": "Profitability Calculator", "match_prefix": "/calculations/profitability"}, {"href": request.url_for('calculations.processing_opex_form') if request else '/calculations/processing-opex', "label": "Processing Opex Planner", "match_prefix": "/calculations/processing-opex"}, {"href": request.url_for('calculations.capex_form') if request else '/calculations/capex', "label": "Initial Capex Planner", "match_prefix": "/calculations/capex"} ] }, { "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 } ] %}
<nav class="sidebar-nav" aria-label="Primary navigation"> <nav
{% set current_path = request.url.path if request else '' %} {% for group in class="sidebar-nav"
nav_groups %} {% if group.links %} aria-label="Primary navigation"
<div class="sidebar-section"> data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
<div class="sidebar-section-label">{{ group.label }}</div> >
<div class="sidebar-section-links"> <div class="sidebar-nav-controls">
{% for link in group.links %} {% set href = link.href | string %} {% set <button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page"></button>
match_prefix = link.get('match_prefix', href) | string %} {% if <button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page"></button>
match_prefix == '/' %} {% set is_active = current_path == '/' %} {% else
%} {% set is_active = current_path.startswith(match_prefix) %} {% endif %}
<div class="sidebar-link-block">
<a
href="{{ href }}"
class="sidebar-link{% if is_active %} is-active{% 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
%}
<a
href="{{ child.href | string }}"
class="sidebar-sublink{% if child_active %} is-active{% endif %}"
>
{{ child.label }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div> </div>
{% endif %} {% endfor %} {% 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 %}
{% 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 %}{% 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_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 }}"
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 %}
{% 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> </nav>

View File

@@ -17,6 +17,7 @@
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p> <p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
</div> </div>
<div class="header-actions"> <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" 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> <a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
</div> </div>
@@ -46,65 +47,91 @@
</section> </section>
<div class="project-layout"> <div class="project-layout">
<section class="card"> <div class="project-column">
<h2>Project Overview</h2> <section class="card">
<dl class="definition-list"> <h2>Project Overview</h2>
<div> <dl class="definition-list">
<dt>Location</dt> <div>
<dd>{{ project.location or '—' }}</dd> <dt>Location</dt>
</div> <dd>{{ project.location or '—' }}</dd>
<div> </div>
<dt>Description</dt> <div>
<dd>{{ project.description or 'No description provided.' }}</dd> <dt>Description</dt>
</div> <dd>{{ project.description or 'No description provided.' }}</dd>
<div> </div>
<dt>Created</dt> <div>
<dd>{{ project.created_at.strftime('%Y-%m-%d %H:%M') }}</dd> <dt>Created</dt>
</div> <dd>{{ project.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
<div> </div>
<dt>Updated</dt> <div>
<dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd> <dt>Updated</dt>
</div> <dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
<div> </div>
<dt>Latest Scenario Update</dt> <div>
<dd>{{ scenario_stats.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_stats.latest_update else '—' }}</dd> <dt>Latest Scenario Update</dt>
</div> <dd>{{ scenario_stats.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_stats.latest_update else '—' }}</dd>
</dl> </div>
</section> </dl>
</section>
<section class="card"> <section class="card project-actions-card">
<header class="card-header"> <h2>Next Steps</h2>
<h2>Scenarios</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> <a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
</header> </header>
{% if scenarios %} {% if scenarios %}
<div class="table-responsive"> <ul class="scenario-list">
<table class="table"> {% for scenario in scenarios %}
<thead> <li class="scenario-item">
<tr> <div class="scenario-item__body">
<th>Name</th> <div class="scenario-item__header">
<th>Status</th> <h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
<th>Currency</th> <span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
<th>Primary Resource</th> </div>
<th class="text-right">Actions</th> <dl class="scenario-item__meta">
</tr> <div>
</thead> <dt>Currency</dt>
<tbody> <dd>{{ scenario.currency or '—' }}</dd>
{% for scenario in scenarios %} </div>
<tr> <div>
<td>{{ scenario.name }}</td> <dt>Primary Resource</dt>
<td>{{ scenario.status.value.title() }}</td> <dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
<td>{{ scenario.currency or '—' }}</td> </div>
<td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td> <div>
<td class="text-right"> <dt>Last Updated</dt>
<a class="table-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a> <dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
<a class="table-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a> </div>
</td> </dl>
</tr> </div>
{% endfor %} <div class="scenario-item__actions">
</tbody> <a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
</table> <a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
</div> </div>
</li>
{% endfor %}
</ul>
{% else %} {% 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> <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 %} {% endif %}

View File

@@ -16,26 +16,21 @@
{% endif %} {% endif %}
</nav> </nav>
<header class="page-header">
<div>
<h1>{% if project %}Edit Project{% else %}Create Project{% endif %}</h1>
<p class="text-muted">Provide core information about the mining project.</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Project</button>
</div>
</header>
{% if error %} {% if error %}
<div class="alert alert-error">{{ error }}</div> <div class="alert alert-error">{{ error }}</div>
{% endif %} {% 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>
<p class="text-muted">Provide core information about the mining project.</p>
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Project</button>
</div>
</header>
{% 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-grid">
<div class="form-group"> <div class="form-group">
<label for="name">Name</label> <label for="name">Name</label>

View File

@@ -17,48 +17,61 @@
class="form-control" class="form-control"
placeholder="Filter projects..." placeholder="Filter projects..."
data-project-filter data-project-filter
aria-label="Filter projects"
/> />
<a class="btn btn-primary" href="{{ url_for('projects.create_project_form') }}">New Project</a> <a class="btn btn-primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
</div> </div>
</section> </section>
{% if projects %} {% if projects %}
<table class="projects-table" data-project-table> <section class="projects-grid" data-project-table>
<thead> {% for project in projects %}
<tr> <article class="project-card" data-project-entry>
<th>Name</th> <header class="project-card__header">
<th>Location</th> <h2 class="project-card__title">
<th>Type</th> <a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
<th>Scenarios</th> </h2>
<th></th> <span class="project-card__type badge">{{ project.operation_type.value.replace('_', ' ') | title }}</span>
</tr> </header>
</thead>
<tbody> <p class="project-card__description">
{% for project in projects %} {{ project.description or 'No description provided yet.' }}
<tr> </p>
<td class="table-cell-actions">
{{ project.name }} <dl class="project-card__meta">
<button <div>
class="btn btn-ghost" <dt>Scenarios</dt>
data-export-trigger <dd><span class="badge badge-pill">{{ project.scenario_count }}</span></dd>
data-export-target="projects" </div>
title="Export projects dataset" <div>
> <dt>Location</dt>
<span aria-hidden="true"></span> <dd>{{ project.location or '—' }}</dd>
<span class="sr-only">Export</span> </div>
</button> <div>
</td> <dt>Updated</dt>
<td>{{ project.location or '—' }}</td> <dd>{{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at else '—' }}</dd>
<td>{{ project.operation_type.value.replace('_', ' ') | title }}</td> </div>
<td>{{ project.scenario_count }}</td> </dl>
<td class="text-right">
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View</a> <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> <a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
</td> </div>
</tr> <button
{% endfor %} class="btn btn-ghost"
</tbody> data-export-trigger
</table> data-export-target="projects"
title="Export projects dataset"
>
<span aria-hidden="true"></span>
<span class="sr-only">Export</span>
</button>
</footer>
</article>
{% endfor %}
</section>
{% else %} {% else %}
<p>No projects yet. <a href="{{ url_for('projects.create_project_form') }}">Create your first project.</a></p> <p>No projects yet. <a href="{{ url_for('projects.create_project_form') }}">Create your first project.</a></p>
{% endif %} {% endif %}

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Initial Capex Planner · CalMiner{% endblock %} {% block title %}Capex Planner · CalMiner{% endblock %}
{% block content %} {% block content %}
<nav class="breadcrumb"> <nav class="breadcrumb">
@@ -10,17 +10,24 @@
{% if scenario %} {% if scenario %}
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a> <a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
{% endif %} {% endif %}
<span aria-current="page">Initial Capex Planner</span> <span aria-current="page">Capex Planner</span>
</nav> </nav>
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>Initial Capex Planner</h1> <h1>Capex Planner</h1>
<p class="text-muted">Capture upfront capital requirements and review categorized totals.</p> <p class="text-muted">Plan capital requirements for {{ scenario.name if scenario else 'this scenario' }}.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{% if cancel_url %} {% if scenario_url %}
<a class="btn" href="{{ cancel_url }}">Cancel</a> <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 %} {% endif %}
<button class="btn primary" type="submit" form="capex-form">Save &amp; Calculate</button> <button class="btn primary" type="submit" form="capex-form">Save &amp; Calculate</button>
</div> </div>
@@ -188,7 +195,7 @@
{% if result %} {% if result %}
<div class="report-grid"> <div class="report-grid">
<article class="report-card"> <article class="report-card">
<h3>Total Initial Capex</h3> <h3>Total Capex</h3>
<p class="metric"> <p class="metric">
<strong>{{ result.totals.overall | currency_display(result.currency) }}</strong> <strong>{{ result.totals.overall | currency_display(result.currency) }}</strong>
</p> </p>
@@ -249,7 +256,7 @@
</table> </table>
{% endif %} {% endif %}
{% else %} {% else %}
<p class="muted">Provide component details and calculate to see initial capex totals.</p> <p class="muted">Provide component details and calculate to see capex totals.</p>
{% endif %} {% endif %}
</section> </section>

View File

@@ -13,28 +13,36 @@
</nav> </nav>
<header class="page-header"> <header class="page-header">
{% set profitability_href = url_for('calculations.profitability_form') %} {% set profitability_href = '/calculations/profitability' %}
{% set processing_opex_href = url_for('calculations.processing_opex_form') %} {% set opex_href = url_for('calculations.opex_form') %}
{% set capex_href = url_for('calculations.capex_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 %} {% if project and scenario %}
{% set profitability_href = profitability_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %} {% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
{% set processing_opex_href = processing_opex_href ~ '?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 %} {% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
{% endif %} {% endif %}
<div> <div>
<h1>{{ scenario.name }}</h1> <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>
<div class="header-actions"> <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="{{ profitability_href }}">Profitability Calculator</a>
<a class="btn" href="{{ processing_opex_href }}">Processing Opex Planner</a> <a class="btn" href="{{ opex_href }}">Opex Planner</a>
<a class="btn" href="{{ capex_href }}">Initial Capex 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> <a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
</div> </div>
</header> </header>
<section class="scenario-metrics"> <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"> <article class="metric-card">
<h2>Financial Inputs</h2> <h2>Financial Inputs</h2>
<p class="metric-value">{{ scenario_metrics.financial_count }}</p> <p class="metric-value">{{ scenario_metrics.financial_count }}</p>
@@ -50,37 +58,54 @@
<p class="metric-value">{{ scenario_metrics.currency or '—' }}</p> <p class="metric-value">{{ scenario_metrics.currency or '—' }}</p>
<span class="metric-caption">Financial reporting</span> <span class="metric-caption">Financial reporting</span>
</article> </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> </section>
<div class="scenario-layout"> <div class="scenario-layout">
<section class="card"> <div class="scenario-column">
<h2>Scenario Details</h2> <section class="card">
<dl class="definition-list"> <h2>Scenario Overview</h2>
<div> <dl class="definition-list">
<dt>Description</dt> <div>
<dd>{{ scenario.description or 'No description provided.' }}</dd> <dt>Description</dt>
</div> <dd>{{ scenario.description or 'No description provided.' }}</dd>
<div> </div>
<dt>Timeline</dt> <div>
<dd> <dt>Timeline</dt>
{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }} <dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
</dd> </div>
</div> <div>
<div> <dt>Discount Rate</dt>
<dt>Discount Rate</dt> <dd>{{ scenario.discount_rate or '—' }}</dd>
<dd>{{ scenario.discount_rate or '—' }}</dd> </div>
</div> <div>
<div> <dt>Primary Resource</dt>
<dt>Last Updated</dt> <dd>{{ scenario_metrics.primary_resource or '—' }}</dd>
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd> </div>
</div> <div>
</dl> <dt>Last Updated</dt>
</section> <dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
</div>
</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 scenarios 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"> <section class="card">
<h2>Financial Inputs</h2> <h2>Financial Inputs</h2>

View File

@@ -16,76 +16,133 @@
{% endif %} {% endif %}
</nav> </nav>
<header class="page-header"> {% set error = error | default(None) %}
<div> {% set error_field = error_field | default(None) %}
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1> {% set currency_error = error if error_field == 'currency' else None %}
<p class="text-muted">Configure assumptions and metadata for this scenario.</p> {% set name_error = error if error_field == 'name' else None %}
</div>
<div class="header-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a>
<button class="btn primary" type="submit">Save Scenario</button>
</div>
</header>
{% if error %} {% if error and not error_field %}
<div class="alert alert-error">{{ error }}</div> <div class="alert alert-error">{{ error }}</div>
{% endif %} {% endif %}
<form class="form scenario-form" method="post" action="{{ form_action }}"> <form class="form scenario-form" method="post" action="{{ form_action }}">
<div class="form-grid"> <header class="page-header">
<div class="form-group"> <div>
<label for="name">Name</label> <h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
<input id="name" name="name" type="text" required value="{{ scenario.name if scenario else '' }}" /> <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>
<button class="btn primary" type="submit">Save Scenario</button>
</div>
</header>
<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>
<section class="card">
<h2>Scenario Overview</h2>
<div class="form-grid">
<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 '' }}" {% 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">
<label for="status">Status</label>
<select id="status" name="status">
{% for value, label in scenario_statuses %}
<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{% 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 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>
<div class="form-group">
<label for="primary_resource">Primary Resource</label>
<select id="primary_resource" name="primary_resource">
<option value=""></option>
{% for value, label in resource_types %}
<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>
<div class="form-group"> <div class="form-group">
<label for="status">Status</label> <label for="description">Description</label>
<select id="status" name="status"> <textarea id="description" name="description" rows="5" placeholder="Describe the key drivers or differences for this scenario.">{{ scenario.description if scenario else '' }}</textarea>
{% for value, label in scenario_statuses %} <p class="field-help">Summarise what distinguishes this scenario for collaborators and future audits.</p>
<option value="{{ value }}" {% if scenario and scenario.status.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div> </div>
</section>
<div class="form-group">
<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>
{% endif %}
</div>
<div class="form-group">
<label for="primary_resource">Primary Resource</label>
<select id="primary_resource" name="primary_resource">
<option value=""></option>
{% for value, label in resource_types %}
<option value="{{ value }}" {% if scenario and scenario.primary_resource and scenario.primary_resource.value == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<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 '' }}" />
</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 '' }}" />
</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 '' }}" />
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="5">{{ scenario.description if scenario else '' }}</textarea>
</div>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="{{ cancel_url }}">Cancel</a> <a class="btn" href="{{ cancel_url }}">Cancel</a>

View 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 %}

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Processing Opex Planner · CalMiner{% endblock %} {% block title %}Opex Planner · CalMiner{% endblock %}
{% block content %} {% block content %}
<nav class="breadcrumb"> <nav class="breadcrumb">
@@ -10,19 +10,26 @@
{% if scenario %} {% if scenario %}
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a> <a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
{% endif %} {% endif %}
<span aria-current="page">Processing Opex Planner</span> <span aria-current="page">Opex Planner</span>
</nav> </nav>
<header class="page-header"> <header class="page-header">
<div> <div>
<h1>Processing Opex Planner</h1> <h1>Opex Planner</h1>
<p class="text-muted">Capture recurring operational costs and review annual totals with escalation assumptions.</p> <p class="text-muted">Capture recurring operational costs and review annual totals with escalation assumptions.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{% if cancel_url %} {% if scenario_url %}
<a class="btn" href="{{ cancel_url }}">Cancel</a> <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 %} {% endif %}
<button class="btn primary" type="submit" form="processing-opex-form">Save &amp; Calculate</button> {% 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 &amp; Calculate</button>
</div> </div>
</header> </header>
@@ -47,7 +54,7 @@
</div> </div>
{% endif %} {% endif %}
<form id="processing-opex-form" class="form scenario-form" method="post" action="{{ form_action }}"> <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="csrf_token" value="{{ csrf_token or '' }}" />
<input type="hidden" name="options[persist]" value="{{ '1' if options and options.persist else '' }}" /> <input type="hidden" name="options[persist]" value="{{ '1' if options and options.persist else '' }}" />

View File

@@ -19,8 +19,15 @@
<p class="text-muted">Evaluate revenue, costs, and key financial metrics for a scenario.</p> <p class="text-muted">Evaluate revenue, costs, and key financial metrics for a scenario.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
{% if cancel_url %} {% if scenario_url %}
<a class="btn" href="{{ cancel_url }}">Cancel</a> <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 %} {% endif %}
<button class="btn primary" type="submit" form="profitability-form">Run Calculation</button> <button class="btn primary" type="submit" form="profitability-form">Run Calculation</button>
</div> </div>
@@ -104,8 +111,8 @@
<input id="smelting_charge" name="smelting_charge" type="number" min="0" step="0.01" value="{{ data.smelting_charge }}" /> <input id="smelting_charge" name="smelting_charge" type="number" min="0" step="0.01" value="{{ data.smelting_charge }}" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="processing_opex">Processing Opex (per period)</label> <label for="opex">Opex (per period)</label>
<input id="processing_opex" name="processing_opex" type="number" min="0" step="0.01" value="{{ data.processing_opex }}" /> <input id="opex" name="opex" type="number" min="0" step="0.01" value="{{ data.opex }}" />
</div> </div>
</div> </div>
</fieldset> </fieldset>
@@ -172,8 +179,8 @@
<legend>Capital &amp; Discounting</legend> <legend>Capital &amp; Discounting</legend>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="initial_capex">Initial Capex</label> <label for="capex">Capex</label>
<input id="initial_capex" name="initial_capex" type="number" min="0" step="0.01" value="{{ data.initial_capex }}" /> <input id="capex" name="capex" type="number" min="0" step="0.01" value="{{ data.capex }}" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sustaining_capex">Sustaining Capex (per period)</label> <label for="sustaining_capex">Sustaining Capex (per period)</label>
@@ -261,16 +268,16 @@
<h3>Cost Breakdown</h3> <h3>Cost Breakdown</h3>
<ul class="metric-list"> <ul class="metric-list">
<li> <li>
<span>Processing Opex</span> <span>Opex</span>
<strong>{{ result.costs.processing_opex_total | currency_display(result.currency) }}</strong> <strong>{{ result.costs.opex_total | currency_display(result.currency) }}</strong>
</li> </li>
<li> <li>
<span>Sustaining Capex</span> <span>Sustaining Capex</span>
<strong>{{ result.costs.sustaining_capex_total | currency_display(result.currency) }}</strong> <strong>{{ result.costs.sustaining_capex_total | currency_display(result.currency) }}</strong>
</li> </li>
<li> <li>
<span>Initial Capex</span> <span>Capex</span>
<strong>{{ result.costs.initial_capex | currency_display(result.currency) }}</strong> <strong>{{ result.costs.capex | currency_display(result.currency) }}</strong>
</li> </li>
</ul> </ul>
</article> </article>
@@ -304,7 +311,7 @@
<tr> <tr>
<th scope="col">Period</th> <th scope="col">Period</th>
<th scope="col">Revenue</th> <th scope="col">Revenue</th>
<th scope="col">Processing Opex</th> <th scope="col">Opex</th>
<th scope="col">Sustaining Capex</th> <th scope="col">Sustaining Capex</th>
<th scope="col">Net Cash Flow</th> <th scope="col">Net Cash Flow</th>
</tr> </tr>
@@ -314,7 +321,7 @@
<tr> <tr>
<th scope="row">{{ entry.period }}</th> <th scope="row">{{ entry.period }}</th>
<td>{{ entry.revenue | currency_display(result.currency) }}</td> <td>{{ entry.revenue | currency_display(result.currency) }}</td>
<td>{{ entry.processing_opex | currency_display(result.currency) }}</td> <td>{{ entry.opex | currency_display(result.currency) }}</td>
<td>{{ entry.sustaining_capex | currency_display(result.currency) }}</td> <td>{{ entry.sustaining_capex | currency_display(result.currency) }}</td>
<td>{{ entry.net | currency_display(result.currency) }}</td> <td>{{ entry.net | currency_display(result.currency) }}</td>
</tr> </tr>

View File

@@ -48,7 +48,7 @@ def test_capex_calculation_html_flow(
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}" f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}"
) )
assert form_page.status_code == 200 assert form_page.status_code == 200
assert "Initial Capex Planner" in form_page.text assert "Capex Planner" in form_page.text
response = client.post( response = client.post(
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}", f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
@@ -71,7 +71,7 @@ def test_capex_calculation_html_flow(
}, },
) )
assert response.status_code == 200 assert response.status_code == 200
assert "Initial capex calculation completed successfully." in response.text assert "Capex calculation completed successfully." in response.text
assert "Capex Summary" 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 "$1,200,000.00" in response.text or "1,200,000" in response.text
assert "USD" in response.text assert "USD" in response.text

View File

@@ -15,7 +15,7 @@ def _create_project(client: TestClient, name: str) -> int:
"name": name, "name": name,
"location": "Nevada", "location": "Nevada",
"operation_type": "open_pit", "operation_type": "open_pit",
"description": "Project for processing opex testing", "description": "Project for opex testing",
}, },
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -37,7 +37,7 @@ def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
return response.json()["id"] return response.json()["id"]
def test_processing_opex_calculation_html_flow( def test_opex_calculation_html_flow(
client: TestClient, client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork], unit_of_work_factory: Callable[[], UnitOfWork],
) -> None: ) -> None:
@@ -45,13 +45,13 @@ def test_processing_opex_calculation_html_flow(
scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario") scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario")
form_page = client.get( form_page = client.get(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}" f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}"
) )
assert form_page.status_code == 200 assert form_page.status_code == 200
assert "Processing Opex Planner" in form_page.text assert "Opex Planner" in form_page.text
response = client.post( response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}", f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
data={ data={
"components[0][name]": "Power", "components[0][name]": "Power",
"components[0][category]": "energy", "components[0][category]": "energy",
@@ -75,21 +75,21 @@ def test_processing_opex_calculation_html_flow(
"parameters[evaluation_horizon_years]": "3", "parameters[evaluation_horizon_years]": "3",
"parameters[apply_escalation]": "1", "parameters[apply_escalation]": "1",
"options[persist]": "1", "options[persist]": "1",
"options[snapshot_notes]": "Processing opex HTML flow", "options[snapshot_notes]": "Opex HTML flow",
}, },
) )
assert response.status_code == 200 assert response.status_code == 200
assert "Processing opex calculation completed successfully." in response.text assert "Opex calculation completed successfully." in response.text
assert "Opex Summary" in response.text assert "Opex Summary" in response.text
assert "$22,000.00" in response.text or "22,000" in response.text assert "$22,000.00" in response.text or "22,000" in response.text
with unit_of_work_factory() as uow: with unit_of_work_factory() as uow:
assert uow.project_processing_opex is not None assert uow.project_opex is not None
assert uow.scenario_processing_opex is not None assert uow.scenario_opex is not None
project_snapshots = uow.project_processing_opex.list_for_project( project_snapshots = uow.project_opex.list_for_project(
project_id) project_id)
scenario_snapshots = uow.scenario_processing_opex.list_for_scenario( scenario_snapshots = uow.scenario_opex.list_for_scenario(
scenario_id) scenario_id)
assert len(project_snapshots) == 1 assert len(project_snapshots) == 1
@@ -119,7 +119,7 @@ def test_processing_opex_calculation_html_flow(
assert scenario_snapshot.currency_code == "USD" assert scenario_snapshot.currency_code == "USD"
def test_processing_opex_calculation_json_flow( def test_opex_calculation_json_flow(
client: TestClient, client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork], unit_of_work_factory: Callable[[], UnitOfWork],
) -> None: ) -> None:
@@ -170,7 +170,7 @@ def test_processing_opex_calculation_json_flow(
} }
response = client.post( response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}", f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
json=payload, json=payload,
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -206,12 +206,12 @@ def test_processing_opex_calculation_json_flow(
assert data["metrics"]["annual_average"] == pytest.approx(expected_average) assert data["metrics"]["annual_average"] == pytest.approx(expected_average)
with unit_of_work_factory() as uow: with unit_of_work_factory() as uow:
assert uow.project_processing_opex is not None assert uow.project_opex is not None
assert uow.scenario_processing_opex is not None assert uow.scenario_opex is not None
project_snapshot = uow.project_processing_opex.latest_for_project( project_snapshot = uow.project_opex.latest_for_project(
project_id) project_id)
scenario_snapshot = uow.scenario_processing_opex.latest_for_scenario( scenario_snapshot = uow.scenario_opex.latest_for_scenario(
scenario_id) scenario_id)
assert project_snapshot is not None assert project_snapshot is not None
@@ -232,7 +232,7 @@ def test_processing_opex_calculation_json_flow(
@pytest.mark.parametrize("content_type", ["form", "json"]) @pytest.mark.parametrize("content_type", ["form", "json"])
def test_processing_opex_calculation_currency_mismatch( def test_opex_calculation_currency_mismatch(
client: TestClient, client: TestClient,
unit_of_work_factory: Callable[[], UnitOfWork], unit_of_work_factory: Callable[[], UnitOfWork],
content_type: str, content_type: str,
@@ -260,7 +260,7 @@ def test_processing_opex_calculation_currency_mismatch(
"options": {"persist": True}, "options": {"persist": True},
} }
response = client.post( response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}", f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
json=payload, json=payload,
) )
assert response.status_code == 422 assert response.status_code == 422
@@ -270,7 +270,7 @@ def test_processing_opex_calculation_currency_mismatch(
"components[0].currency" in entry for entry in body.get("errors", [])) "components[0].currency" in entry for entry in body.get("errors", []))
else: else:
response = client.post( response = client.post(
f"/calculations/processing-opex?project_id={project_id}&scenario_id={scenario_id}", f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
data={ data={
"components[0][name]": "Power", "components[0][name]": "Power",
"components[0][category]": "energy", "components[0][category]": "energy",
@@ -298,12 +298,12 @@ def test_processing_opex_calculation_currency_mismatch(
"components[0].currency" in entry for entry in combined_errors) "components[0].currency" in entry for entry in combined_errors)
with unit_of_work_factory() as uow: with unit_of_work_factory() as uow:
assert uow.project_processing_opex is not None assert uow.project_opex is not None
assert uow.scenario_processing_opex is not None assert uow.scenario_opex is not None
project_snapshots = uow.project_processing_opex.list_for_project( project_snapshots = uow.project_opex.list_for_project(
project_id) project_id)
scenario_snapshots = uow.scenario_processing_opex.list_for_scenario( scenario_snapshots = uow.scenario_opex.list_for_scenario(
scenario_id) scenario_id)
assert project_snapshots == [] assert project_snapshots == []

View File

@@ -1,16 +1,16 @@
import pytest import pytest
from schemas.calculations import ( from schemas.calculations import (
ProcessingOpexCalculationRequest, OpexCalculationRequest,
ProcessingOpexComponentInput, OpexComponentInput,
ProcessingOpexOptions, OpexOptions,
ProcessingOpexParameters, OpexParameters,
) )
from services.calculations import calculate_processing_opex from services.calculations import calculate_opex
from services.exceptions import OpexValidationError from services.exceptions import OpexValidationError
def _component(**overrides) -> ProcessingOpexComponentInput: def _component(**overrides) -> OpexComponentInput:
defaults = { defaults = {
"id": None, "id": None,
"name": "Component", "name": "Component",
@@ -24,11 +24,11 @@ def _component(**overrides) -> ProcessingOpexComponentInput:
"notes": None, "notes": None,
} }
defaults.update(overrides) defaults.update(overrides)
return ProcessingOpexComponentInput(**defaults) return OpexComponentInput(**defaults)
def test_calculate_processing_opex_success(): def test_calculate_opex_success():
request = ProcessingOpexCalculationRequest( request = OpexCalculationRequest(
components=[ components=[
_component( _component(
name="Power", name="Power",
@@ -49,17 +49,17 @@ def test_calculate_processing_opex_success():
period_end=2, period_end=2,
), ),
], ],
parameters=ProcessingOpexParameters( parameters=OpexParameters(
currency_code="USD", currency_code="USD",
escalation_pct=5, escalation_pct=5,
discount_rate_pct=None, discount_rate_pct=None,
evaluation_horizon_years=2, evaluation_horizon_years=2,
apply_escalation=True, apply_escalation=True,
), ),
options=ProcessingOpexOptions(persist=True, snapshot_notes=None), options=OpexOptions(persist=True, snapshot_notes=None),
) )
result = calculate_processing_opex(request) result = calculate_opex(request)
assert result.currency == "USD" assert result.currency == "USD"
assert result.options.persist is True assert result.options.persist is True
@@ -89,10 +89,10 @@ def test_calculate_processing_opex_success():
assert result.components[1].frequency == "quarterly" assert result.components[1].frequency == "quarterly"
def test_calculate_processing_opex_currency_mismatch(): def test_calculate_opex_currency_mismatch():
request = ProcessingOpexCalculationRequest( request = OpexCalculationRequest(
components=[_component(currency="USD")], components=[_component(currency="USD")],
parameters=ProcessingOpexParameters( parameters=OpexParameters(
currency_code="CAD", currency_code="CAD",
escalation_pct=None, escalation_pct=None,
discount_rate_pct=None, discount_rate_pct=None,
@@ -101,16 +101,16 @@ def test_calculate_processing_opex_currency_mismatch():
) )
with pytest.raises(OpexValidationError) as exc: with pytest.raises(OpexValidationError) as exc:
calculate_processing_opex(request) calculate_opex(request)
assert "Component currency does not match" in exc.value.message 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] assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
def test_calculate_processing_opex_unsupported_frequency(): def test_calculate_opex_unsupported_frequency():
request = ProcessingOpexCalculationRequest( request = OpexCalculationRequest(
components=[_component(frequency="biweekly")], components=[_component(frequency="biweekly")],
parameters=ProcessingOpexParameters( parameters=OpexParameters(
currency_code="USD", currency_code="USD",
escalation_pct=None, escalation_pct=None,
discount_rate_pct=None, discount_rate_pct=None,
@@ -119,28 +119,28 @@ def test_calculate_processing_opex_unsupported_frequency():
) )
with pytest.raises(OpexValidationError) as exc: with pytest.raises(OpexValidationError) as exc:
calculate_processing_opex(request) calculate_opex(request)
assert "Unsupported frequency" in exc.value.message assert "Unsupported frequency" in exc.value.message
assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0] assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0]
def test_calculate_processing_opex_requires_components(): def test_calculate_opex_requires_components():
request = ProcessingOpexCalculationRequest(components=[]) request = OpexCalculationRequest(components=[])
with pytest.raises(OpexValidationError) as exc: with pytest.raises(OpexValidationError) as exc:
calculate_processing_opex(request) calculate_opex(request)
assert "At least one processing opex component" in exc.value.message assert "At least one opex component" in exc.value.message
assert exc.value.field_errors and "components" in exc.value.field_errors[0] assert exc.value.field_errors and "components" in exc.value.field_errors[0]
def test_calculate_processing_opex_extends_evaluation_horizon(): def test_calculate_opex_extends_evaluation_horizon():
request = ProcessingOpexCalculationRequest( request = OpexCalculationRequest(
components=[ components=[
_component(period_start=1, period_end=4), _component(period_start=1, period_end=4),
], ],
parameters=ProcessingOpexParameters( parameters=OpexParameters(
currency_code="USD", currency_code="USD",
discount_rate_pct=0, discount_rate_pct=0,
escalation_pct=0, escalation_pct=0,
@@ -149,7 +149,7 @@ def test_calculate_processing_opex_extends_evaluation_horizon():
), ),
) )
result = calculate_processing_opex(request) result = calculate_opex(request)
assert len(result.timeline) == 4 assert len(result.timeline) == 4
assert result.timeline[-1].period == 4 assert result.timeline[-1].period == 4

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from models import MiningOperationType
class TestDashboardRoute: class TestDashboardRoute:
def test_renders_empty_state(self, client: TestClient) -> None: def test_renders_empty_state(self, client: TestClient) -> None:
@@ -17,9 +19,18 @@ class TestDashboardRoute:
class TestProjectUIRoutes: class TestProjectUIRoutes:
def test_projects_ui_page_resolves(self, client: TestClient) -> None: 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") response = client.get("/projects/ui")
assert response.status_code == 200 assert response.status_code == 200
assert "Projects" in response.text assert "Projects" in response.text
assert "project-card" in response.text
def test_projects_create_form_resolves(self, client: TestClient) -> None: def test_projects_create_form_resolves(self, client: TestClient) -> None:
response = client.get("/projects/create") response = client.get("/projects/create")

View File

@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
import html
from fastapi import status
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from models import MiningOperationType, ResourceType, ScenarioStatus from models import MiningOperationType, ResourceType, ScenarioStatus
@@ -17,6 +20,32 @@ def _create_project(client: TestClient, name: str = "Alpha Project") -> dict:
return response.json() 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: def test_project_crud_cycle(client: TestClient) -> None:
project = _create_project(client) project = _create_project(client)
project_id = project["id"] project_id = project["id"]
@@ -29,10 +58,13 @@ def test_project_crud_cycle(client: TestClient) -> None:
project_ids = {item["id"] for item in list_response.json()} project_ids = {item["id"] for item in list_response.json()}
assert project_id in project_ids assert project_id in project_ids
update_payload = {"description": "Updated project description", "location": "Peru"} update_payload = {
update_response = client.put(f"/projects/{project_id}", json=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.status_code == 200
assert update_response.json()["description"] == "Updated project description" assert update_response.json(
)["description"] == "Updated project description"
assert update_response.json()["location"] == "Peru" assert update_response.json()["location"] == "Peru"
delete_response = client.delete(f"/projects/{project_id}") delete_response = client.delete(f"/projects/{project_id}")
@@ -97,8 +129,10 @@ def test_scenario_crud_cycle(client: TestClient) -> None:
listed_ids = {item["id"] for item in list_response.json()} listed_ids = {item["id"] for item in list_response.json()}
assert scenario_id in listed_ids assert scenario_id in listed_ids
update_payload = {"description": "Revised assumptions", "status": ScenarioStatus.ACTIVE.value} update_payload = {"description": "Revised assumptions",
update_response = client.put(f"/scenarios/{scenario_id}", json=update_payload) "status": ScenarioStatus.ACTIVE.value}
update_response = client.put(
f"/scenarios/{scenario_id}", json=update_payload)
assert update_response.status_code == 200 assert update_response.status_code == 200
updated = update_response.json() updated = update_response.json()
assert updated["description"] == "Revised assumptions" assert updated["description"] == "Revised assumptions"
@@ -125,10 +159,12 @@ def test_create_scenario_conflict_returns_409(client: TestClient) -> None:
project_id = project["id"] project_id = project["id"]
payload = {"name": "Duplicate Scenario"} payload = {"name": "Duplicate Scenario"}
first_response = client.post(f"/projects/{project_id}/scenarios", json=payload) first_response = client.post(
f"/projects/{project_id}/scenarios", json=payload)
assert first_response.status_code == 201 assert first_response.status_code == 201
conflict_response = client.post(f"/projects/{project_id}/scenarios", json=payload) conflict_response = client.post(
f"/projects/{project_id}/scenarios", json=payload)
assert conflict_response.status_code == 409 assert conflict_response.status_code == 409
assert "constraints" in conflict_response.json()["detail"].lower() assert "constraints" in conflict_response.json()["detail"].lower()
@@ -150,3 +186,152 @@ def test_create_scenario_invalid_currency_returns_422(client: TestClient) -> Non
def test_list_scenarios_missing_project_returns_404(client: TestClient) -> None: def test_list_scenarios_missing_project_returns_404(client: TestClient) -> None:
response = client.get("/projects/424242/scenarios") response = client.get("/projects/424242/scenarios")
assert response.status_code == 404 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