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