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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user