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