feat: add scenarios list page with metrics and quick actions
- Introduced a new template for listing scenarios associated with a project. - Added metrics for total, active, draft, and archived scenarios. - Implemented quick actions for creating new scenarios and reviewing project overview. - Enhanced navigation with breadcrumbs for better user experience. refactor: update Opex and Profitability templates for consistency - Changed titles and button labels for clarity in Opex and Profitability templates. - Updated form IDs and action URLs for better alignment with new naming conventions. - Improved navigation links to include scenario and project overviews. test: add integration tests for Opex calculations - Created new tests for Opex calculation HTML and JSON flows. - Validated successful calculations and ensured correct data persistence. - Implemented tests for currency mismatch and unsupported frequency scenarios. test: enhance project and scenario route tests - Added tests to verify scenario list rendering and calculator shortcuts. - Ensured scenario detail pages link back to the portfolio correctly. - Validated project detail pages show associated scenarios accurately.
This commit is contained in:
@@ -8,6 +8,6 @@ The system is designed to help mining companies make informed decisions by simul
|
||||
|
||||
## Documentation & quickstart
|
||||
|
||||
- Detailed developer, architecture, and operations guides live in the companion [calminer-docs](../calminer-docs/) repository.
|
||||
- Detailed developer, architecture, and operations guides live in the companion [calminer-docs](../calminer-docs/) repository. Please see the [README](../calminer-docs/README.md) there for instructions.
|
||||
- For a local run, create a `.env` (see `.env.example`), install requirements, then execute `python -m scripts.init_db` followed by `uvicorn main:app --reload`. The initializer is safe to rerun and seeds demo data automatically.
|
||||
- To wipe and recreate the schema in development, run `CALMINER_ENV=development python -m scripts.reset_db` before invoking the initializer again.
|
||||
|
||||
BIN
alembic_test.db
BIN
alembic_test.db
Binary file not shown.
18
changelog.md
18
changelog.md
@@ -2,14 +2,18 @@
|
||||
|
||||
## 2025-11-13
|
||||
|
||||
- Finalised the financial data import/export templates by inventorying required fields, defining CSV column specs with validation rules, drafting Excel workbook layouts, documenting end-user workflows in `calminer-docs/userguide/data_import_export.md`, and recording stakeholder review steps alongside updated TODO/DONE tracking.
|
||||
- Scoped profitability calculator UI under the scenario hierarchy by adding `/calculations/projects/{project_id}/scenarios/{scenario_id}/profitability` GET/POST handlers, updating scenario templates and sidebar navigation to link to the new route, and extending `tests/test_project_scenario_routes.py` with coverage for the scenario path plus legacy redirect behaviour (module run: 14 passed).
|
||||
- Extended scenario frontend regression coverage by updating `tests/test_project_scenario_routes.py` to assert project/scenario breadcrumbs and calculator navigation, normalising escaped URLs, and re-running the module tests (13 passing).
|
||||
- Cleared FastAPI and Pydantic deprecation warnings by migrating `scripts/init_db.py` to `@field_validator`, replacing the `main.py` startup hook with a lifespan handler, auditing template response call signatures, confirming HTTP 422 constant usage, and re-running the full pytest suite to ensure a clean warning slate.
|
||||
- Delivered the 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`.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the processing opex documentation updates.
|
||||
- Delivered the capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`.
|
||||
- Updated UI navigation to surface the opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`.
|
||||
- Completed manual validation of the Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up.
|
||||
- Added opex calculation unit tests in `tests/services/test_calculations_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension.
|
||||
- Documented the Opex Planner workflow in `calminer-docs/userguide/opex_planner.md`, linked it from the user guide index, extended `calminer-docs/architecture/08_concepts/02_data_model.md` with snapshot coverage, and captured the completion in `.github/instructions/DONE.md`.
|
||||
- Implemented opex integration coverage in `tests/integration/test_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions.
|
||||
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the opex documentation updates.
|
||||
- Completed the navigation sidebar API migration by finalising the database-backed service, refactoring `templates/partials/sidebar_nav.html` to consume the endpoint, hydrating via `static/js/navigation_sidebar.js`, and updating HTML route dependencies (`routes/projects.py`, `routes/scenarios.py`, `routes/reports.py`, `routes/imports.py`, `routes/calculations.py`) to use redirect-aware guards so anonymous visitors receive login redirects instead of JSON errors (manual verification via curl across projects, scenarios, reports, and calculations pages).
|
||||
|
||||
## 2025-11-12
|
||||
|
||||
|
||||
131
dependencies.py
131
dependencies.py
@@ -23,6 +23,7 @@ from services.session import (
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from services.importers import ImportIngestionService
|
||||
from services.pricing import PricingMetadata
|
||||
from services.navigation import NavigationService
|
||||
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||
from services.repositories import pricing_settings_to_metadata
|
||||
|
||||
@@ -64,6 +65,14 @@ def get_pricing_metadata(
|
||||
return pricing_settings_to_metadata(seed_result.settings)
|
||||
|
||||
|
||||
def get_navigation_service(
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> NavigationService:
|
||||
if not uow.navigation:
|
||||
raise RuntimeError("Navigation repository is not initialised")
|
||||
return NavigationService(uow.navigation)
|
||||
|
||||
|
||||
def get_pricing_evaluator(
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ScenarioPricingEvaluator:
|
||||
@@ -153,6 +162,28 @@ def require_authenticated_user(
|
||||
return user
|
||||
|
||||
|
||||
def require_authenticated_user_html(
|
||||
request: Request,
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
) -> User:
|
||||
"""HTML-aware authenticated dependency that redirects anonymous sessions."""
|
||||
|
||||
user = session.user
|
||||
if user is None or session.tokens.is_empty:
|
||||
login_url = str(request.url_for("auth.login_form"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
headers={"Location": login_url},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled.",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _user_role_names(user: User) -> set[str]:
|
||||
roles: Iterable[Role] = getattr(user, "roles", []) or []
|
||||
return {role.name for role in roles}
|
||||
@@ -186,12 +217,55 @@ def require_any_role(*roles: str) -> Callable[[User], User]:
|
||||
return require_roles(*roles)
|
||||
|
||||
|
||||
def require_project_resource(*, require_manage: bool = False) -> Callable[[int], Project]:
|
||||
def require_roles_html(*roles: str) -> Callable[[Request], User]:
|
||||
"""Ensure user is authenticated for HTML responses; redirect anonymous to login."""
|
||||
|
||||
required = tuple(role.strip() for role in roles if role.strip())
|
||||
if not required:
|
||||
raise ValueError("require_roles_html requires at least one role name")
|
||||
|
||||
def _dependency(
|
||||
request: Request,
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
) -> User:
|
||||
user = session.user
|
||||
if user is None:
|
||||
login_url = str(request.url_for("auth.login_form"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
headers={"Location": login_url},
|
||||
)
|
||||
|
||||
if user.is_superuser:
|
||||
return user
|
||||
|
||||
role_names = _user_role_names(user)
|
||||
if not any(role in role_names for role in required):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this action.",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_any_role_html(*roles: str) -> Callable[[Request], User]:
|
||||
"""Alias of require_roles_html for readability."""
|
||||
|
||||
return require_roles_html(*roles)
|
||||
|
||||
|
||||
def require_project_resource(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int], Project]:
|
||||
"""Dependency factory that resolves a project with authorization checks."""
|
||||
|
||||
def _dependency(
|
||||
project_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Project:
|
||||
try:
|
||||
@@ -216,13 +290,16 @@ def require_project_resource(*, require_manage: bool = False) -> Callable[[int],
|
||||
|
||||
|
||||
def require_scenario_resource(
|
||||
*, require_manage: bool = False, with_children: bool = False
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int], Scenario]:
|
||||
"""Dependency factory that resolves a scenario with authorization checks."""
|
||||
|
||||
def _dependency(
|
||||
scenario_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Scenario:
|
||||
try:
|
||||
@@ -248,14 +325,17 @@ def require_scenario_resource(
|
||||
|
||||
|
||||
def require_project_scenario_resource(
|
||||
*, require_manage: bool = False, with_children: bool = False
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int, int], Scenario]:
|
||||
"""Dependency factory ensuring a scenario belongs to the given project and is accessible."""
|
||||
|
||||
def _dependency(
|
||||
project_id: int,
|
||||
scenario_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Scenario:
|
||||
try:
|
||||
@@ -279,3 +359,42 @@ def require_project_scenario_resource(
|
||||
) from exc
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_project_resource_html(
|
||||
*, require_manage: bool = False
|
||||
) -> Callable[[int], Project]:
|
||||
"""HTML-aware project loader that redirects anonymous sessions."""
|
||||
|
||||
return require_project_resource(
|
||||
require_manage=require_manage,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
|
||||
def require_scenario_resource_html(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
) -> Callable[[int], Scenario]:
|
||||
"""HTML-aware scenario loader that redirects anonymous sessions."""
|
||||
|
||||
return require_scenario_resource(
|
||||
require_manage=require_manage,
|
||||
with_children=with_children,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
|
||||
def require_project_scenario_resource_html(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
) -> Callable[[int, int], Scenario]:
|
||||
"""HTML-aware project-scenario loader redirecting anonymous sessions."""
|
||||
|
||||
return require_project_scenario_resource(
|
||||
require_manage=require_manage,
|
||||
with_children=with_children,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
2
main.py
2
main.py
@@ -19,6 +19,7 @@ from routes.projects import router as projects_router
|
||||
from routes.reports import router as reports_router
|
||||
from routes.scenarios import router as scenarios_router
|
||||
from routes.ui import router as ui_router
|
||||
from routes.navigation import router as navigation_router
|
||||
from monitoring import router as monitoring_router
|
||||
from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
|
||||
from scripts.init_db import init_db as init_db_script
|
||||
@@ -113,5 +114,6 @@ app.include_router(scenarios_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(ui_router)
|
||||
app.include_router(monitoring_router)
|
||||
app.include_router(navigation_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -145,6 +145,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
session.user = user
|
||||
session.scopes = tuple(payload.scopes)
|
||||
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
|
||||
return True
|
||||
|
||||
def _try_refresh_token(
|
||||
@@ -166,6 +167,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
session.user = user
|
||||
session.scopes = tuple(payload.scopes)
|
||||
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
|
||||
|
||||
access_token = create_access_token(
|
||||
str(user.id),
|
||||
|
||||
@@ -27,11 +27,13 @@ from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .simulation_parameter import SimulationParameter
|
||||
from .user import Role, User, UserRole, password_context
|
||||
from .navigation import NavigationGroup, NavigationLink
|
||||
|
||||
from .profitability_snapshot import ProjectProfitability, ScenarioProfitability
|
||||
from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot
|
||||
from .processing_opex_snapshot import (
|
||||
ProjectProcessingOpexSnapshot,
|
||||
ScenarioProcessingOpexSnapshot,
|
||||
from .opex_snapshot import (
|
||||
ProjectOpexSnapshot,
|
||||
ScenarioOpexSnapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -41,14 +43,14 @@ __all__ = [
|
||||
"Project",
|
||||
"ProjectProfitability",
|
||||
"ProjectCapexSnapshot",
|
||||
"ProjectProcessingOpexSnapshot",
|
||||
"ProjectOpexSnapshot",
|
||||
"PricingSettings",
|
||||
"PricingMetalSettings",
|
||||
"PricingImpuritySettings",
|
||||
"Scenario",
|
||||
"ScenarioProfitability",
|
||||
"ScenarioCapexSnapshot",
|
||||
"ScenarioProcessingOpexSnapshot",
|
||||
"ScenarioOpexSnapshot",
|
||||
"ScenarioStatus",
|
||||
"DistributionType",
|
||||
"SimulationParameter",
|
||||
@@ -65,4 +67,6 @@ __all__ = [
|
||||
"UserRole",
|
||||
"password_context",
|
||||
"PerformanceMetric",
|
||||
"NavigationGroup",
|
||||
"NavigationLink",
|
||||
]
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -64,7 +64,7 @@ class ProjectCapexSnapshot(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"
|
||||
|
||||
|
||||
125
models/navigation.py
Normal file
125
models/navigation.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.ext.mutable import MutableList
|
||||
from sqlalchemy import JSON
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class NavigationGroup(Base):
|
||||
__tablename__ = "navigation_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slug", name="uq_navigation_groups_slug"),
|
||||
Index("ix_navigation_groups_sort_order", "sort_order"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
tooltip: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
links: Mapped[List["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
back_populates="group",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="NavigationLink.sort_order",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"NavigationGroup(id={self.id!r}, slug={self.slug!r})"
|
||||
|
||||
|
||||
class NavigationLink(Base):
|
||||
__tablename__ = "navigation_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "slug",
|
||||
name="uq_navigation_links_group_slug"),
|
||||
Index("ix_navigation_links_group_sort", "group_id", "sort_order"),
|
||||
Index("ix_navigation_links_parent_sort",
|
||||
"parent_link_id", "sort_order"),
|
||||
CheckConstraint(
|
||||
"(route_name IS NOT NULL) OR (href_override IS NOT NULL)",
|
||||
name="ck_navigation_links_route_or_href",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("navigation_groups.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
parent_link_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("navigation_links.id", ondelete="CASCADE")
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
route_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
href_override: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
match_prefix: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
tooltip: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
required_roles: Mapped[list[str]] = mapped_column(
|
||||
MutableList.as_mutable(JSON), nullable=False, default=list
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
is_external: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
group: Mapped[NavigationGroup] = relationship(
|
||||
NavigationGroup,
|
||||
back_populates="links",
|
||||
)
|
||||
parent: Mapped[Optional["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
remote_side="NavigationLink.id",
|
||||
back_populates="children",
|
||||
)
|
||||
children: Mapped[List["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
back_populates="parent",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="NavigationLink.sort_order",
|
||||
)
|
||||
|
||||
def is_visible_for_roles(self, roles: list[str]) -> bool:
|
||||
if not self.required_roles:
|
||||
return True
|
||||
role_set = set(roles)
|
||||
return any(role in role_set for role in self.required_roles)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"NavigationLink(id={self.id!r}, slug={self.slug!r})"
|
||||
@@ -15,10 +15,10 @@ if TYPE_CHECKING: # pragma: no cover
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectProcessingOpexSnapshot(Base):
|
||||
"""Snapshot of recurring processing opex metrics at the project level."""
|
||||
class ProjectOpexSnapshot(Base):
|
||||
"""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)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
@@ -27,17 +27,24 @@ class ProjectProcessingOpexSnapshot(Base):
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@@ -48,13 +55,13 @@ class ProjectProcessingOpexSnapshot(Base):
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="processing_opex_snapshots"
|
||||
"Project", back_populates="opex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
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,
|
||||
project_id=self.project_id,
|
||||
overall_annual=self.overall_annual,
|
||||
@@ -62,10 +69,10 @@ class ProjectProcessingOpexSnapshot(Base):
|
||||
)
|
||||
|
||||
|
||||
class ScenarioProcessingOpexSnapshot(Base):
|
||||
"""Snapshot of processing opex metrics for an individual scenario."""
|
||||
class ScenarioOpexSnapshot(Base):
|
||||
"""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)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
@@ -74,17 +81,24 @@ class ScenarioProcessingOpexSnapshot(Base):
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
@@ -95,13 +109,13 @@ class ScenarioProcessingOpexSnapshot(Base):
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="processing_opex_snapshots"
|
||||
"Scenario", back_populates="opex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
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,
|
||||
scenario_id=self.scenario_id,
|
||||
overall_annual=self.overall_annual,
|
||||
@@ -43,13 +43,13 @@ class ProjectProfitability(Base):
|
||||
Numeric(12, 6), nullable=True)
|
||||
revenue_total: Mapped[float | None] = mapped_column(
|
||||
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
|
||||
)
|
||||
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
initial_capex: Mapped[float | None] = mapped_column(
|
||||
capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
@@ -102,13 +102,13 @@ class ScenarioProfitability(Base):
|
||||
Numeric(12, 6), nullable=True)
|
||||
revenue_total: Mapped[float | None] = mapped_column(
|
||||
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
|
||||
)
|
||||
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
initial_capex: Mapped[float | None] = mapped_column(
|
||||
capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, List
|
||||
from .enums import MiningOperationType, sql_enum
|
||||
from .profitability_snapshot import ProjectProfitability
|
||||
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.orm import Mapped, mapped_column, relationship
|
||||
@@ -68,11 +68,11 @@ class Project(Base):
|
||||
order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
processing_opex_snapshots: Mapped[List["ProjectProcessingOpexSnapshot"]] = relationship(
|
||||
"ProjectProcessingOpexSnapshot",
|
||||
opex_snapshots: Mapped[List["ProjectOpexSnapshot"]] = relationship(
|
||||
"ProjectOpexSnapshot",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectProcessingOpexSnapshot.calculated_at.desc(),
|
||||
order_by=lambda: ProjectOpexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@@ -93,12 +93,12 @@ class Project(Base):
|
||||
return self.capex_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_processing_opex(self) -> "ProjectProcessingOpexSnapshot | None":
|
||||
"""Return the most recent processing opex snapshot, if any."""
|
||||
def latest_opex(self) -> "ProjectOpexSnapshot | None":
|
||||
"""Return the most recent opex snapshot, if any."""
|
||||
|
||||
if not self.processing_opex_snapshots:
|
||||
if not self.opex_snapshots:
|
||||
return None
|
||||
return self.processing_opex_snapshots[0]
|
||||
return self.opex_snapshots[0]
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
||||
return f"Project(id={self.id!r}, name={self.name!r})"
|
||||
|
||||
@@ -21,7 +21,7 @@ from services.currency import normalise_currency
|
||||
from .enums import ResourceType, ScenarioStatus, sql_enum
|
||||
from .profitability_snapshot import ScenarioProfitability
|
||||
from .capex_snapshot import ScenarioCapexSnapshot
|
||||
from .processing_opex_snapshot import ScenarioProcessingOpexSnapshot
|
||||
from .opex_snapshot import ScenarioOpexSnapshot
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .financial_input import FinancialInput
|
||||
@@ -92,11 +92,11 @@ class Scenario(Base):
|
||||
order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
processing_opex_snapshots: Mapped[List["ScenarioProcessingOpexSnapshot"]] = relationship(
|
||||
"ScenarioProcessingOpexSnapshot",
|
||||
opex_snapshots: Mapped[List["ScenarioOpexSnapshot"]] = relationship(
|
||||
"ScenarioOpexSnapshot",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioProcessingOpexSnapshot.calculated_at.desc(),
|
||||
order_by=lambda: ScenarioOpexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@@ -125,9 +125,9 @@ class Scenario(Base):
|
||||
return self.capex_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_processing_opex(self) -> "ScenarioProcessingOpexSnapshot | None":
|
||||
"""Return the most recent processing opex snapshot for this scenario."""
|
||||
def latest_opex(self) -> "ScenarioOpexSnapshot | None":
|
||||
"""Return the most recent opex snapshot for this scenario."""
|
||||
|
||||
if not self.processing_opex_snapshots:
|
||||
if not self.opex_snapshots:
|
||||
return None
|
||||
return self.processing_opex_snapshots[0]
|
||||
return self.opex_snapshots[0]
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any, Iterable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import ValidationError
|
||||
from starlette.datastructures import FormData
|
||||
|
||||
@@ -43,9 +42,10 @@ from services.session import (
|
||||
)
|
||||
from services.repositories import RoleRepository, UserRepository
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(tags=["Authentication"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
_PASSWORD_RESET_SCOPE = "password-reset"
|
||||
_AUTH_SCOPE = "auth"
|
||||
|
||||
@@ -6,20 +6,25 @@ from decimal import Decimal
|
||||
from typing import Any, Sequence
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
|
||||
from pydantic import ValidationError
|
||||
from starlette.datastructures import FormData
|
||||
from starlette.routing import NoMatchFound
|
||||
|
||||
from dependencies import get_pricing_metadata, get_unit_of_work, require_authenticated_user
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_authenticated_user,
|
||||
require_authenticated_user_html,
|
||||
)
|
||||
from models import (
|
||||
Project,
|
||||
ProjectCapexSnapshot,
|
||||
ProjectProcessingOpexSnapshot,
|
||||
ProjectOpexSnapshot,
|
||||
ProjectProfitability,
|
||||
Scenario,
|
||||
ScenarioCapexSnapshot,
|
||||
ScenarioProcessingOpexSnapshot,
|
||||
ScenarioOpexSnapshot,
|
||||
ScenarioProfitability,
|
||||
User,
|
||||
)
|
||||
@@ -29,17 +34,17 @@ from schemas.calculations import (
|
||||
CapexCalculationResult,
|
||||
CapexComponentInput,
|
||||
CapexParameters,
|
||||
ProcessingOpexCalculationRequest,
|
||||
ProcessingOpexCalculationResult,
|
||||
ProcessingOpexComponentInput,
|
||||
ProcessingOpexOptions,
|
||||
ProcessingOpexParameters,
|
||||
OpexCalculationRequest,
|
||||
OpexCalculationResult,
|
||||
OpexComponentInput,
|
||||
OpexOptions,
|
||||
OpexParameters,
|
||||
ProfitabilityCalculationRequest,
|
||||
ProfitabilityCalculationResult,
|
||||
)
|
||||
from services.calculations import (
|
||||
calculate_initial_capex,
|
||||
calculate_processing_opex,
|
||||
calculate_opex,
|
||||
calculate_profitability,
|
||||
)
|
||||
from services.exceptions import (
|
||||
@@ -50,11 +55,10 @@ from services.exceptions import (
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import register_common_filters
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/calculations", tags=["Calculations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
register_common_filters(templates)
|
||||
templates = create_templates()
|
||||
|
||||
_SUPPORTED_METALS: tuple[dict[str, str], ...] = (
|
||||
{"value": "copper", "label": "Copper"},
|
||||
@@ -90,7 +94,7 @@ _OPEX_FREQUENCY_OPTIONS: tuple[dict[str, str], ...] = (
|
||||
|
||||
_DEFAULT_OPEX_HORIZON_YEARS = 5
|
||||
|
||||
_PROCESSING_OPEX_TEMPLATE = "scenarios/opex.html"
|
||||
_opex_TEMPLATE = "scenarios/opex.html"
|
||||
|
||||
|
||||
def _combine_impurity_metadata(metadata: PricingMetadata) -> list[dict[str, object]]:
|
||||
@@ -196,7 +200,7 @@ def _build_default_form_data(
|
||||
"reference_price": "",
|
||||
"treatment_charge": "",
|
||||
"smelting_charge": "",
|
||||
"processing_opex": "",
|
||||
"opex": "",
|
||||
"moisture_pct": "",
|
||||
"moisture_threshold_pct": moisture_threshold_default,
|
||||
"moisture_penalty_per_pct": moisture_penalty_default,
|
||||
@@ -204,7 +208,7 @@ def _build_default_form_data(
|
||||
"fx_rate": 1.0,
|
||||
"currency_code": currency,
|
||||
"impurities": None,
|
||||
"initial_capex": "",
|
||||
"capex": "",
|
||||
"sustaining_capex": "",
|
||||
"discount_rate": discount_rate,
|
||||
"periods": _DEFAULT_EVALUATION_PERIODS,
|
||||
@@ -380,6 +384,12 @@ def _prepare_capex_context(
|
||||
currency_code = parameters.get(
|
||||
"currency_code") or defaults["currency_code"]
|
||||
|
||||
navigation = _resolve_navigation_links(
|
||||
request,
|
||||
project=project,
|
||||
scenario=scenario,
|
||||
)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"project": project,
|
||||
@@ -396,14 +406,14 @@ def _prepare_capex_context(
|
||||
"notices": notices or [],
|
||||
"component_errors": component_errors or [],
|
||||
"component_notices": component_notices or [],
|
||||
"cancel_url": request.headers.get("Referer"),
|
||||
"form_action": request.url.path,
|
||||
"form_action": str(request.url),
|
||||
"csrf_token": None,
|
||||
**navigation,
|
||||
}
|
||||
|
||||
|
||||
def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
|
||||
if isinstance(component, ProcessingOpexComponentInput):
|
||||
if isinstance(component, OpexComponentInput):
|
||||
raw = component.model_dump()
|
||||
elif isinstance(component, dict):
|
||||
raw = dict(component)
|
||||
@@ -436,7 +446,7 @@ def _serialise_opex_component_entry(component: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
|
||||
if isinstance(parameters, ProcessingOpexParameters):
|
||||
if isinstance(parameters, OpexParameters):
|
||||
raw = parameters.model_dump()
|
||||
elif isinstance(parameters, dict):
|
||||
raw = dict(parameters)
|
||||
@@ -455,7 +465,7 @@ def _serialise_opex_parameters(parameters: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _serialise_opex_options(options: Any) -> dict[str, Any]:
|
||||
if isinstance(options, ProcessingOpexOptions):
|
||||
if isinstance(options, OpexOptions):
|
||||
raw = options.model_dump()
|
||||
elif isinstance(options, dict):
|
||||
raw = dict(options)
|
||||
@@ -511,7 +521,7 @@ def _prepare_opex_context(
|
||||
project: Project | None,
|
||||
scenario: Scenario | None,
|
||||
form_data: dict[str, Any] | None = None,
|
||||
result: ProcessingOpexCalculationResult | None = None,
|
||||
result: OpexCalculationResult | None = None,
|
||||
errors: list[str] | None = None,
|
||||
notices: list[str] | None = None,
|
||||
component_errors: list[str] | None = None,
|
||||
@@ -544,6 +554,12 @@ def _prepare_opex_context(
|
||||
currency_code = parameters.get(
|
||||
"currency_code") or defaults["currency_code"]
|
||||
|
||||
navigation = _resolve_navigation_links(
|
||||
request,
|
||||
project=project,
|
||||
scenario=scenario,
|
||||
)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"project": project,
|
||||
@@ -561,9 +577,9 @@ def _prepare_opex_context(
|
||||
"notices": notices or [],
|
||||
"component_errors": component_errors or [],
|
||||
"component_notices": component_notices or [],
|
||||
"cancel_url": request.headers.get("Referer"),
|
||||
"form_action": request.url.path,
|
||||
"form_action": str(request.url),
|
||||
"csrf_token": None,
|
||||
**navigation,
|
||||
}
|
||||
|
||||
|
||||
@@ -758,6 +774,76 @@ async def _extract_capex_payload(request: Request) -> dict[str, Any]:
|
||||
return _capex_form_to_payload(form)
|
||||
|
||||
|
||||
def _resolve_navigation_links(
|
||||
request: Request,
|
||||
*,
|
||||
project: Project | None,
|
||||
scenario: Scenario | None,
|
||||
) -> dict[str, str | None]:
|
||||
project_url: str | None = None
|
||||
scenario_url: str | None = None
|
||||
scenario_portfolio_url: str | None = None
|
||||
|
||||
candidate_project = project
|
||||
if scenario is not None and getattr(scenario, "id", None) is not None:
|
||||
try:
|
||||
scenario_url = str(
|
||||
request.url_for(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
)
|
||||
)
|
||||
except NoMatchFound:
|
||||
scenario_url = None
|
||||
|
||||
try:
|
||||
scenario_portfolio_url = str(
|
||||
request.url_for(
|
||||
"scenarios.project_scenario_list",
|
||||
project_id=scenario.project_id,
|
||||
)
|
||||
)
|
||||
except NoMatchFound:
|
||||
scenario_portfolio_url = None
|
||||
|
||||
if candidate_project is None:
|
||||
candidate_project = getattr(scenario, "project", None)
|
||||
|
||||
if candidate_project is not None and getattr(candidate_project, "id", None) is not None:
|
||||
try:
|
||||
project_url = str(
|
||||
request.url_for(
|
||||
"projects.view_project", project_id=candidate_project.id
|
||||
)
|
||||
)
|
||||
except NoMatchFound:
|
||||
project_url = None
|
||||
|
||||
if scenario_portfolio_url is None:
|
||||
try:
|
||||
scenario_portfolio_url = str(
|
||||
request.url_for(
|
||||
"scenarios.project_scenario_list",
|
||||
project_id=candidate_project.id,
|
||||
)
|
||||
)
|
||||
except NoMatchFound:
|
||||
scenario_portfolio_url = None
|
||||
|
||||
cancel_url = scenario_url or project_url or request.headers.get("Referer")
|
||||
if cancel_url is None:
|
||||
try:
|
||||
cancel_url = str(request.url_for("projects.project_list_page"))
|
||||
except NoMatchFound:
|
||||
cancel_url = "/"
|
||||
|
||||
return {
|
||||
"project_url": project_url,
|
||||
"scenario_url": scenario_url,
|
||||
"scenario_portfolio_url": scenario_portfolio_url,
|
||||
"cancel_url": cancel_url,
|
||||
}
|
||||
|
||||
|
||||
def _prepare_default_context(
|
||||
request: Request,
|
||||
*,
|
||||
@@ -781,6 +867,12 @@ def _prepare_default_context(
|
||||
allow_empty_override=allow_empty_override,
|
||||
)
|
||||
|
||||
navigation = _resolve_navigation_links(
|
||||
request,
|
||||
project=project,
|
||||
scenario=scenario,
|
||||
)
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"project": project,
|
||||
@@ -792,10 +884,10 @@ def _prepare_default_context(
|
||||
"result": result,
|
||||
"errors": [],
|
||||
"notices": [],
|
||||
"cancel_url": request.headers.get("Referer"),
|
||||
"form_action": request.url.path,
|
||||
"form_action": str(request.url),
|
||||
"csrf_token": None,
|
||||
"default_periods": _DEFAULT_EVALUATION_PERIODS,
|
||||
**navigation,
|
||||
}
|
||||
|
||||
|
||||
@@ -920,11 +1012,11 @@ def _persist_profitability_snapshots(
|
||||
created_by_id = getattr(user, "id", None)
|
||||
|
||||
revenue_total = float(result.pricing.net_revenue)
|
||||
processing_total = float(result.costs.processing_opex_total)
|
||||
processing_total = float(result.costs.opex_total)
|
||||
sustaining_total = float(result.costs.sustaining_capex_total)
|
||||
initial_capex = float(result.costs.initial_capex)
|
||||
capex = float(result.costs.capex)
|
||||
net_cash_flow_total = revenue_total - (
|
||||
processing_total + sustaining_total + initial_capex
|
||||
processing_total + sustaining_total + capex
|
||||
)
|
||||
|
||||
npv_value = (
|
||||
@@ -964,9 +1056,9 @@ def _persist_profitability_snapshots(
|
||||
payback_period_years=payback_value,
|
||||
margin_pct=margin_value,
|
||||
revenue_total=revenue_total,
|
||||
processing_opex_total=processing_total,
|
||||
opex_total=processing_total,
|
||||
sustaining_capex_total=sustaining_total,
|
||||
initial_capex=initial_capex,
|
||||
capex=capex,
|
||||
net_cash_flow_total=net_cash_flow_total,
|
||||
payload=payload,
|
||||
)
|
||||
@@ -983,9 +1075,9 @@ def _persist_profitability_snapshots(
|
||||
payback_period_years=payback_value,
|
||||
margin_pct=margin_value,
|
||||
revenue_total=revenue_total,
|
||||
processing_opex_total=processing_total,
|
||||
opex_total=processing_total,
|
||||
sustaining_capex_total=sustaining_total,
|
||||
initial_capex=initial_capex,
|
||||
capex=capex,
|
||||
net_cash_flow_total=net_cash_flow_total,
|
||||
payload=payload,
|
||||
)
|
||||
@@ -1067,7 +1159,7 @@ def _should_persist_opex(
|
||||
*,
|
||||
project: Project | None,
|
||||
scenario: Scenario | None,
|
||||
request_model: ProcessingOpexCalculationRequest,
|
||||
request_model: OpexCalculationRequest,
|
||||
) -> bool:
|
||||
persist_requested = bool(
|
||||
getattr(request_model, "options", None)
|
||||
@@ -1082,8 +1174,8 @@ def _persist_opex_snapshots(
|
||||
project: Project | None,
|
||||
scenario: Scenario | None,
|
||||
user: User | None,
|
||||
request_model: ProcessingOpexCalculationRequest,
|
||||
result: ProcessingOpexCalculationResult,
|
||||
request_model: OpexCalculationRequest,
|
||||
result: OpexCalculationResult,
|
||||
) -> None:
|
||||
if not _should_persist_opex(
|
||||
project=project,
|
||||
@@ -1130,11 +1222,11 @@ def _persist_opex_snapshots(
|
||||
"result": result.model_dump(),
|
||||
}
|
||||
|
||||
if scenario and uow.scenario_processing_opex:
|
||||
scenario_snapshot = ScenarioProcessingOpexSnapshot(
|
||||
if scenario and uow.scenario_opex:
|
||||
scenario_snapshot = ScenarioOpexSnapshot(
|
||||
scenario_id=scenario.id,
|
||||
created_by_id=created_by_id,
|
||||
calculation_source="calculations.processing_opex",
|
||||
calculation_source="calculations.opex",
|
||||
currency_code=result.currency,
|
||||
overall_annual=overall_annual,
|
||||
escalated_total=escalated_total,
|
||||
@@ -1145,13 +1237,13 @@ def _persist_opex_snapshots(
|
||||
component_count=component_count,
|
||||
payload=payload,
|
||||
)
|
||||
uow.scenario_processing_opex.create(scenario_snapshot)
|
||||
uow.scenario_opex.create(scenario_snapshot)
|
||||
|
||||
if project and uow.project_processing_opex:
|
||||
project_snapshot = ProjectProcessingOpexSnapshot(
|
||||
if project and uow.project_opex:
|
||||
project_snapshot = ProjectOpexSnapshot(
|
||||
project_id=project.id,
|
||||
created_by_id=created_by_id,
|
||||
calculation_source="calculations.processing_opex",
|
||||
calculation_source="calculations.opex",
|
||||
currency_code=result.currency,
|
||||
overall_annual=overall_annual,
|
||||
escalated_total=escalated_total,
|
||||
@@ -1162,24 +1254,24 @@ def _persist_opex_snapshots(
|
||||
component_count=component_count,
|
||||
payload=payload,
|
||||
)
|
||||
uow.project_processing_opex.create(project_snapshot)
|
||||
uow.project_opex.create(project_snapshot)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/processing-opex",
|
||||
"/opex",
|
||||
response_class=HTMLResponse,
|
||||
name="calculations.processing_opex_form",
|
||||
name="calculations.opex_form",
|
||||
)
|
||||
def processing_opex_form(
|
||||
def opex_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_authenticated_user),
|
||||
_: User = Depends(require_authenticated_user_html),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
project_id: int | None = Query(
|
||||
None, description="Optional project identifier"),
|
||||
scenario_id: int | None = Query(
|
||||
None, description="Optional scenario identifier"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the processing opex planner with default context."""
|
||||
"""Render the opex planner with default context."""
|
||||
|
||||
project, scenario = _load_project_and_scenario(
|
||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||
@@ -1189,14 +1281,14 @@ def processing_opex_form(
|
||||
project=project,
|
||||
scenario=scenario,
|
||||
)
|
||||
return templates.TemplateResponse(_PROCESSING_OPEX_TEMPLATE, context)
|
||||
return templates.TemplateResponse(_opex_TEMPLATE, context)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/processing-opex",
|
||||
name="calculations.processing_opex_submit",
|
||||
"/opex",
|
||||
name="calculations.opex_submit",
|
||||
)
|
||||
async def processing_opex_submit(
|
||||
async def opex_submit(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_authenticated_user),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
@@ -1205,16 +1297,16 @@ async def processing_opex_submit(
|
||||
scenario_id: int | None = Query(
|
||||
None, description="Optional scenario identifier"),
|
||||
) -> Response:
|
||||
"""Handle processing opex submissions and respond with HTML or JSON."""
|
||||
"""Handle opex submissions and respond with HTML or JSON."""
|
||||
|
||||
wants_json = _is_json_request(request)
|
||||
payload_data = await _extract_opex_payload(request)
|
||||
|
||||
try:
|
||||
request_model = ProcessingOpexCalculationRequest.model_validate(
|
||||
request_model = OpexCalculationRequest.model_validate(
|
||||
payload_data
|
||||
)
|
||||
result = calculate_processing_opex(request_model)
|
||||
result = calculate_opex(request_model)
|
||||
except ValidationError as exc:
|
||||
if wants_json:
|
||||
return JSONResponse(
|
||||
@@ -1237,7 +1329,7 @@ async def processing_opex_submit(
|
||||
component_errors=component_errors,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
_PROCESSING_OPEX_TEMPLATE,
|
||||
_opex_TEMPLATE,
|
||||
context,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
@@ -1263,7 +1355,7 @@ async def processing_opex_submit(
|
||||
errors=errors,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
_PROCESSING_OPEX_TEMPLATE,
|
||||
_opex_TEMPLATE,
|
||||
context,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
@@ -1295,10 +1387,10 @@ async def processing_opex_submit(
|
||||
result=result,
|
||||
)
|
||||
notices = _list_from_context(context, "notices")
|
||||
notices.append("Processing opex calculation completed successfully.")
|
||||
notices.append("Opex calculation completed successfully.")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
_PROCESSING_OPEX_TEMPLATE,
|
||||
_opex_TEMPLATE,
|
||||
context,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -1311,14 +1403,14 @@ async def processing_opex_submit(
|
||||
)
|
||||
def capex_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_authenticated_user),
|
||||
_: User = Depends(require_authenticated_user_html),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
project_id: int | None = Query(
|
||||
None, description="Optional project identifier"),
|
||||
scenario_id: int | None = Query(
|
||||
None, description="Optional scenario identifier"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the initial capex planner template with defaults."""
|
||||
"""Render the capex planner template with defaults."""
|
||||
|
||||
project, scenario = _load_project_and_scenario(
|
||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||
@@ -1432,7 +1524,7 @@ async def capex_submit(
|
||||
result=result,
|
||||
)
|
||||
notices = _list_from_context(context, "notices")
|
||||
notices.append("Initial capex calculation completed successfully.")
|
||||
notices.append("Capex calculation completed successfully.")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"scenarios/capex.html",
|
||||
@@ -1441,26 +1533,35 @@ async def capex_submit(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/profitability",
|
||||
response_class=HTMLResponse,
|
||||
name="calculations.profitability_form",
|
||||
)
|
||||
def profitability_form(
|
||||
def _render_profitability_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_authenticated_user),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
project_id: int | None = Query(
|
||||
None, description="Optional project identifier"),
|
||||
scenario_id: int | None = Query(
|
||||
None, description="Optional scenario identifier"),
|
||||
) -> HTMLResponse:
|
||||
"""Render the profitability calculation form with default metadata."""
|
||||
|
||||
*,
|
||||
metadata: PricingMetadata,
|
||||
uow: UnitOfWork,
|
||||
project_id: int | None,
|
||||
scenario_id: int | None,
|
||||
allow_redirect: bool,
|
||||
) -> Response:
|
||||
project, scenario = _load_project_and_scenario(
|
||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||
)
|
||||
|
||||
if allow_redirect and scenario is not None and getattr(scenario, "id", None):
|
||||
target_project_id = project_id or getattr(scenario, "project_id", None)
|
||||
if target_project_id is None and getattr(scenario, "project", None) is not None:
|
||||
target_project_id = getattr(scenario.project, "id", None)
|
||||
|
||||
if target_project_id is not None:
|
||||
redirect_url = request.url_for(
|
||||
"calculations.profitability_form",
|
||||
project_id=target_project_id,
|
||||
scenario_id=scenario.id,
|
||||
)
|
||||
if redirect_url != str(request.url):
|
||||
return RedirectResponse(
|
||||
redirect_url, status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
|
||||
context = _prepare_default_context(
|
||||
request,
|
||||
project=project,
|
||||
@@ -1471,28 +1572,74 @@ def profitability_form(
|
||||
return templates.TemplateResponse("scenarios/profitability.html", context)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/profitability",
|
||||
name="calculations.profitability_submit",
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/{scenario_id}/profitability",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
name="calculations.profitability_form",
|
||||
)
|
||||
async def profitability_submit(
|
||||
def profitability_form_for_scenario(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_authenticated_user),
|
||||
project_id: int,
|
||||
scenario_id: int,
|
||||
_: User = Depends(require_authenticated_user_html),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Response:
|
||||
return _render_profitability_form(
|
||||
request,
|
||||
metadata=metadata,
|
||||
uow=uow,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
allow_redirect=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/profitability",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
def profitability_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_authenticated_user_html),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
project_id: int | None = Query(
|
||||
None, description="Optional project identifier"),
|
||||
None, description="Optional project identifier"
|
||||
),
|
||||
scenario_id: int | None = Query(
|
||||
None, description="Optional scenario identifier"),
|
||||
None, description="Optional scenario identifier"
|
||||
),
|
||||
) -> Response:
|
||||
"""Handle profitability calculations and return HTML or JSON."""
|
||||
"""Render the profitability calculation form with default metadata."""
|
||||
|
||||
return _render_profitability_form(
|
||||
request,
|
||||
metadata=metadata,
|
||||
uow=uow,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
allow_redirect=True,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_profitability_submission(
|
||||
request: Request,
|
||||
*,
|
||||
current_user: User,
|
||||
metadata: PricingMetadata,
|
||||
uow: UnitOfWork,
|
||||
project_id: int | None,
|
||||
scenario_id: int | None,
|
||||
) -> Response:
|
||||
wants_json = _is_json_request(request)
|
||||
payload_data = await _extract_payload(request)
|
||||
|
||||
try:
|
||||
request_model = ProfitabilityCalculationRequest.model_validate(
|
||||
payload_data)
|
||||
payload_data
|
||||
)
|
||||
result = calculate_profitability(request_model, metadata=metadata)
|
||||
except ValidationError as exc:
|
||||
if wants_json:
|
||||
@@ -1586,3 +1733,53 @@ async def profitability_submit(
|
||||
context,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/scenarios/{scenario_id}/profitability",
|
||||
include_in_schema=False,
|
||||
name="calculations.profitability_submit",
|
||||
)
|
||||
async def profitability_submit_for_scenario(
|
||||
request: Request,
|
||||
project_id: int,
|
||||
scenario_id: int,
|
||||
current_user: User = Depends(require_authenticated_user),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Response:
|
||||
return await _handle_profitability_submission(
|
||||
request,
|
||||
current_user=current_user,
|
||||
metadata=metadata,
|
||||
uow=uow,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/profitability",
|
||||
)
|
||||
async def profitability_submit(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_authenticated_user),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
project_id: int | None = Query(
|
||||
None, description="Optional project identifier"
|
||||
),
|
||||
scenario_id: int | None = Query(
|
||||
None, description="Optional scenario identifier"
|
||||
),
|
||||
) -> Response:
|
||||
"""Handle profitability calculations and return HTML or JSON."""
|
||||
|
||||
return await _handle_profitability_submission(
|
||||
request,
|
||||
current_user=current_user,
|
||||
metadata=metadata,
|
||||
uow=uow,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
)
|
||||
|
||||
@@ -4,14 +4,14 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
from dependencies import get_current_user, get_unit_of_work
|
||||
from models import ScenarioStatus, User
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(tags=["Dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
|
||||
def _format_timestamp(moment: datetime | None) -> str | None:
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import get_unit_of_work, require_any_role
|
||||
from schemas.exports import (
|
||||
@@ -24,10 +23,12 @@ from services.export_serializers import (
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from models.import_export_log import ImportExportLog
|
||||
from monitoring.metrics import observe_export
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/exports", tags=["exports"])
|
||||
templates = create_templates()
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -49,7 +50,6 @@ async def export_modal(
|
||||
submit_url = request.url_for(
|
||||
"export_projects" if dataset == "projects" else "export_scenarios"
|
||||
)
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"exports/modal.html",
|
||||
|
||||
@@ -5,9 +5,12 @@ from io import BytesIO
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import get_import_ingestion_service, require_roles
|
||||
from dependencies import (
|
||||
get_import_ingestion_service,
|
||||
require_roles,
|
||||
require_roles_html,
|
||||
)
|
||||
from models import User
|
||||
from schemas.imports import (
|
||||
ImportCommitRequest,
|
||||
@@ -17,9 +20,10 @@ from schemas.imports import (
|
||||
ScenarioImportPreviewResponse,
|
||||
)
|
||||
from services.importers import ImportIngestionService, UnsupportedImportFormat
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/imports", tags=["Imports"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
|
||||
@@ -32,7 +36,7 @@ MANAGE_ROLES = ("project_manager", "admin")
|
||||
)
|
||||
def import_dashboard(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
63
routes/navigation.py
Normal file
63
routes/navigation.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from dependencies import (
|
||||
get_auth_session,
|
||||
get_navigation_service,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from models import User
|
||||
from schemas.navigation import (
|
||||
NavigationGroupSchema,
|
||||
NavigationLinkSchema,
|
||||
NavigationSidebarResponse,
|
||||
)
|
||||
from services.navigation import NavigationGroupDTO, NavigationLinkDTO, NavigationService
|
||||
from services.session import AuthSession
|
||||
|
||||
router = APIRouter(prefix="/navigation", tags=["Navigation"])
|
||||
|
||||
|
||||
def _to_link_schema(dto: NavigationLinkDTO) -> NavigationLinkSchema:
|
||||
return NavigationLinkSchema(
|
||||
id=dto.id,
|
||||
label=dto.label,
|
||||
href=dto.href,
|
||||
match_prefix=dto.match_prefix,
|
||||
icon=dto.icon,
|
||||
tooltip=dto.tooltip,
|
||||
is_external=dto.is_external,
|
||||
children=[_to_link_schema(child) for child in dto.children],
|
||||
)
|
||||
|
||||
|
||||
def _to_group_schema(dto: NavigationGroupDTO) -> NavigationGroupSchema:
|
||||
return NavigationGroupSchema(
|
||||
id=dto.id,
|
||||
label=dto.label,
|
||||
icon=dto.icon,
|
||||
tooltip=dto.tooltip,
|
||||
links=[_to_link_schema(link) for link in dto.links],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sidebar",
|
||||
response_model=NavigationSidebarResponse,
|
||||
name="navigation.sidebar",
|
||||
)
|
||||
async def get_sidebar_navigation(
|
||||
request: Request,
|
||||
_: User = Depends(require_authenticated_user),
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
service: NavigationService = Depends(get_navigation_service),
|
||||
) -> NavigationSidebarResponse:
|
||||
dto = service.build_sidebar(session=session, request=request)
|
||||
return NavigationSidebarResponse(
|
||||
groups=[_to_group_schema(group) for group in dto.groups],
|
||||
roles=list(dto.roles),
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
@@ -4,23 +4,26 @@ from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_any_role_html,
|
||||
require_project_resource,
|
||||
require_project_resource_html,
|
||||
require_roles,
|
||||
require_roles_html,
|
||||
)
|
||||
from models import MiningOperationType, Project, ScenarioStatus, User
|
||||
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
from services.exceptions import EntityConflictError
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -79,7 +82,7 @@ def create_project(
|
||||
)
|
||||
def project_list_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
projects = _require_project_repo(uow).list(with_children=True)
|
||||
@@ -101,7 +104,8 @@ def project_list_page(
|
||||
name="projects.create_project_form",
|
||||
)
|
||||
def create_project_form(
|
||||
request: Request, _: User = Depends(require_roles(*MANAGE_ROLES))
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -122,7 +126,7 @@ def create_project_form(
|
||||
)
|
||||
def create_project_submit(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
name: str = Form(...),
|
||||
location: str | None = Form(None),
|
||||
operation_type: str = Form(...),
|
||||
@@ -221,7 +225,8 @@ def delete_project(
|
||||
)
|
||||
def view_project(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
project: Project = Depends(require_project_resource_html()),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
project = _require_project_repo(uow).get(project.id, with_children=True)
|
||||
@@ -256,8 +261,9 @@ def view_project(
|
||||
)
|
||||
def edit_project_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
project: Project = Depends(
|
||||
require_project_resource(require_manage=True)
|
||||
require_project_resource_html(require_manage=True)
|
||||
),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
@@ -283,8 +289,9 @@ def edit_project_form(
|
||||
)
|
||||
def edit_project_submit(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
project: Project = Depends(
|
||||
require_project_resource(require_manage=True)
|
||||
require_project_resource_html(require_manage=True)
|
||||
),
|
||||
name: str = Form(...),
|
||||
location: str | None = Form(None),
|
||||
|
||||
@@ -5,13 +5,15 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_any_role_html,
|
||||
require_project_resource,
|
||||
require_scenario_resource,
|
||||
require_project_resource_html,
|
||||
require_scenario_resource_html,
|
||||
)
|
||||
from models import Project, Scenario, User
|
||||
from services.exceptions import EntityNotFoundError, ScenarioValidationError
|
||||
@@ -24,11 +26,10 @@ from services.reporting import (
|
||||
validate_percentiles,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import register_common_filters
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["Reports"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
register_common_filters(templates)
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -250,8 +251,8 @@ def scenario_distribution_report(
|
||||
)
|
||||
def project_summary_page(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
project: Project = Depends(require_project_resource_html()),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
@@ -314,8 +315,8 @@ def project_summary_page(
|
||||
)
|
||||
def project_scenario_comparison_page(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
project: Project = Depends(require_project_resource_html()),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
scenario_ids: list[int] = Query(
|
||||
..., alias="scenario_ids", description="Repeatable scenario identifier."),
|
||||
@@ -391,8 +392,10 @@ def project_scenario_comparison_page(
|
||||
)
|
||||
def scenario_distribution_page(
|
||||
request: Request,
|
||||
scenario: Scenario = Depends(require_scenario_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource_html()
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
|
||||
@@ -6,14 +6,16 @@ from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_any_role_html,
|
||||
require_roles,
|
||||
require_roles_html,
|
||||
require_scenario_resource,
|
||||
require_scenario_resource_html,
|
||||
)
|
||||
from models import ResourceType, Scenario, ScenarioStatus, User
|
||||
from schemas.scenario import (
|
||||
@@ -31,9 +33,10 @@ from services.exceptions import (
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(tags=["Scenarios"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -170,6 +173,63 @@ def create_scenario_for_project(
|
||||
return _to_read_model(created)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/ui",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
name="scenarios.project_scenario_list",
|
||||
)
|
||||
def project_scenario_list_page(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
project = _require_project_repo(uow).get(
|
||||
project_id, with_children=True)
|
||||
except EntityNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
scenarios = sorted(
|
||||
project.scenarios,
|
||||
key=lambda scenario: scenario.updated_at or scenario.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
scenario_totals = {
|
||||
"total": len(scenarios),
|
||||
"active": sum(
|
||||
1 for scenario in scenarios if scenario.status == ScenarioStatus.ACTIVE
|
||||
),
|
||||
"draft": sum(
|
||||
1 for scenario in scenarios if scenario.status == ScenarioStatus.DRAFT
|
||||
),
|
||||
"archived": sum(
|
||||
1 for scenario in scenarios if scenario.status == ScenarioStatus.ARCHIVED
|
||||
),
|
||||
"latest_update": max(
|
||||
(
|
||||
scenario.updated_at or scenario.created_at
|
||||
for scenario in scenarios
|
||||
if scenario.updated_at or scenario.created_at
|
||||
),
|
||||
default=None,
|
||||
),
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"scenarios/list.html",
|
||||
{
|
||||
"project": project,
|
||||
"scenarios": scenarios,
|
||||
"scenario_totals": scenario_totals,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scenarios/{scenario_id}", response_model=ScenarioRead)
|
||||
def get_scenario(
|
||||
scenario: Scenario = Depends(require_scenario_resource()),
|
||||
@@ -263,7 +323,7 @@ def _scenario_form_state(
|
||||
def create_scenario_form(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> HTMLResponse:
|
||||
@@ -301,7 +361,7 @@ def create_scenario_form(
|
||||
def create_scenario_submit(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
name: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
status_value: str = Form(ScenarioStatus.DRAFT.value),
|
||||
@@ -374,6 +434,7 @@ def create_scenario_submit(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": str(exc),
|
||||
"error_field": "currency",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -408,7 +469,8 @@ def create_scenario_submit(
|
||||
"cancel_url": request.url_for(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": "Scenario could not be created.",
|
||||
"error": "Scenario with this name already exists for this project.",
|
||||
"error_field": "name",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
@@ -428,8 +490,9 @@ def create_scenario_submit(
|
||||
)
|
||||
def view_scenario(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource(with_children=True)
|
||||
require_scenario_resource_html(with_children=True)
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
@@ -469,8 +532,9 @@ def view_scenario(
|
||||
)
|
||||
def edit_scenario_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource(require_manage=True)
|
||||
require_scenario_resource_html(require_manage=True)
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
@@ -503,8 +567,9 @@ def edit_scenario_form(
|
||||
)
|
||||
def edit_scenario_submit(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource(require_manage=True)
|
||||
require_scenario_resource_html(require_manage=True)
|
||||
),
|
||||
name: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
@@ -569,6 +634,7 @@ def edit_scenario_submit(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
),
|
||||
"error": str(exc),
|
||||
"error_field": "currency",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from services.navigation import NavigationService
|
||||
from services.session import AuthSession
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_datetime(value: Any) -> str:
|
||||
"""Render datetime values consistently for templates."""
|
||||
@@ -85,6 +94,47 @@ def register_common_filters(templates: Jinja2Templates) -> None:
|
||||
templates.env.filters["period_display"] = period_display
|
||||
|
||||
|
||||
def _sidebar_navigation_for_request(request: Request | None):
|
||||
if request is None:
|
||||
return None
|
||||
|
||||
cached = getattr(request.state, "_navigation_sidebar_dto", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
session_context = getattr(request.state, "auth_session", None)
|
||||
if isinstance(session_context, AuthSession):
|
||||
session = session_context
|
||||
else:
|
||||
session = AuthSession.anonymous()
|
||||
|
||||
try:
|
||||
with UnitOfWork() as uow:
|
||||
if not uow.navigation:
|
||||
logger.debug("Navigation repository unavailable for sidebar rendering")
|
||||
sidebar_dto = None
|
||||
else:
|
||||
service = NavigationService(uow.navigation)
|
||||
sidebar_dto = service.build_sidebar(session=session, request=request)
|
||||
except Exception: # pragma: no cover - defensive fallback for templates
|
||||
logger.exception("Failed to build sidebar navigation during template render")
|
||||
sidebar_dto = None
|
||||
|
||||
setattr(request.state, "_navigation_sidebar_dto", sidebar_dto)
|
||||
return sidebar_dto
|
||||
|
||||
|
||||
def register_navigation_globals(templates: Jinja2Templates) -> None:
|
||||
templates.env.globals["get_sidebar_navigation"] = _sidebar_navigation_for_request
|
||||
|
||||
|
||||
def create_templates() -> Jinja2Templates:
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
register_common_filters(templates)
|
||||
register_navigation_globals(templates)
|
||||
return templates
|
||||
|
||||
|
||||
__all__ = [
|
||||
"format_datetime",
|
||||
"currency_display",
|
||||
@@ -92,4 +142,6 @@ __all__ = [
|
||||
"percentage_display",
|
||||
"period_display",
|
||||
"register_common_filters",
|
||||
"register_navigation_globals",
|
||||
"create_templates",
|
||||
]
|
||||
|
||||
16
routes/ui.py
16
routes/ui.py
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import require_any_role, require_roles
|
||||
from dependencies import require_any_role_html, require_roles_html
|
||||
from models import User
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(tags=["UI"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -22,7 +22,7 @@ MANAGE_ROLES = ("project_manager", "admin")
|
||||
)
|
||||
def simulations_dashboard(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -41,7 +41,7 @@ def simulations_dashboard(
|
||||
)
|
||||
def reporting_dashboard(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -60,7 +60,7 @@ def reporting_dashboard(
|
||||
)
|
||||
def settings_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -79,7 +79,7 @@ def settings_page(
|
||||
)
|
||||
def theme_settings_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -98,7 +98,7 @@ def theme_settings_page(
|
||||
)
|
||||
def currencies_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
@@ -40,9 +40,9 @@ class ProfitabilityCalculationRequest(BaseModel):
|
||||
premiums: float = Field(0)
|
||||
fx_rate: PositiveFloat = Field(1)
|
||||
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)
|
||||
initial_capex: float = Field(0, ge=0)
|
||||
capex: float = Field(0, ge=0)
|
||||
discount_rate: float | None = Field(None, ge=0, le=100)
|
||||
periods: int = Field(10, ge=1, le=120)
|
||||
impurities: List[ImpurityInput] = Field(default_factory=list)
|
||||
@@ -63,9 +63,9 @@ class ProfitabilityCalculationRequest(BaseModel):
|
||||
class ProfitabilityCosts(BaseModel):
|
||||
"""Aggregated cost components for profitability output."""
|
||||
|
||||
processing_opex_total: float
|
||||
opex_total: float
|
||||
sustaining_capex_total: float
|
||||
initial_capex: float
|
||||
capex: float
|
||||
|
||||
|
||||
class ProfitabilityMetrics(BaseModel):
|
||||
@@ -82,7 +82,7 @@ class CashFlowEntry(BaseModel):
|
||||
|
||||
period: int
|
||||
revenue: float
|
||||
processing_opex: float
|
||||
opex: float
|
||||
sustaining_capex: float
|
||||
net: float
|
||||
|
||||
@@ -197,8 +197,8 @@ class CapexCalculationResult(BaseModel):
|
||||
currency: str | None
|
||||
|
||||
|
||||
class ProcessingOpexComponentInput(BaseModel):
|
||||
"""Processing opex component entry supplied by the UI."""
|
||||
class OpexComponentInput(BaseModel):
|
||||
"""opex component entry supplied by the UI."""
|
||||
|
||||
id: int | None = Field(default=None, ge=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
@@ -234,8 +234,8 @@ class ProcessingOpexComponentInput(BaseModel):
|
||||
return value.strip()
|
||||
|
||||
|
||||
class ProcessingOpexParameters(BaseModel):
|
||||
"""Global parameters applied to processing opex calculations."""
|
||||
class OpexParameters(BaseModel):
|
||||
"""Global parameters applied to opex calculations."""
|
||||
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
escalation_pct: float | None = Field(None, ge=0, le=100)
|
||||
@@ -251,35 +251,35 @@ class ProcessingOpexParameters(BaseModel):
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class ProcessingOpexOptions(BaseModel):
|
||||
class OpexOptions(BaseModel):
|
||||
"""Optional behaviour flags for opex calculations."""
|
||||
|
||||
persist: bool = False
|
||||
snapshot_notes: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class ProcessingOpexCalculationRequest(BaseModel):
|
||||
"""Request payload for processing opex aggregation."""
|
||||
class OpexCalculationRequest(BaseModel):
|
||||
"""Request payload for opex aggregation."""
|
||||
|
||||
components: List[ProcessingOpexComponentInput] = Field(
|
||||
components: List[OpexComponentInput] = Field(
|
||||
default_factory=list)
|
||||
parameters: ProcessingOpexParameters = Field(
|
||||
default_factory=ProcessingOpexParameters, # type: ignore[arg-type]
|
||||
parameters: OpexParameters = Field(
|
||||
default_factory=OpexParameters, # type: ignore[arg-type]
|
||||
)
|
||||
options: ProcessingOpexOptions = Field(
|
||||
default_factory=ProcessingOpexOptions, # type: ignore[arg-type]
|
||||
options: OpexOptions = Field(
|
||||
default_factory=OpexOptions, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class ProcessingOpexCategoryBreakdown(BaseModel):
|
||||
"""Category breakdown for processing opex totals."""
|
||||
class OpexCategoryBreakdown(BaseModel):
|
||||
"""Category breakdown for opex totals."""
|
||||
|
||||
category: str
|
||||
annual_cost: float = Field(..., ge=0)
|
||||
share: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class ProcessingOpexTimelineEntry(BaseModel):
|
||||
class OpexTimelineEntry(BaseModel):
|
||||
"""Timeline entry representing cost over evaluation periods."""
|
||||
|
||||
period: int
|
||||
@@ -287,34 +287,34 @@ class ProcessingOpexTimelineEntry(BaseModel):
|
||||
escalated_cost: float | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class ProcessingOpexMetrics(BaseModel):
|
||||
"""Derived KPIs for processing opex outputs."""
|
||||
class OpexMetrics(BaseModel):
|
||||
"""Derived KPIs for opex outputs."""
|
||||
|
||||
annual_average: float | None
|
||||
cost_per_ton: float | None
|
||||
|
||||
|
||||
class ProcessingOpexTotals(BaseModel):
|
||||
"""Aggregated totals for processing opex."""
|
||||
class OpexTotals(BaseModel):
|
||||
"""Aggregated totals for opex."""
|
||||
|
||||
overall_annual: float = Field(..., ge=0)
|
||||
escalated_total: float | None = Field(None, ge=0)
|
||||
escalation_pct: float | None = Field(None, ge=0, le=100)
|
||||
by_category: List[ProcessingOpexCategoryBreakdown] = Field(
|
||||
by_category: List[OpexCategoryBreakdown] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class ProcessingOpexCalculationResult(BaseModel):
|
||||
"""Response body summarising processing opex calculations."""
|
||||
class OpexCalculationResult(BaseModel):
|
||||
"""Response body summarising opex calculations."""
|
||||
|
||||
totals: ProcessingOpexTotals
|
||||
timeline: List[ProcessingOpexTimelineEntry] = Field(default_factory=list)
|
||||
metrics: ProcessingOpexMetrics
|
||||
components: List[ProcessingOpexComponentInput] = Field(
|
||||
totals: OpexTotals
|
||||
timeline: List[OpexTimelineEntry] = Field(default_factory=list)
|
||||
metrics: OpexMetrics
|
||||
components: List[OpexComponentInput] = Field(
|
||||
default_factory=list)
|
||||
parameters: ProcessingOpexParameters
|
||||
options: ProcessingOpexOptions
|
||||
parameters: OpexParameters
|
||||
options: OpexOptions
|
||||
currency: str | None
|
||||
|
||||
|
||||
@@ -333,14 +333,14 @@ __all__ = [
|
||||
"CapexTotals",
|
||||
"CapexTimelineEntry",
|
||||
"CapexCalculationResult",
|
||||
"ProcessingOpexComponentInput",
|
||||
"ProcessingOpexParameters",
|
||||
"ProcessingOpexOptions",
|
||||
"ProcessingOpexCalculationRequest",
|
||||
"ProcessingOpexCategoryBreakdown",
|
||||
"ProcessingOpexTimelineEntry",
|
||||
"ProcessingOpexMetrics",
|
||||
"ProcessingOpexTotals",
|
||||
"ProcessingOpexCalculationResult",
|
||||
"OpexComponentInput",
|
||||
"OpexParameters",
|
||||
"OpexOptions",
|
||||
"OpexCalculationRequest",
|
||||
"OpexCategoryBreakdown",
|
||||
"OpexTimelineEntry",
|
||||
"OpexMetrics",
|
||||
"OpexTotals",
|
||||
"OpexCalculationResult",
|
||||
"ValidationError",
|
||||
]
|
||||
|
||||
36
schemas/navigation.py
Normal file
36
schemas/navigation.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NavigationLinkSchema(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
href: str
|
||||
match_prefix: str | None = Field(default=None)
|
||||
icon: str | None = Field(default=None)
|
||||
tooltip: str | None = Field(default=None)
|
||||
is_external: bool = Field(default=False)
|
||||
children: List["NavigationLinkSchema"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NavigationGroupSchema(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
icon: str | None = Field(default=None)
|
||||
tooltip: str | None = Field(default=None)
|
||||
links: List[NavigationLinkSchema] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NavigationSidebarResponse(BaseModel):
|
||||
groups: List[NavigationGroupSchema]
|
||||
roles: List[str] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
NavigationLinkSchema.model_rebuild()
|
||||
NavigationGroupSchema.model_rebuild()
|
||||
NavigationSidebarResponse.model_rebuild()
|
||||
@@ -23,9 +23,10 @@ import logging
|
||||
from decimal import Decimal
|
||||
|
||||
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 passlib.context import CryptContext
|
||||
from sqlalchemy.sql import bindparam
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
password_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
@@ -116,6 +117,40 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon TEXT,
|
||||
tooltip TEXT,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
|
||||
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
route_name TEXT,
|
||||
href_override TEXT,
|
||||
match_prefix TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon TEXT,
|
||||
tooltip TEXT,
|
||||
required_roles TEXT NOT NULL DEFAULT '[]',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_external INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (group_id, slug)
|
||||
);
|
||||
""",
|
||||
# pricing_settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pricing_settings (
|
||||
@@ -268,6 +303,41 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
CONSTRAINT uq_user_roles_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon VARCHAR(64),
|
||||
tooltip VARCHAR(255),
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT uq_navigation_groups_slug UNIQUE (slug)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
|
||||
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
route_name VARCHAR(128),
|
||||
href_override VARCHAR(512),
|
||||
match_prefix VARCHAR(512),
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon VARCHAR(64),
|
||||
tooltip VARCHAR(255),
|
||||
required_roles JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
is_external BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT uq_navigation_links_group_slug UNIQUE (group_id, slug)
|
||||
);
|
||||
""",
|
||||
# pricing_settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pricing_settings (
|
||||
@@ -471,6 +541,230 @@ class PricingSeed(BaseModel):
|
||||
moisture_penalty_per_pct: float
|
||||
|
||||
|
||||
class NavigationGroupSeed(BaseModel):
|
||||
slug: str
|
||||
label: str
|
||||
sort_order: int = 100
|
||||
icon: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class NavigationLinkSeed(BaseModel):
|
||||
slug: str
|
||||
group_slug: str
|
||||
label: str
|
||||
route_name: Optional[str] = None
|
||||
href_override: Optional[str] = None
|
||||
match_prefix: Optional[str] = None
|
||||
sort_order: int = 100
|
||||
icon: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
required_roles: list[str] = Field(default_factory=list)
|
||||
is_enabled: bool = True
|
||||
is_external: bool = False
|
||||
parent_slug: Optional[str] = None
|
||||
|
||||
@field_validator("required_roles", mode="after")
|
||||
def _normalise_roles(cls, value: list[str]) -> list[str]:
|
||||
normalised = []
|
||||
for role in value:
|
||||
if not role:
|
||||
continue
|
||||
slug = role.strip().lower()
|
||||
if slug and slug not in normalised:
|
||||
normalised.append(slug)
|
||||
return normalised
|
||||
|
||||
@field_validator("route_name")
|
||||
def _route_or_href(cls, value: Optional[str], info):
|
||||
href = info.data.get("href_override")
|
||||
if not value and not href:
|
||||
raise ValueError(
|
||||
"navigation link requires route_name or href_override")
|
||||
return value
|
||||
|
||||
|
||||
DEFAULT_NAVIGATION_GROUPS: list[NavigationGroupSeed] = [
|
||||
NavigationGroupSeed(
|
||||
slug="workspace",
|
||||
label="Workspace",
|
||||
sort_order=10,
|
||||
icon="briefcase",
|
||||
tooltip="Primary work hub",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="insights",
|
||||
label="Insights",
|
||||
sort_order=20,
|
||||
icon="insights",
|
||||
tooltip="Analytics and reports",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="configuration",
|
||||
label="Configuration",
|
||||
sort_order=30,
|
||||
icon="cog",
|
||||
tooltip="Administration and settings",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="account",
|
||||
label="Account",
|
||||
sort_order=40,
|
||||
icon="user",
|
||||
tooltip="Session management",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_NAVIGATION_LINKS: list[NavigationLinkSeed] = [
|
||||
NavigationLinkSeed(
|
||||
slug="dashboard",
|
||||
group_slug="workspace",
|
||||
label="Dashboard",
|
||||
route_name="dashboard.home",
|
||||
match_prefix="/",
|
||||
sort_order=10,
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="projects",
|
||||
group_slug="workspace",
|
||||
label="Projects",
|
||||
route_name="projects.project_list_page",
|
||||
match_prefix="/projects",
|
||||
sort_order=20,
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="project-create",
|
||||
group_slug="workspace",
|
||||
label="New Project",
|
||||
route_name="projects.create_project_form",
|
||||
match_prefix="/projects/create",
|
||||
sort_order=30,
|
||||
required_roles=["project_manager", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="imports",
|
||||
group_slug="workspace",
|
||||
label="Imports",
|
||||
href_override="/imports/ui",
|
||||
match_prefix="/imports",
|
||||
sort_order=40,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="profitability",
|
||||
group_slug="workspace",
|
||||
label="Profitability Calculator",
|
||||
route_name="calculations.profitability_form",
|
||||
match_prefix="/calculations/profitability",
|
||||
sort_order=50,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="opex",
|
||||
group_slug="workspace",
|
||||
label="Opex Planner",
|
||||
route_name="calculations.opex_form",
|
||||
match_prefix="/calculations/opex",
|
||||
sort_order=60,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="capex",
|
||||
group_slug="workspace",
|
||||
label="Capex Planner",
|
||||
route_name="calculations.capex_form",
|
||||
match_prefix="/calculations/capex",
|
||||
sort_order=70,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="simulations",
|
||||
group_slug="insights",
|
||||
label="Simulations",
|
||||
href_override="/ui/simulations",
|
||||
match_prefix="/ui/simulations",
|
||||
sort_order=10,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="reporting",
|
||||
group_slug="insights",
|
||||
label="Reporting",
|
||||
href_override="/ui/reporting",
|
||||
match_prefix="/ui/reporting",
|
||||
sort_order=20,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="settings",
|
||||
group_slug="configuration",
|
||||
label="Settings",
|
||||
href_override="/ui/settings",
|
||||
match_prefix="/ui/settings",
|
||||
sort_order=10,
|
||||
required_roles=["admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="themes",
|
||||
group_slug="configuration",
|
||||
label="Themes",
|
||||
href_override="/theme-settings",
|
||||
match_prefix="/theme-settings",
|
||||
sort_order=20,
|
||||
required_roles=["admin"],
|
||||
parent_slug="settings",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="currencies",
|
||||
group_slug="configuration",
|
||||
label="Currency Management",
|
||||
href_override="/ui/currencies",
|
||||
match_prefix="/ui/currencies",
|
||||
sort_order=30,
|
||||
required_roles=["admin"],
|
||||
parent_slug="settings",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="logout",
|
||||
group_slug="account",
|
||||
label="Logout",
|
||||
route_name="auth.logout",
|
||||
match_prefix="/logout",
|
||||
sort_order=10,
|
||||
required_roles=["viewer", "analyst", "project_manager", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="login",
|
||||
group_slug="account",
|
||||
label="Login",
|
||||
route_name="auth.login_form",
|
||||
match_prefix="/login",
|
||||
sort_order=10,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="register",
|
||||
group_slug="account",
|
||||
label="Register",
|
||||
route_name="auth.register_form",
|
||||
match_prefix="/register",
|
||||
sort_order=20,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="forgot-password",
|
||||
group_slug="account",
|
||||
label="Forgot Password",
|
||||
route_name="auth.password_reset_request_form",
|
||||
match_prefix="/forgot-password",
|
||||
sort_order=30,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_PROJECTS: list[ProjectSeed] = [
|
||||
ProjectSeed(
|
||||
name="Helios Copper",
|
||||
@@ -528,7 +822,7 @@ DEFAULT_FINANCIAL_INPUTS: list[FinancialInputSeed] = [
|
||||
FinancialInputSeed(
|
||||
project_name="Helios Copper",
|
||||
scenario_name="Base Case",
|
||||
name="Processing Opex",
|
||||
name="Opex",
|
||||
category="opex",
|
||||
cost_bucket="operating_variable",
|
||||
amount=Decimal("75000000"),
|
||||
@@ -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]:
|
||||
row = conn.execute(
|
||||
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_admin_user(engine, is_sqlite)
|
||||
ensure_default_pricing(engine, is_sqlite)
|
||||
seed_navigation(engine, is_sqlite)
|
||||
ensure_default_projects(engine, is_sqlite)
|
||||
ensure_default_scenarios(engine, is_sqlite)
|
||||
ensure_default_financial_inputs(engine, is_sqlite)
|
||||
|
||||
@@ -29,14 +29,14 @@ from schemas.calculations import (
|
||||
CapexTotals,
|
||||
CapexTimelineEntry,
|
||||
CashFlowEntry,
|
||||
ProcessingOpexCalculationRequest,
|
||||
ProcessingOpexCalculationResult,
|
||||
ProcessingOpexCategoryBreakdown,
|
||||
ProcessingOpexComponentInput,
|
||||
ProcessingOpexMetrics,
|
||||
ProcessingOpexParameters,
|
||||
ProcessingOpexTotals,
|
||||
ProcessingOpexTimelineEntry,
|
||||
OpexCalculationRequest,
|
||||
OpexCalculationResult,
|
||||
OpexCategoryBreakdown,
|
||||
OpexComponentInput,
|
||||
OpexMetrics,
|
||||
OpexParameters,
|
||||
OpexTotals,
|
||||
OpexTimelineEntry,
|
||||
ProfitabilityCalculationRequest,
|
||||
ProfitabilityCalculationResult,
|
||||
ProfitabilityCosts,
|
||||
@@ -101,20 +101,20 @@ def _generate_cash_flows(
|
||||
*,
|
||||
periods: int,
|
||||
net_per_period: float,
|
||||
initial_capex: float,
|
||||
capex: float,
|
||||
) -> tuple[list[CashFlow], list[CashFlowEntry]]:
|
||||
"""Create cash flow structures for financial metric calculations."""
|
||||
|
||||
cash_flow_models: list[CashFlow] = [
|
||||
CashFlow(amount=-initial_capex, period_index=0)
|
||||
CashFlow(amount=-capex, period_index=0)
|
||||
]
|
||||
cash_flow_entries: list[CashFlowEntry] = [
|
||||
CashFlowEntry(
|
||||
period=0,
|
||||
revenue=0.0,
|
||||
processing_opex=0.0,
|
||||
opex=0.0,
|
||||
sustaining_capex=0.0,
|
||||
net=-initial_capex,
|
||||
net=-capex,
|
||||
)
|
||||
]
|
||||
|
||||
@@ -125,7 +125,7 @@ def _generate_cash_flows(
|
||||
CashFlowEntry(
|
||||
period=period,
|
||||
revenue=0.0,
|
||||
processing_opex=0.0,
|
||||
opex=0.0,
|
||||
sustaining_capex=0.0,
|
||||
net=net_per_period,
|
||||
)
|
||||
@@ -159,26 +159,26 @@ def calculate_profitability(
|
||||
revenue_total = float(pricing_result.net_revenue)
|
||||
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
|
||||
initial_capex = float(request.initial_capex)
|
||||
capex = float(request.capex)
|
||||
|
||||
net_per_period = (
|
||||
revenue_per_period
|
||||
- float(request.processing_opex)
|
||||
- float(request.opex)
|
||||
- float(request.sustaining_capex)
|
||||
)
|
||||
|
||||
cash_flow_models, cash_flow_entries = _generate_cash_flows(
|
||||
periods=periods,
|
||||
net_per_period=net_per_period,
|
||||
initial_capex=initial_capex,
|
||||
capex=capex,
|
||||
)
|
||||
|
||||
# Update per-period entries to include explicit costs for presentation
|
||||
for entry in cash_flow_entries[1:]:
|
||||
entry.revenue = revenue_per_period
|
||||
entry.processing_opex = float(request.processing_opex)
|
||||
entry.opex = float(request.opex)
|
||||
entry.sustaining_capex = float(request.sustaining_capex)
|
||||
entry.net = net_per_period
|
||||
|
||||
@@ -196,7 +196,7 @@ def calculate_profitability(
|
||||
except (ValueError, PaybackNotReachedError):
|
||||
payback_value = None
|
||||
|
||||
total_costs = processing_total + sustaining_total + initial_capex
|
||||
total_costs = processing_total + sustaining_total + capex
|
||||
total_net = revenue_total - total_costs
|
||||
|
||||
if revenue_total == 0:
|
||||
@@ -212,9 +212,9 @@ def calculate_profitability(
|
||||
str(exc), ["currency_code"]) from exc
|
||||
|
||||
costs = ProfitabilityCosts(
|
||||
processing_opex_total=processing_total,
|
||||
opex_total=processing_total,
|
||||
sustaining_capex_total=sustaining_total,
|
||||
initial_capex=initial_capex,
|
||||
capex=capex,
|
||||
)
|
||||
|
||||
metrics = ProfitabilityMetrics(
|
||||
@@ -354,18 +354,18 @@ def calculate_initial_capex(
|
||||
)
|
||||
|
||||
|
||||
def calculate_processing_opex(
|
||||
request: ProcessingOpexCalculationRequest,
|
||||
) -> ProcessingOpexCalculationResult:
|
||||
"""Aggregate processing opex components into annual totals and timeline."""
|
||||
def calculate_opex(
|
||||
request: OpexCalculationRequest,
|
||||
) -> OpexCalculationResult:
|
||||
"""Aggregate opex components into annual totals and timeline."""
|
||||
|
||||
if not request.components:
|
||||
raise OpexValidationError(
|
||||
"At least one processing opex component is required for calculation.",
|
||||
"At least one opex component is required for calculation.",
|
||||
["components"],
|
||||
)
|
||||
|
||||
parameters: ProcessingOpexParameters = request.parameters
|
||||
parameters: OpexParameters = request.parameters
|
||||
base_currency = parameters.currency_code
|
||||
if base_currency:
|
||||
try:
|
||||
@@ -388,7 +388,7 @@ def calculate_processing_opex(
|
||||
category_totals: dict[str, float] = defaultdict(float)
|
||||
timeline_totals: 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
|
||||
|
||||
@@ -448,7 +448,7 @@ def calculate_processing_opex(
|
||||
timeline_totals[period] += annual_cost
|
||||
|
||||
normalised_components.append(
|
||||
ProcessingOpexComponentInput(
|
||||
OpexComponentInput(
|
||||
id=component.id,
|
||||
name=component.name,
|
||||
category=component.category,
|
||||
@@ -471,7 +471,7 @@ def calculate_processing_opex(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
timeline_entries: list[ProcessingOpexTimelineEntry] = []
|
||||
timeline_entries: list[OpexTimelineEntry] = []
|
||||
escalated_values: list[float] = []
|
||||
overall_annual = timeline_totals.get(1, 0.0)
|
||||
escalated_total = 0.0
|
||||
@@ -486,7 +486,7 @@ def calculate_processing_opex(
|
||||
timeline_escalated[period] = escalated_cost
|
||||
escalated_total += escalated_cost
|
||||
timeline_entries.append(
|
||||
ProcessingOpexTimelineEntry(
|
||||
OpexTimelineEntry(
|
||||
period=period,
|
||||
base_cost=base_cost,
|
||||
escalated_cost=escalated_cost if apply_escalation else None,
|
||||
@@ -494,31 +494,31 @@ def calculate_processing_opex(
|
||||
)
|
||||
escalated_values.append(escalated_cost)
|
||||
|
||||
category_breakdowns: list[ProcessingOpexCategoryBreakdown] = []
|
||||
category_breakdowns: list[OpexCategoryBreakdown] = []
|
||||
total_base = sum(category_totals.values())
|
||||
for category, total in sorted(category_totals.items()):
|
||||
share = (total / total_base * 100.0) if total_base else None
|
||||
category_breakdowns.append(
|
||||
ProcessingOpexCategoryBreakdown(
|
||||
OpexCategoryBreakdown(
|
||||
category=category,
|
||||
annual_cost=total,
|
||||
share=share,
|
||||
)
|
||||
)
|
||||
|
||||
metrics = ProcessingOpexMetrics(
|
||||
metrics = OpexMetrics(
|
||||
annual_average=fmean(escalated_values) if escalated_values else None,
|
||||
cost_per_ton=None,
|
||||
)
|
||||
|
||||
totals = ProcessingOpexTotals(
|
||||
totals = OpexTotals(
|
||||
overall_annual=overall_annual,
|
||||
escalated_total=escalated_total if apply_escalation else None,
|
||||
escalation_pct=escalation_pct if apply_escalation else None,
|
||||
by_category=category_breakdowns,
|
||||
)
|
||||
|
||||
return ProcessingOpexCalculationResult(
|
||||
return OpexCalculationResult(
|
||||
totals=totals,
|
||||
timeline=timeline_entries,
|
||||
metrics=metrics,
|
||||
@@ -532,5 +532,5 @@ def calculate_processing_opex(
|
||||
__all__ = [
|
||||
"calculate_profitability",
|
||||
"calculate_initial_capex",
|
||||
"calculate_processing_opex",
|
||||
"calculate_opex",
|
||||
]
|
||||
|
||||
188
services/navigation.py
Normal file
188
services/navigation.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from models.navigation import NavigationGroup, NavigationLink
|
||||
from services.repositories import NavigationRepository
|
||||
from services.session import AuthSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationLinkDTO:
|
||||
id: int
|
||||
label: str
|
||||
href: str
|
||||
match_prefix: str | None
|
||||
icon: str | None
|
||||
tooltip: str | None
|
||||
is_external: bool
|
||||
children: List["NavigationLinkDTO"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationGroupDTO:
|
||||
id: int
|
||||
label: str
|
||||
icon: str | None
|
||||
tooltip: str | None
|
||||
links: List[NavigationLinkDTO] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationSidebarDTO:
|
||||
groups: List[NavigationGroupDTO]
|
||||
roles: tuple[str, ...]
|
||||
|
||||
|
||||
class NavigationService:
|
||||
"""Build navigation payloads filtered for the current session."""
|
||||
|
||||
def __init__(self, repository: NavigationRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def build_sidebar(
|
||||
self,
|
||||
*,
|
||||
session: AuthSession,
|
||||
request: Request | None = None,
|
||||
include_disabled: bool = False,
|
||||
) -> NavigationSidebarDTO:
|
||||
roles = self._collect_roles(session)
|
||||
groups = self._repository.list_groups_with_links(
|
||||
include_disabled=include_disabled
|
||||
)
|
||||
context = self._derive_context(request)
|
||||
|
||||
mapped_groups: List[NavigationGroupDTO] = []
|
||||
for group in groups:
|
||||
if not include_disabled and not group.is_enabled:
|
||||
continue
|
||||
mapped_links = self._map_links(
|
||||
group.links,
|
||||
roles,
|
||||
request=request,
|
||||
include_disabled=include_disabled,
|
||||
context=context,
|
||||
)
|
||||
if not mapped_links and not include_disabled:
|
||||
continue
|
||||
mapped_groups.append(
|
||||
NavigationGroupDTO(
|
||||
id=group.id,
|
||||
label=group.label,
|
||||
icon=group.icon,
|
||||
tooltip=group.tooltip,
|
||||
links=mapped_links,
|
||||
)
|
||||
)
|
||||
return NavigationSidebarDTO(groups=mapped_groups, roles=roles)
|
||||
|
||||
def _map_links(
|
||||
self,
|
||||
links: Sequence[NavigationLink],
|
||||
roles: Iterable[str],
|
||||
*,
|
||||
request: Request | None,
|
||||
include_disabled: bool,
|
||||
context: dict[str, str | None],
|
||||
) -> List[NavigationLinkDTO]:
|
||||
resolved_roles = tuple(roles)
|
||||
mapped: List[NavigationLinkDTO] = []
|
||||
for link in sorted(links, key=lambda l: (l.sort_order, l.id)):
|
||||
if not include_disabled and (not link.is_enabled):
|
||||
continue
|
||||
if not self._link_visible(link, resolved_roles, include_disabled):
|
||||
continue
|
||||
href = self._resolve_href(link, request=request, context=context)
|
||||
if not href:
|
||||
continue
|
||||
children = self._map_links(
|
||||
link.children,
|
||||
resolved_roles,
|
||||
request=request,
|
||||
include_disabled=include_disabled,
|
||||
context=context,
|
||||
)
|
||||
match_prefix = link.match_prefix or href
|
||||
mapped.append(
|
||||
NavigationLinkDTO(
|
||||
id=link.id,
|
||||
label=link.label,
|
||||
href=href,
|
||||
match_prefix=match_prefix,
|
||||
icon=link.icon,
|
||||
tooltip=link.tooltip,
|
||||
is_external=link.is_external,
|
||||
children=children,
|
||||
)
|
||||
)
|
||||
return mapped
|
||||
|
||||
@staticmethod
|
||||
def _collect_roles(session: AuthSession) -> tuple[str, ...]:
|
||||
roles = tuple((session.role_slugs or ()) if session else ())
|
||||
if session and session.is_authenticated:
|
||||
return roles
|
||||
if "anonymous" in roles:
|
||||
return roles
|
||||
return roles + ("anonymous",)
|
||||
|
||||
@staticmethod
|
||||
def _derive_context(request: Request | None) -> dict[str, str | None]:
|
||||
if request is None:
|
||||
return {"project_id": None, "scenario_id": None}
|
||||
project_id = request.path_params.get(
|
||||
"project_id") if hasattr(request, "path_params") else None
|
||||
scenario_id = request.path_params.get(
|
||||
"scenario_id") if hasattr(request, "path_params") else None
|
||||
if not project_id:
|
||||
project_id = request.query_params.get("project_id")
|
||||
if not scenario_id:
|
||||
scenario_id = request.query_params.get("scenario_id")
|
||||
return {"project_id": project_id, "scenario_id": scenario_id}
|
||||
|
||||
def _resolve_href(
|
||||
self,
|
||||
link: NavigationLink,
|
||||
*,
|
||||
request: Request | None,
|
||||
context: dict[str, str | None],
|
||||
) -> str | None:
|
||||
if link.route_name:
|
||||
if request is None:
|
||||
# Fallback to route name when no request is available
|
||||
return f"/{link.route_name.replace('.', '/')}"
|
||||
if link.slug in {"profitability", "profitability-calculator"}:
|
||||
project_id = context.get("project_id")
|
||||
scenario_id = context.get("scenario_id")
|
||||
if project_id and scenario_id:
|
||||
try:
|
||||
return request.url_for(
|
||||
link.route_name,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
try:
|
||||
return request.url_for(link.route_name)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return link.href_override
|
||||
return link.href_override
|
||||
|
||||
@staticmethod
|
||||
def _link_visible(
|
||||
link: NavigationLink,
|
||||
roles: Iterable[str],
|
||||
include_disabled: bool,
|
||||
) -> bool:
|
||||
role_tuple = tuple(roles)
|
||||
if not include_disabled and not link.is_enabled:
|
||||
return False
|
||||
if not link.required_roles:
|
||||
return True
|
||||
role_set = set(role_tuple)
|
||||
return any(role in role_set for role in link.required_roles)
|
||||
@@ -17,12 +17,14 @@ from models import (
|
||||
PricingSettings,
|
||||
ProjectCapexSnapshot,
|
||||
ProjectProfitability,
|
||||
ProjectProcessingOpexSnapshot,
|
||||
ProjectOpexSnapshot,
|
||||
NavigationGroup,
|
||||
NavigationLink,
|
||||
Role,
|
||||
Scenario,
|
||||
ScenarioCapexSnapshot,
|
||||
ScenarioProfitability,
|
||||
ScenarioProcessingOpexSnapshot,
|
||||
ScenarioOpexSnapshot,
|
||||
ScenarioStatus,
|
||||
SimulationParameter,
|
||||
User,
|
||||
@@ -38,6 +40,54 @@ def _enum_value(e):
|
||||
return getattr(e, "value", e)
|
||||
|
||||
|
||||
class NavigationRepository:
|
||||
"""Persistence operations for navigation metadata."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list_groups_with_links(
|
||||
self,
|
||||
*,
|
||||
include_disabled: bool = False,
|
||||
) -> Sequence[NavigationGroup]:
|
||||
stmt = (
|
||||
select(NavigationGroup)
|
||||
.options(
|
||||
selectinload(NavigationGroup.links)
|
||||
.selectinload(NavigationLink.children)
|
||||
)
|
||||
.order_by(NavigationGroup.sort_order, NavigationGroup.id)
|
||||
)
|
||||
if not include_disabled:
|
||||
stmt = stmt.where(NavigationGroup.is_enabled.is_(True))
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def get_group_by_slug(self, slug: str) -> NavigationGroup | None:
|
||||
stmt = select(NavigationGroup).where(NavigationGroup.slug == slug)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def get_link_by_slug(
|
||||
self,
|
||||
slug: str,
|
||||
*,
|
||||
group_id: int | None = None,
|
||||
) -> NavigationLink | None:
|
||||
stmt = select(NavigationLink).where(NavigationLink.slug == slug)
|
||||
if group_id is not None:
|
||||
stmt = stmt.where(NavigationLink.group_id == group_id)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def add_group(self, group: NavigationGroup) -> NavigationGroup:
|
||||
self.session.add(group)
|
||||
self.session.flush()
|
||||
return group
|
||||
|
||||
def add_link(self, link: NavigationLink) -> NavigationLink:
|
||||
self.session.add(link)
|
||||
self.session.flush()
|
||||
return link
|
||||
|
||||
class ProjectRepository:
|
||||
"""Persistence operations for Project entities."""
|
||||
|
||||
@@ -573,15 +623,15 @@ class ScenarioCapexRepository:
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ProjectProcessingOpexRepository:
|
||||
"""Persistence operations for project-level processing opex snapshots."""
|
||||
class ProjectOpexRepository:
|
||||
"""Persistence operations for project-level opex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(
|
||||
self, snapshot: ProjectProcessingOpexSnapshot
|
||||
) -> ProjectProcessingOpexSnapshot:
|
||||
self, snapshot: ProjectOpexSnapshot
|
||||
) -> ProjectOpexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
@@ -591,11 +641,11 @@ class ProjectProcessingOpexRepository:
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectProcessingOpexSnapshot]:
|
||||
) -> Sequence[ProjectOpexSnapshot]:
|
||||
stmt = (
|
||||
select(ProjectProcessingOpexSnapshot)
|
||||
.where(ProjectProcessingOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectProcessingOpexSnapshot.calculated_at.desc())
|
||||
select(ProjectOpexSnapshot)
|
||||
.where(ProjectOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectOpexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
@@ -604,36 +654,36 @@ class ProjectProcessingOpexRepository:
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectProcessingOpexSnapshot | None:
|
||||
) -> ProjectOpexSnapshot | None:
|
||||
stmt = (
|
||||
select(ProjectProcessingOpexSnapshot)
|
||||
.where(ProjectProcessingOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectProcessingOpexSnapshot.calculated_at.desc())
|
||||
select(ProjectOpexSnapshot)
|
||||
.where(ProjectOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectOpexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectProcessingOpexSnapshot).where(
|
||||
ProjectProcessingOpexSnapshot.id == snapshot_id
|
||||
stmt = select(ProjectOpexSnapshot).where(
|
||||
ProjectOpexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project processing opex snapshot {snapshot_id} not found"
|
||||
f"Project opex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioProcessingOpexRepository:
|
||||
"""Persistence operations for scenario-level processing opex snapshots."""
|
||||
class ScenarioOpexRepository:
|
||||
"""Persistence operations for scenario-level opex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(
|
||||
self, snapshot: ScenarioProcessingOpexSnapshot
|
||||
) -> ScenarioProcessingOpexSnapshot:
|
||||
self, snapshot: ScenarioOpexSnapshot
|
||||
) -> ScenarioOpexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
@@ -643,11 +693,11 @@ class ScenarioProcessingOpexRepository:
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioProcessingOpexSnapshot]:
|
||||
) -> Sequence[ScenarioOpexSnapshot]:
|
||||
stmt = (
|
||||
select(ScenarioProcessingOpexSnapshot)
|
||||
.where(ScenarioProcessingOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioProcessingOpexSnapshot.calculated_at.desc())
|
||||
select(ScenarioOpexSnapshot)
|
||||
.where(ScenarioOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioOpexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
@@ -656,23 +706,23 @@ class ScenarioProcessingOpexRepository:
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioProcessingOpexSnapshot | None:
|
||||
) -> ScenarioOpexSnapshot | None:
|
||||
stmt = (
|
||||
select(ScenarioProcessingOpexSnapshot)
|
||||
.where(ScenarioProcessingOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioProcessingOpexSnapshot.calculated_at.desc())
|
||||
select(ScenarioOpexSnapshot)
|
||||
.where(ScenarioOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioOpexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioProcessingOpexSnapshot).where(
|
||||
ScenarioProcessingOpexSnapshot.id == snapshot_id
|
||||
stmt = select(ScenarioOpexSnapshot).where(
|
||||
ScenarioOpexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario processing opex snapshot {snapshot_id} not found"
|
||||
f"Scenario opex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional, TYPE_CHECKING
|
||||
from typing import Iterable, Literal, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
@@ -67,6 +67,7 @@ class AuthSession:
|
||||
tokens: SessionTokens
|
||||
user: Optional["User"] = None
|
||||
scopes: tuple[str, ...] = ()
|
||||
role_slugs: tuple[str, ...] = ()
|
||||
issued_access_token: Optional[str] = None
|
||||
issued_refresh_token: Optional[str] = None
|
||||
clear_cookies: bool = False
|
||||
@@ -77,7 +78,10 @@ class AuthSession:
|
||||
|
||||
@classmethod
|
||||
def anonymous(cls) -> "AuthSession":
|
||||
return cls(tokens=SessionTokens(access_token=None, refresh_token=None))
|
||||
return cls(
|
||||
tokens=SessionTokens(access_token=None, refresh_token=None),
|
||||
role_slugs=(),
|
||||
)
|
||||
|
||||
def issue_tokens(
|
||||
self,
|
||||
@@ -100,6 +104,10 @@ class AuthSession:
|
||||
self.tokens = SessionTokens(access_token=None, refresh_token=None)
|
||||
self.user = None
|
||||
self.scopes = ()
|
||||
self.role_slugs = ()
|
||||
|
||||
def set_role_slugs(self, roles: Iterable[str]) -> None:
|
||||
self.role_slugs = tuple(dict.fromkeys(role.strip().lower() for role in roles if role))
|
||||
|
||||
|
||||
def extract_session_tokens(request: Request, strategy: SessionStrategy) -> SessionTokens:
|
||||
|
||||
@@ -14,12 +14,12 @@ from services.repositories import (
|
||||
PricingSettingsSeedResult,
|
||||
ProjectRepository,
|
||||
ProjectProfitabilityRepository,
|
||||
ProjectProcessingOpexRepository,
|
||||
ProjectOpexRepository,
|
||||
ProjectCapexRepository,
|
||||
RoleRepository,
|
||||
ScenarioRepository,
|
||||
ScenarioProfitabilityRepository,
|
||||
ScenarioProcessingOpexRepository,
|
||||
ScenarioOpexRepository,
|
||||
ScenarioCapexRepository,
|
||||
SimulationParameterRepository,
|
||||
UserRepository,
|
||||
@@ -27,6 +27,7 @@ from services.repositories import (
|
||||
ensure_default_pricing_settings,
|
||||
ensure_default_roles,
|
||||
pricing_settings_to_metadata,
|
||||
NavigationRepository,
|
||||
)
|
||||
from services.scenario_validation import ScenarioComparisonValidator
|
||||
|
||||
@@ -44,13 +45,14 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.simulation_parameters: SimulationParameterRepository | None = None
|
||||
self.project_profitability: ProjectProfitabilityRepository | 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_capex: ScenarioCapexRepository | None = None
|
||||
self.scenario_processing_opex: ScenarioProcessingOpexRepository | None = None
|
||||
self.scenario_opex: ScenarioOpexRepository | None = None
|
||||
self.users: UserRepository | None = None
|
||||
self.roles: RoleRepository | None = None
|
||||
self.pricing_settings: PricingSettingsRepository | None = None
|
||||
self.navigation: NavigationRepository | None = None
|
||||
|
||||
def __enter__(self) -> "UnitOfWork":
|
||||
self.session = self._session_factory()
|
||||
@@ -62,17 +64,18 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.project_profitability = ProjectProfitabilityRepository(
|
||||
self.session)
|
||||
self.project_capex = ProjectCapexRepository(self.session)
|
||||
self.project_processing_opex = ProjectProcessingOpexRepository(
|
||||
self.project_opex = ProjectOpexRepository(
|
||||
self.session)
|
||||
self.scenario_profitability = ScenarioProfitabilityRepository(
|
||||
self.session
|
||||
)
|
||||
self.scenario_capex = ScenarioCapexRepository(self.session)
|
||||
self.scenario_processing_opex = ScenarioProcessingOpexRepository(
|
||||
self.scenario_opex = ScenarioOpexRepository(
|
||||
self.session)
|
||||
self.users = UserRepository(self.session)
|
||||
self.roles = RoleRepository(self.session)
|
||||
self.pricing_settings = PricingSettingsRepository(self.session)
|
||||
self.navigation = NavigationRepository(self.session)
|
||||
self._scenario_validator = ScenarioComparisonValidator()
|
||||
return self
|
||||
|
||||
@@ -90,13 +93,14 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.simulation_parameters = None
|
||||
self.project_profitability = None
|
||||
self.project_capex = None
|
||||
self.project_processing_opex = None
|
||||
self.project_opex = None
|
||||
self.scenario_profitability = None
|
||||
self.scenario_capex = None
|
||||
self.scenario_processing_opex = None
|
||||
self.scenario_opex = None
|
||||
self.users = None
|
||||
self.roles = None
|
||||
self.pricing_settings = None
|
||||
self.navigation = None
|
||||
|
||||
def flush(self) -> None:
|
||||
if not self.session:
|
||||
|
||||
@@ -367,17 +367,17 @@ a.sidebar-brand:focus {
|
||||
.sidebar-nav-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-chevron {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 1.2rem;
|
||||
font-size: 4.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -388,8 +388,9 @@ a.sidebar-brand:focus {
|
||||
|
||||
.nav-chevron:hover,
|
||||
.nav-chevron:focus {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.nav-chevron:disabled {
|
||||
@@ -1188,8 +1189,16 @@ footer a:focus {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-nav-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-link-block {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
flex: 1 1 140px;
|
||||
flex: 1 1 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1219,6 +1228,10 @@ footer a:focus {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-open .app-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
body.sidebar-open .app-sidebar {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -1227,7 +1240,7 @@ footer a:focus {
|
||||
width: min(320px, 82vw);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
z-index: 900;
|
||||
z-index: 999;
|
||||
box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4);
|
||||
}
|
||||
|
||||
@@ -1235,9 +1248,4 @@ footer a:focus {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.sidebar-open .app-main {
|
||||
position: relative;
|
||||
z-index: 950;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,108 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card:hover,
|
||||
.project-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 22px 45px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.project-card__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.project-card__title a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-card__title a:hover,
|
||||
.project-card__title a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-card__type {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.project-card__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-subtle);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.project-card__meta {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.project-card__meta div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.project-card__meta dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.project-card__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.project-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-card__links {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-card__links .btn-link {
|
||||
padding: 3px 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.project-metrics {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
@@ -87,6 +189,163 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.project-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list li a {
|
||||
font-weight: 600;
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick-link-list li a:hover,
|
||||
.quick-link-list li a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.quick-link-list p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
background: rgba(21, 27, 35, 0.85);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.scenario-item__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scenario-item__header a:hover,
|
||||
.scenario-item__header a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pill--draft {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-pill--active {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status-pill--archived {
|
||||
background: rgba(148, 163, 184, 0.24);
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.scenario-item__meta {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.scenario-item__meta dt {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.scenario-item__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scenario-item__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-item__actions .btn-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -151,6 +410,16 @@
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
|
||||
@@ -106,6 +106,76 @@
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.scenario-form .card {
|
||||
background: rgba(21, 27, 35, 0.9);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenario-form .card h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-form .form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenario-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scenario-form .form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.scenario-form .form-group input,
|
||||
.scenario-form .form-group select,
|
||||
.scenario-form .form-group textarea {
|
||||
padding: 0.75rem 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--card-border);
|
||||
background: rgba(8, 12, 19, 0.78);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.scenario-form .form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.scenario-form .form-group input:focus,
|
||||
.scenario-form .form-group select:focus,
|
||||
.scenario-form .form-group textarea:focus {
|
||||
outline: 2px solid var(--brand-2);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form-group--error input,
|
||||
.form-group--error select,
|
||||
.form-group--error textarea {
|
||||
border-color: rgba(209, 75, 75, 0.6);
|
||||
box-shadow: 0 0 0 1px rgba(209, 75, 75, 0.3);
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
width: 100%;
|
||||
@@ -165,12 +235,214 @@
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.scenario-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.quick-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.quick-link-list li a {
|
||||
font-weight: 600;
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick-link-list li a:hover,
|
||||
.quick-link-list li a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.quick-link-list p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.status-pill--draft {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.status-pill--active {
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status-pill--archived {
|
||||
background: rgba(148, 163, 184, 0.24);
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.scenario-layout {
|
||||
grid-template-columns: 1.1fr 1.9fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.scenario-portfolio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.scenario-portfolio__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item {
|
||||
background: rgba(21, 27, 35, 0.85);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.scenario-item__header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.scenario-item__header a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scenario-item__header a:hover,
|
||||
.scenario-item__header a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scenario-item__meta {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.scenario-item__meta dt {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.scenario-item__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.scenario-item__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-item__actions .btn-link {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.scenario-item {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scenario-item__body {
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
.scenario-context-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-context-card .definition-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-defaults {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-defaults li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.scenario-defaults li strong {
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
|
||||
230
static/js/navigation_sidebar.js
Normal file
230
static/js/navigation_sidebar.js
Normal file
@@ -0,0 +1,230 @@
|
||||
(function () {
|
||||
const NAV_ENDPOINT = "/navigation/sidebar";
|
||||
const SIDEBAR_SELECTOR = ".sidebar-nav";
|
||||
const DATA_SOURCE_ATTR = "navigationSource";
|
||||
const ROLE_ATTR = "navigationRoles";
|
||||
|
||||
function onReady(callback) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", callback, { once: true });
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function isActivePath(pathname, matchPrefix) {
|
||||
if (!matchPrefix) {
|
||||
return false;
|
||||
}
|
||||
if (matchPrefix === "/") {
|
||||
return pathname === "/";
|
||||
}
|
||||
return pathname.startsWith(matchPrefix);
|
||||
}
|
||||
|
||||
function createAnchor({
|
||||
href,
|
||||
label,
|
||||
matchPrefix,
|
||||
tooltip,
|
||||
isExternal,
|
||||
isActive,
|
||||
className,
|
||||
}) {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.className = className + (isActive ? " is-active" : "");
|
||||
anchor.dataset.matchPrefix = matchPrefix || href;
|
||||
if (tooltip) {
|
||||
anchor.title = tooltip;
|
||||
}
|
||||
if (isExternal) {
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener noreferrer";
|
||||
anchor.classList.add("is-external");
|
||||
}
|
||||
anchor.textContent = label;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function buildLinkBlock(link, pathname) {
|
||||
if (!link || !link.href) {
|
||||
return null;
|
||||
}
|
||||
const matchPrefix = link.match_prefix || link.matchPrefix || link.href;
|
||||
const isActive = isActivePath(pathname, matchPrefix);
|
||||
|
||||
const block = document.createElement("div");
|
||||
block.className = "sidebar-link-block";
|
||||
if (typeof link.id === "number") {
|
||||
block.dataset.linkId = String(link.id);
|
||||
}
|
||||
|
||||
const anchor = createAnchor({
|
||||
href: link.href,
|
||||
label: link.label,
|
||||
matchPrefix,
|
||||
tooltip: link.tooltip,
|
||||
isExternal: Boolean(link.is_external ?? link.isExternal),
|
||||
isActive,
|
||||
className: "sidebar-link",
|
||||
});
|
||||
block.appendChild(anchor);
|
||||
|
||||
const children = Array.isArray(link.children) ? link.children : [];
|
||||
if (children.length > 0) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "sidebar-sublinks";
|
||||
for (const child of children) {
|
||||
if (!child || !child.href) {
|
||||
continue;
|
||||
}
|
||||
const childMatch =
|
||||
child.match_prefix || child.matchPrefix || child.href;
|
||||
const childActive = isActivePath(pathname, childMatch);
|
||||
const childAnchor = createAnchor({
|
||||
href: child.href,
|
||||
label: child.label,
|
||||
matchPrefix: childMatch,
|
||||
tooltip: child.tooltip,
|
||||
isExternal: Boolean(child.is_external ?? child.isExternal),
|
||||
isActive: childActive,
|
||||
className: "sidebar-sublink",
|
||||
});
|
||||
container.appendChild(childAnchor);
|
||||
}
|
||||
if (container.children.length > 0) {
|
||||
block.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function buildGroupSection(group, pathname) {
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const links = Array.isArray(group.links) ? group.links : [];
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section";
|
||||
if (typeof group.id === "number") {
|
||||
section.dataset.groupId = String(group.id);
|
||||
}
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = group.label;
|
||||
section.appendChild(label);
|
||||
|
||||
const linksContainer = document.createElement("div");
|
||||
linksContainer.className = "sidebar-section-links";
|
||||
|
||||
for (const link of links) {
|
||||
const block = buildLinkBlock(link, pathname);
|
||||
if (block) {
|
||||
linksContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
if (linksContainer.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
section.appendChild(linksContainer);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildEmptyState() {
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section sidebar-empty-state";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = "Navigation";
|
||||
section.appendChild(label);
|
||||
|
||||
const copyWrapper = document.createElement("div");
|
||||
copyWrapper.className = "sidebar-section-links";
|
||||
|
||||
const copy = document.createElement("p");
|
||||
copy.className = "sidebar-empty-copy";
|
||||
copy.textContent = "Navigation is unavailable.";
|
||||
copyWrapper.appendChild(copy);
|
||||
|
||||
section.appendChild(copyWrapper);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderSidebar(navContainer, payload) {
|
||||
const pathname = window.location.pathname;
|
||||
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
||||
navContainer.replaceChildren();
|
||||
|
||||
const rendered = [];
|
||||
for (const group of groups) {
|
||||
const section = buildGroupSection(group, pathname);
|
||||
if (section) {
|
||||
rendered.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
if (rendered.length === 0) {
|
||||
navContainer.appendChild(buildEmptyState());
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of rendered) {
|
||||
navContainer.appendChild(section);
|
||||
}
|
||||
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client";
|
||||
const roles = Array.isArray(payload?.roles) ? payload.roles : [];
|
||||
if (roles.length > 0) {
|
||||
navContainer.dataset[ROLE_ATTR] = roles.join(",");
|
||||
} else {
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSidebar(navContainer) {
|
||||
try {
|
||||
const response = await fetch(NAV_ENDPOINT, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
console.warn(
|
||||
"Navigation sidebar hydration failed with status",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderSidebar(navContainer, payload);
|
||||
} catch (error) {
|
||||
console.warn("Navigation sidebar hydration failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
onReady(() => {
|
||||
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
|
||||
if (!navContainer) {
|
||||
return;
|
||||
}
|
||||
hydrateSidebar(navContainer);
|
||||
});
|
||||
})();
|
||||
@@ -1,14 +1,35 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const table = document.querySelector("[data-project-table]");
|
||||
const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : [];
|
||||
const container = document.querySelector("[data-project-table]");
|
||||
const filterInput = document.querySelector("[data-project-filter]");
|
||||
|
||||
if (table && filterInput) {
|
||||
const resolveFilterItems = () => {
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.from(
|
||||
container.querySelectorAll("[data-project-entry]")
|
||||
);
|
||||
|
||||
if (entries.length) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (container.tagName === "TABLE") {
|
||||
return Array.from(container.querySelectorAll("tbody tr"));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const filterItems = resolveFilterItems();
|
||||
|
||||
if (container && filterInput && filterItems.length) {
|
||||
filterInput.addEventListener("input", () => {
|
||||
const query = filterInput.value.trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const match = row.textContent.toLowerCase().includes(query);
|
||||
row.style.display = match ? "" : "none";
|
||||
filterItems.forEach((item) => {
|
||||
const match = item.textContent.toLowerCase().includes(query);
|
||||
item.style.display = match ? "" : "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<script src="/static/js/exports.js" defer></script>
|
||||
<script src="/static/js/imports.js" defer></script>
|
||||
<script src="/static/js/notifications.js" defer></script>
|
||||
<script src="/static/js/navigation_sidebar.js" defer></script>
|
||||
<script src="/static/js/navigation.js" defer></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
<span class="brand-subtitle">Mining Planner</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="sidebar-nav-controls">
|
||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">←</button>
|
||||
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">→</button>
|
||||
</div>
|
||||
{% include "partials/sidebar_nav.html" %}
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,78 @@
|
||||
{% set dashboard_href = request.url_for('dashboard.home') if request else '/' %}
|
||||
{% set projects_href = request.url_for('projects.project_list_page') if request
|
||||
else '/projects/ui' %} {% set project_create_href =
|
||||
request.url_for('projects.create_project_form') if request else
|
||||
'/projects/create' %} {% set auth_session = request.state.auth_session if
|
||||
request else None %} {% set is_authenticated = auth_session and
|
||||
auth_session.is_authenticated %} {% if is_authenticated %} {% set logout_href =
|
||||
request.url_for('auth.logout') if request else '/logout' %} {% set account_links
|
||||
= [ {"href": logout_href, "label": "Logout", "match_prefix": "/logout"} ] %} {%
|
||||
else %} {% set login_href = request.url_for('auth.login_form') if request else
|
||||
'/login' %} {% set register_href = request.url_for('auth.register_form') if
|
||||
request else '/register' %} {% set forgot_href =
|
||||
request.url_for('auth.password_reset_request_form') if request else
|
||||
'/forgot-password' %} {% set account_links = [ {"href": login_href, "label":
|
||||
"Login", "match_prefix": "/login"}, {"href": register_href, "label": "Register",
|
||||
"match_prefix": "/register"}, {"href": forgot_href, "label": "Forgot Password",
|
||||
"match_prefix": "/forgot-password"} ] %} {% endif %} {% set nav_groups = [ {
|
||||
"label": "Workspace", "links": [ {"href": dashboard_href, "label": "Dashboard",
|
||||
"match_prefix": "/"}, {"href": projects_href, "label": "Projects",
|
||||
"match_prefix": "/projects"}, {"href": project_create_href, "label": "New
|
||||
Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label":
|
||||
"Imports", "match_prefix": "/imports"}, {"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 } ] %}
|
||||
{% set sidebar_nav = get_sidebar_navigation(request) %}
|
||||
{% set nav_groups = sidebar_nav.groups if sidebar_nav else [] %}
|
||||
{% set current_path = request.url.path if request else '' %}
|
||||
|
||||
<nav class="sidebar-nav" aria-label="Primary navigation">
|
||||
{% set current_path = request.url.path if request else '' %} {% for group in
|
||||
nav_groups %} {% if group.links %}
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-label">{{ group.label }}</div>
|
||||
<div class="sidebar-section-links">
|
||||
{% for link in group.links %} {% set href = link.href | string %} {% set
|
||||
match_prefix = link.get('match_prefix', href) | string %} {% if
|
||||
match_prefix == '/' %} {% set is_active = current_path == '/' %} {% else
|
||||
%} {% set is_active = current_path.startswith(match_prefix) %} {% endif %}
|
||||
<div class="sidebar-link-block">
|
||||
<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>
|
||||
<nav
|
||||
class="sidebar-nav"
|
||||
aria-label="Primary navigation"
|
||||
data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
|
||||
>
|
||||
<div class="sidebar-nav-controls">
|
||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">ᐊ</button>
|
||||
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">ᐅ</button>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
|
||||
<a class="btn" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||
</div>
|
||||
@@ -46,65 +47,91 @@
|
||||
</section>
|
||||
|
||||
<div class="project-layout">
|
||||
<section class="card">
|
||||
<h2>Project Overview</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ project.description or 'No description provided.' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ project.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest Scenario Update</dt>
|
||||
<dd>{{ scenario_stats.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_stats.latest_update else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="project-column">
|
||||
<section class="card">
|
||||
<h2>Project Overview</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ project.description or 'No description provided.' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ project.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ project.updated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest Scenario Update</dt>
|
||||
<dd>{{ scenario_stats.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_stats.latest_update else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<h2>Scenarios</h2>
|
||||
<section class="card project-actions-card">
|
||||
<h2>Next Steps</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Capture a new scenario</a>
|
||||
<p>Create an additional assumption set under this project.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Review scenario portfolio</a>
|
||||
<p>Compare scenarios and jump into calculators with inherited context.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Update project details</a>
|
||||
<p>Revise metadata or operation type for reporting.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card project-scenarios-card">
|
||||
<header class="project-scenarios-card__header">
|
||||
<div>
|
||||
<h2>Scenarios</h2>
|
||||
<p class="text-muted">Project scenarios inherit pricing and provide entry points to profitability planning.</p>
|
||||
</div>
|
||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
</header>
|
||||
{% if scenarios %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Currency</th>
|
||||
<th>Primary Resource</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scenario in scenarios %}
|
||||
<tr>
|
||||
<td>{{ scenario.name }}</td>
|
||||
<td>{{ scenario.status.value.title() }}</td>
|
||||
<td>{{ scenario.currency or '—' }}</td>
|
||||
<td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td>
|
||||
<td class="text-right">
|
||||
<a class="table-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="table-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul class="scenario-list">
|
||||
{% for scenario in scenarios %}
|
||||
<li class="scenario-item">
|
||||
<div class="scenario-item__body">
|
||||
<div class="scenario-item__header">
|
||||
<h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
|
||||
<span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
|
||||
</div>
|
||||
<dl class="scenario-item__meta">
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="scenario-item__actions">
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -16,26 +16,21 @@
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form class="form project-form" method="post" action="{{ form_action }}">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if project %}Edit Project{% else %}Create Project{% endif %}</h1>
|
||||
<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-group">
|
||||
<label for="name">Name</label>
|
||||
|
||||
@@ -17,48 +17,61 @@
|
||||
class="form-control"
|
||||
placeholder="Filter projects..."
|
||||
data-project-filter
|
||||
aria-label="Filter projects"
|
||||
/>
|
||||
<a class="btn btn-primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if projects %}
|
||||
<table class="projects-table" data-project-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Type</th>
|
||||
<th>Scenarios</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr>
|
||||
<td class="table-cell-actions">
|
||||
{{ project.name }}
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
data-export-trigger
|
||||
data-export-target="projects"
|
||||
title="Export projects dataset"
|
||||
>
|
||||
<span aria-hidden="true">⇩</span>
|
||||
<span class="sr-only">Export</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ project.location or '—' }}</td>
|
||||
<td>{{ project.operation_type.value.replace('_', ' ') | title }}</td>
|
||||
<td>{{ project.scenario_count }}</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View</a>
|
||||
<section class="projects-grid" data-project-table>
|
||||
{% for project in projects %}
|
||||
<article class="project-card" data-project-entry>
|
||||
<header class="project-card__header">
|
||||
<h2 class="project-card__title">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
</h2>
|
||||
<span class="project-card__type badge">{{ project.operation_type.value.replace('_', ' ') | title }}</span>
|
||||
</header>
|
||||
|
||||
<p class="project-card__description">
|
||||
{{ project.description or 'No description provided yet.' }}
|
||||
</p>
|
||||
|
||||
<dl class="project-card__meta">
|
||||
<div>
|
||||
<dt>Scenarios</dt>
|
||||
<dd><span class="badge badge-pill">{{ project.scenario_count }}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<footer class="project-card__footer">
|
||||
<div class="project-card__links">
|
||||
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View Project</a>
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
<a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
data-export-trigger
|
||||
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 %}
|
||||
<p>No projects yet. <a href="{{ url_for('projects.create_project_form') }}">Create your first project.</a></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Initial Capex Planner · CalMiner{% endblock %}
|
||||
{% block title %}Capex Planner · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
@@ -10,17 +10,24 @@
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Initial Capex Planner</span>
|
||||
<span aria-current="page">Capex Planner</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Initial Capex Planner</h1>
|
||||
<p class="text-muted">Capture upfront capital requirements and review categorized totals.</p>
|
||||
<h1>Capex Planner</h1>
|
||||
<p class="text-muted">Plan capital requirements for {{ scenario.name if scenario else 'this scenario' }}.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
{% if scenario_url %}
|
||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit" form="capex-form">Save & Calculate</button>
|
||||
</div>
|
||||
@@ -188,7 +195,7 @@
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Total Initial Capex</h3>
|
||||
<h3>Total Capex</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.overall | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
@@ -249,7 +256,7 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,28 +13,36 @@
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
{% set profitability_href = url_for('calculations.profitability_form') %}
|
||||
{% set processing_opex_href = url_for('calculations.processing_opex_form') %}
|
||||
{% set profitability_href = '/calculations/profitability' %}
|
||||
{% set opex_href = url_for('calculations.opex_form') %}
|
||||
{% set capex_href = url_for('calculations.capex_form') %}
|
||||
{% set scenario_list_href = url_for('scenarios.project_scenario_list', project_id=project.id) %}
|
||||
{% if project and scenario %}
|
||||
{% set profitability_href = profitability_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% set processing_opex_href = processing_opex_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 opex_href = opex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1>{{ scenario.name }}</h1>
|
||||
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
|
||||
<p class="text-muted">
|
||||
Part of <a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a>
|
||||
<a class="btn" href="{{ scenario_list_href }}">Scenario Portfolio</a>
|
||||
<a class="btn" href="{{ profitability_href }}">Profitability Calculator</a>
|
||||
<a class="btn" href="{{ processing_opex_href }}">Processing Opex Planner</a>
|
||||
<a class="btn" href="{{ capex_href }}">Initial Capex Planner</a>
|
||||
<a class="btn" href="{{ opex_href }}">Opex Planner</a>
|
||||
<a class="btn" href="{{ capex_href }}">Capex Planner</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scenario-metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Status</h2>
|
||||
<p class="metric-value status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</p>
|
||||
<span class="metric-caption">Lifecycle state</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Financial Inputs</h2>
|
||||
<p class="metric-value">{{ scenario_metrics.financial_count }}</p>
|
||||
@@ -50,37 +58,54 @@
|
||||
<p class="metric-value">{{ scenario_metrics.currency or '—' }}</p>
|
||||
<span class="metric-caption">Financial reporting</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Primary Resource</h2>
|
||||
<p class="metric-value">{{ scenario_metrics.primary_resource or '—' }}</p>
|
||||
<span class="metric-caption">Scenario focus</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="scenario-layout">
|
||||
<section class="card">
|
||||
<h2>Scenario Details</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ scenario.description or 'No description provided.' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>
|
||||
{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Discount Rate</dt>
|
||||
<dd>{{ scenario.discount_rate or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<div class="scenario-column">
|
||||
<section class="card">
|
||||
<h2>Scenario Overview</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
<dd>{{ scenario.description or 'No description provided.' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Discount Rate</dt>
|
||||
<dd>{{ scenario.discount_rate or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario_metrics.primary_resource or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
</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 scenario’s assumptions as defaults.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ scenario_list_href }}">Browse all project scenarios</a>
|
||||
<p>Compare assumption sets and launch calculators in context.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Update scenario assumptions</a>
|
||||
<p>Adjust dates, status, or drivers before recalculations.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Financial Inputs</h2>
|
||||
|
||||
@@ -16,76 +16,133 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
|
||||
<p class="text-muted">Configure assumptions and metadata for this scenario.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn primary" type="submit">Save Scenario</button>
|
||||
</div>
|
||||
</header>
|
||||
{% set error = error | default(None) %}
|
||||
{% set error_field = error_field | default(None) %}
|
||||
{% set currency_error = error if error_field == 'currency' else None %}
|
||||
{% set name_error = error if error_field == 'name' else None %}
|
||||
|
||||
{% if error %}
|
||||
{% if error and not error_field %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" required value="{{ scenario.name if scenario else '' }}" />
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
|
||||
<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 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>
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="5" placeholder="Describe the key drivers or differences for this scenario.">{{ scenario.description if scenario else '' }}</textarea>
|
||||
<p class="field-help">Summarise what distinguishes this scenario for collaborators and future audits.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
|
||||
142
templates/scenarios/list.html
Normal file
142
templates/scenarios/list.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Scenarios · {{ project.name }} · CalMiner{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/scenarios.css" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
<span aria-current="page">Scenarios</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Scenarios</h1>
|
||||
<p class="text-muted">Assumption sets and calculators for {{ project.name }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scenario-metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Total Scenarios</h2>
|
||||
<p class="metric-value">{{ scenario_totals.total }}</p>
|
||||
<span class="metric-caption">Across this project</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Active</h2>
|
||||
<p class="metric-value">{{ scenario_totals.active }}</p>
|
||||
<span class="metric-caption">Currently live analyses</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Draft</h2>
|
||||
<p class="metric-value">{{ scenario_totals.draft }}</p>
|
||||
<span class="metric-caption">Awaiting validation</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Archived</h2>
|
||||
<p class="metric-value">{{ scenario_totals.archived }}</p>
|
||||
<span class="metric-caption">Historical references</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="scenario-layout">
|
||||
<div class="scenario-column">
|
||||
<section class="card">
|
||||
<h2>Project Context</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Project</dt>
|
||||
<dd>{{ project.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type.value.replace('_', ' ') | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest Update</dt>
|
||||
<dd>{{ scenario_totals.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_totals.latest_update else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card quick-actions-card">
|
||||
<h2>Quick Actions</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Capture a new scenario</a>
|
||||
<p>Add additional assumption sets for profitability planning.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">Review project overview</a>
|
||||
<p>Cross-check project metadata before running calculators.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card scenario-portfolio">
|
||||
<header class="scenario-portfolio__header">
|
||||
<div>
|
||||
<h2>Scenario Portfolio</h2>
|
||||
<p class="text-muted">Each scenario below inherits pricing defaults and links directly into calculators.</p>
|
||||
</div>
|
||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
</header>
|
||||
{% if scenarios %}
|
||||
<ul class="scenario-list">
|
||||
{% for scenario in scenarios %}
|
||||
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||
{% set opex_href = url_for('calculations.opex_form') ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
{% set capex_href = url_for('calculations.capex_form') ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
||||
<li class="scenario-item">
|
||||
<div class="scenario-item__body">
|
||||
<div class="scenario-item__header">
|
||||
<h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
|
||||
<span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
|
||||
</div>
|
||||
<dl class="scenario-item__meta">
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ (scenario.updated_at or scenario.created_at).strftime('%Y-%m-%d %H:%M') if scenario.updated_at or scenario.created_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="scenario-item__actions">
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
<a class="btn btn-link" href="{{ profitability_href }}">Profitability</a>
|
||||
<a class="btn btn-link" href="{{ opex_href }}">Opex</a>
|
||||
<a class="btn btn-link" href="{{ capex_href }}">Capex</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Processing Opex Planner · CalMiner{% endblock %}
|
||||
{% block title %}Opex Planner · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
@@ -10,19 +10,26 @@
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Processing Opex Planner</span>
|
||||
<span aria-current="page">Opex Planner</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
{% if scenario_url %}
|
||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit" form="processing-opex-form">Save & 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 & Calculate</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -47,7 +54,7 @@
|
||||
</div>
|
||||
{% 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="options[persist]" value="{{ '1' if options and options.persist else '' }}" />
|
||||
|
||||
|
||||
@@ -19,8 +19,15 @@
|
||||
<p class="text-muted">Evaluate revenue, costs, and key financial metrics for a scenario.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
{% if scenario_url %}
|
||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit" form="profitability-form">Run Calculation</button>
|
||||
</div>
|
||||
@@ -104,8 +111,8 @@
|
||||
<input id="smelting_charge" name="smelting_charge" type="number" min="0" step="0.01" value="{{ data.smelting_charge }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="processing_opex">Processing Opex (per period)</label>
|
||||
<input id="processing_opex" name="processing_opex" type="number" min="0" step="0.01" value="{{ data.processing_opex }}" />
|
||||
<label for="opex">Opex (per period)</label>
|
||||
<input id="opex" name="opex" type="number" min="0" step="0.01" value="{{ data.opex }}" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -172,8 +179,8 @@
|
||||
<legend>Capital & Discounting</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="initial_capex">Initial Capex</label>
|
||||
<input id="initial_capex" name="initial_capex" type="number" min="0" step="0.01" value="{{ data.initial_capex }}" />
|
||||
<label for="capex">Capex</label>
|
||||
<input id="capex" name="capex" type="number" min="0" step="0.01" value="{{ data.capex }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sustaining_capex">Sustaining Capex (per period)</label>
|
||||
@@ -261,16 +268,16 @@
|
||||
<h3>Cost Breakdown</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Processing Opex</span>
|
||||
<strong>{{ result.costs.processing_opex_total | currency_display(result.currency) }}</strong>
|
||||
<span>Opex</span>
|
||||
<strong>{{ result.costs.opex_total | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Sustaining Capex</span>
|
||||
<strong>{{ result.costs.sustaining_capex_total | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Initial Capex</span>
|
||||
<strong>{{ result.costs.initial_capex | currency_display(result.currency) }}</strong>
|
||||
<span>Capex</span>
|
||||
<strong>{{ result.costs.capex | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
@@ -304,7 +311,7 @@
|
||||
<tr>
|
||||
<th scope="col">Period</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">Net Cash Flow</th>
|
||||
</tr>
|
||||
@@ -314,7 +321,7 @@
|
||||
<tr>
|
||||
<th scope="row">{{ entry.period }}</th>
|
||||
<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.net | currency_display(result.currency) }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_capex_calculation_html_flow(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}"
|
||||
)
|
||||
assert form_page.status_code == 200
|
||||
assert "Initial Capex Planner" in form_page.text
|
||||
assert "Capex Planner" in form_page.text
|
||||
|
||||
response = client.post(
|
||||
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 "Initial capex calculation completed successfully." in response.text
|
||||
assert "Capex calculation completed successfully." in response.text
|
||||
assert "Capex Summary" in response.text
|
||||
assert "$1,200,000.00" in response.text or "1,200,000" in response.text
|
||||
assert "USD" in response.text
|
||||
|
||||
@@ -15,7 +15,7 @@ def _create_project(client: TestClient, name: str) -> int:
|
||||
"name": name,
|
||||
"location": "Nevada",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Project for processing opex testing",
|
||||
"description": "Project for opex testing",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
@@ -37,7 +37,7 @@ def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_processing_opex_calculation_html_flow(
|
||||
def test_opex_calculation_html_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
@@ -45,13 +45,13 @@ def test_processing_opex_calculation_html_flow(
|
||||
scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario")
|
||||
|
||||
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 "Processing Opex Planner" in form_page.text
|
||||
assert "Opex Planner" in form_page.text
|
||||
|
||||
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={
|
||||
"components[0][name]": "Power",
|
||||
"components[0][category]": "energy",
|
||||
@@ -75,21 +75,21 @@ def test_processing_opex_calculation_html_flow(
|
||||
"parameters[evaluation_horizon_years]": "3",
|
||||
"parameters[apply_escalation]": "1",
|
||||
"options[persist]": "1",
|
||||
"options[snapshot_notes]": "Processing opex HTML flow",
|
||||
"options[snapshot_notes]": "Opex HTML flow",
|
||||
},
|
||||
)
|
||||
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 "$22,000.00" in response.text or "22,000" in response.text
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_processing_opex is not None
|
||||
assert uow.scenario_processing_opex is not None
|
||||
assert uow.project_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)
|
||||
scenario_snapshots = uow.scenario_processing_opex.list_for_scenario(
|
||||
scenario_snapshots = uow.scenario_opex.list_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert len(project_snapshots) == 1
|
||||
@@ -119,7 +119,7 @@ def test_processing_opex_calculation_html_flow(
|
||||
assert scenario_snapshot.currency_code == "USD"
|
||||
|
||||
|
||||
def test_processing_opex_calculation_json_flow(
|
||||
def test_opex_calculation_json_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
@@ -170,7 +170,7 @@ def test_processing_opex_calculation_json_flow(
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_processing_opex is not None
|
||||
assert uow.scenario_processing_opex is not None
|
||||
assert uow.project_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)
|
||||
scenario_snapshot = uow.scenario_processing_opex.latest_for_scenario(
|
||||
scenario_snapshot = uow.scenario_opex.latest_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert project_snapshot is not None
|
||||
@@ -232,7 +232,7 @@ def test_processing_opex_calculation_json_flow(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["form", "json"])
|
||||
def test_processing_opex_calculation_currency_mismatch(
|
||||
def test_opex_calculation_currency_mismatch(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
content_type: str,
|
||||
@@ -260,7 +260,7 @@ def test_processing_opex_calculation_currency_mismatch(
|
||||
"options": {"persist": True},
|
||||
}
|
||||
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,
|
||||
)
|
||||
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", []))
|
||||
else:
|
||||
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={
|
||||
"components[0][name]": "Power",
|
||||
"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)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_processing_opex is not None
|
||||
assert uow.scenario_processing_opex is not None
|
||||
assert uow.project_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)
|
||||
scenario_snapshots = uow.scenario_processing_opex.list_for_scenario(
|
||||
scenario_snapshots = uow.scenario_opex.list_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert project_snapshots == []
|
||||
@@ -1,16 +1,16 @@
|
||||
import pytest
|
||||
|
||||
from schemas.calculations import (
|
||||
ProcessingOpexCalculationRequest,
|
||||
ProcessingOpexComponentInput,
|
||||
ProcessingOpexOptions,
|
||||
ProcessingOpexParameters,
|
||||
OpexCalculationRequest,
|
||||
OpexComponentInput,
|
||||
OpexOptions,
|
||||
OpexParameters,
|
||||
)
|
||||
from services.calculations import calculate_processing_opex
|
||||
from services.calculations import calculate_opex
|
||||
from services.exceptions import OpexValidationError
|
||||
|
||||
|
||||
def _component(**overrides) -> ProcessingOpexComponentInput:
|
||||
def _component(**overrides) -> OpexComponentInput:
|
||||
defaults = {
|
||||
"id": None,
|
||||
"name": "Component",
|
||||
@@ -24,11 +24,11 @@ def _component(**overrides) -> ProcessingOpexComponentInput:
|
||||
"notes": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ProcessingOpexComponentInput(**defaults)
|
||||
return OpexComponentInput(**defaults)
|
||||
|
||||
|
||||
def test_calculate_processing_opex_success():
|
||||
request = ProcessingOpexCalculationRequest(
|
||||
def test_calculate_opex_success():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(
|
||||
name="Power",
|
||||
@@ -49,17 +49,17 @@ def test_calculate_processing_opex_success():
|
||||
period_end=2,
|
||||
),
|
||||
],
|
||||
parameters=ProcessingOpexParameters(
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=5,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
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.options.persist is True
|
||||
@@ -89,10 +89,10 @@ def test_calculate_processing_opex_success():
|
||||
assert result.components[1].frequency == "quarterly"
|
||||
|
||||
|
||||
def test_calculate_processing_opex_currency_mismatch():
|
||||
request = ProcessingOpexCalculationRequest(
|
||||
def test_calculate_opex_currency_mismatch():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(currency="USD")],
|
||||
parameters=ProcessingOpexParameters(
|
||||
parameters=OpexParameters(
|
||||
currency_code="CAD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
@@ -101,16 +101,16 @@ def test_calculate_processing_opex_currency_mismatch():
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_processing_opex(request)
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Component currency does not match" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_processing_opex_unsupported_frequency():
|
||||
request = ProcessingOpexCalculationRequest(
|
||||
def test_calculate_opex_unsupported_frequency():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(frequency="biweekly")],
|
||||
parameters=ProcessingOpexParameters(
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
@@ -119,28 +119,28 @@ def test_calculate_processing_opex_unsupported_frequency():
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_processing_opex(request)
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Unsupported frequency" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_processing_opex_requires_components():
|
||||
request = ProcessingOpexCalculationRequest(components=[])
|
||||
def test_calculate_opex_requires_components():
|
||||
request = OpexCalculationRequest(components=[])
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def test_calculate_processing_opex_extends_evaluation_horizon():
|
||||
request = ProcessingOpexCalculationRequest(
|
||||
def test_calculate_opex_extends_evaluation_horizon():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(period_start=1, period_end=4),
|
||||
],
|
||||
parameters=ProcessingOpexParameters(
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
discount_rate_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 result.timeline[-1].period == 4
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models import MiningOperationType
|
||||
|
||||
|
||||
class TestDashboardRoute:
|
||||
def test_renders_empty_state(self, client: TestClient) -> None:
|
||||
@@ -17,9 +19,18 @@ class TestDashboardRoute:
|
||||
|
||||
class TestProjectUIRoutes:
|
||||
def test_projects_ui_page_resolves(self, client: TestClient) -> None:
|
||||
create_payload = {
|
||||
"name": "UI Project",
|
||||
"location": "Peru",
|
||||
"operation_type": MiningOperationType.OPEN_PIT.value,
|
||||
"description": "Project for UI validation",
|
||||
}
|
||||
client.post("/projects", json=create_payload)
|
||||
|
||||
response = client.get("/projects/ui")
|
||||
assert response.status_code == 200
|
||||
assert "Projects" in response.text
|
||||
assert "project-card" in response.text
|
||||
|
||||
def test_projects_create_form_resolves(self, client: TestClient) -> None:
|
||||
response = client.get("/projects/create")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||
@@ -17,6 +20,32 @@ def _create_project(client: TestClient, name: str = "Alpha Project") -> dict:
|
||||
return response.json()
|
||||
|
||||
|
||||
def _create_scenario(
|
||||
client: TestClient,
|
||||
project_id: int,
|
||||
*,
|
||||
name: str = "Scenario A",
|
||||
status: ScenarioStatus = ScenarioStatus.DRAFT,
|
||||
currency: str | None = "USD",
|
||||
primary_resource: ResourceType | None = ResourceType.DIESEL,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"status": status.value,
|
||||
}
|
||||
if currency:
|
||||
payload["currency"] = currency
|
||||
if primary_resource:
|
||||
payload["primary_resource"] = primary_resource.value
|
||||
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_project_crud_cycle(client: TestClient) -> None:
|
||||
project = _create_project(client)
|
||||
project_id = project["id"]
|
||||
@@ -29,10 +58,13 @@ def test_project_crud_cycle(client: TestClient) -> None:
|
||||
project_ids = {item["id"] for item in list_response.json()}
|
||||
assert project_id in project_ids
|
||||
|
||||
update_payload = {"description": "Updated project description", "location": "Peru"}
|
||||
update_response = client.put(f"/projects/{project_id}", json=update_payload)
|
||||
update_payload = {
|
||||
"description": "Updated project description", "location": "Peru"}
|
||||
update_response = client.put(
|
||||
f"/projects/{project_id}", json=update_payload)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["description"] == "Updated project description"
|
||||
assert update_response.json(
|
||||
)["description"] == "Updated project description"
|
||||
assert update_response.json()["location"] == "Peru"
|
||||
|
||||
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()}
|
||||
assert scenario_id in listed_ids
|
||||
|
||||
update_payload = {"description": "Revised assumptions", "status": ScenarioStatus.ACTIVE.value}
|
||||
update_response = client.put(f"/scenarios/{scenario_id}", json=update_payload)
|
||||
update_payload = {"description": "Revised assumptions",
|
||||
"status": ScenarioStatus.ACTIVE.value}
|
||||
update_response = client.put(
|
||||
f"/scenarios/{scenario_id}", json=update_payload)
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["description"] == "Revised assumptions"
|
||||
@@ -125,10 +159,12 @@ def test_create_scenario_conflict_returns_409(client: TestClient) -> None:
|
||||
project_id = project["id"]
|
||||
|
||||
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
|
||||
|
||||
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 "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:
|
||||
response = client.get("/projects/424242/scenarios")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_project_detail_page_renders_scenario_list(client: TestClient) -> None:
|
||||
project = _create_project(client, name="UI Detail Project")
|
||||
project_id = project["id"]
|
||||
|
||||
_create_scenario(
|
||||
client,
|
||||
project_id,
|
||||
name="Scenario UI",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(f"/projects/{project_id}/view")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "scenario-list" in body
|
||||
assert "status-pill--active" in body
|
||||
|
||||
|
||||
def test_scenario_list_page_shows_calculator_shortcuts(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Portfolio Project")
|
||||
project_id = project["id"]
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project_id,
|
||||
name="Portfolio Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(f"/projects/{project_id}/scenarios/ui")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
unescaped = html.unescape(body)
|
||||
|
||||
assert "Scenario Portfolio" in body
|
||||
assert project["name"] in body
|
||||
assert scenario["name"] in body
|
||||
assert f"projects/{project_id}/view" in unescaped
|
||||
assert f"scenarios/{scenario['id']}/view" in unescaped
|
||||
expected_calc_fragment = (
|
||||
f"calculations/projects/{project_id}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert expected_calc_fragment in unescaped
|
||||
|
||||
|
||||
def test_scenario_detail_page_links_back_to_portfolio(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Detail Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Detail Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
primary_resource=ResourceType.ELECTRICITY,
|
||||
)
|
||||
|
||||
response = client.get(f"/scenarios/{scenario['id']}/view")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
unescaped = html.unescape(body)
|
||||
|
||||
assert project["name"] in body
|
||||
assert scenario["name"] in body
|
||||
assert "Scenario Overview" in body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in unescaped
|
||||
assert (
|
||||
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
in unescaped
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_form_includes_project_context_guidance(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Form Project")
|
||||
|
||||
response = client.get(f"/projects/{project['id']}/scenarios/new")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert "Project Context" in body
|
||||
assert project["name"] in body
|
||||
assert "Status Guidance" in body
|
||||
assert "Baseline Reminder" in body
|
||||
assert "Defaults to" in body
|
||||
|
||||
|
||||
def test_calculator_headers_surface_scenario_navigation(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Calc Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Calc Scenario",
|
||||
status=ScenarioStatus.DRAFT,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
profitability = client.get(
|
||||
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert profitability.status_code == 200
|
||||
profitability_body = html.unescape(profitability.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in profitability_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in profitability_body
|
||||
assert (
|
||||
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
in profitability_body
|
||||
)
|
||||
|
||||
capex = client.get(
|
||||
f"/calculations/capex?project_id={project['id']}&scenario_id={scenario['id']}"
|
||||
)
|
||||
assert capex.status_code == 200
|
||||
capex_body = html.unescape(capex.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in capex_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in capex_body
|
||||
|
||||
opex = client.get(
|
||||
f"/calculations/opex?project_id={project['id']}&scenario_id={scenario['id']}"
|
||||
)
|
||||
assert opex.status_code == 200
|
||||
opex_body = html.unescape(opex.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in opex_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in opex_body
|
||||
|
||||
|
||||
def test_profitability_legacy_endpoint_redirects_to_scenario_path(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Redirect Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Redirect Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/profitability?project_id={project['id']}&scenario_id={scenario['id']}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
|
||||
location = response.headers.get("location")
|
||||
assert location is not None
|
||||
expected_path = (
|
||||
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert expected_path in location
|
||||
|
||||
Reference in New Issue
Block a user