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

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