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

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

refactor: update Opex and Profitability templates for consistency

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

test: add integration tests for Opex calculations

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

test: enhance project and scenario route tests

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

View File

@@ -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)