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

@@ -23,9 +23,10 @@ import logging
from decimal import Decimal
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import create_engine, text
from sqlalchemy import JSON, create_engine, text
from sqlalchemy.engine import Engine
from passlib.context import CryptContext
from sqlalchemy.sql import bindparam
logger = logging.getLogger(__name__)
password_context = CryptContext(schemes=["argon2"], deprecated="auto")
@@ -116,6 +117,40 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
PRIMARY KEY (user_id, role_id)
);
""",
"""
CREATE TABLE IF NOT EXISTS navigation_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
icon TEXT,
tooltip TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""",
"""
CREATE TABLE IF NOT EXISTS navigation_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
slug TEXT NOT NULL,
label TEXT NOT NULL,
route_name TEXT,
href_override TEXT,
match_prefix TEXT,
sort_order INTEGER NOT NULL DEFAULT 100,
icon TEXT,
tooltip TEXT,
required_roles TEXT NOT NULL DEFAULT '[]',
is_enabled INTEGER NOT NULL DEFAULT 1,
is_external INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE (group_id, slug)
);
""",
# pricing_settings
"""
CREATE TABLE IF NOT EXISTS pricing_settings (
@@ -268,6 +303,41 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
CONSTRAINT uq_user_roles_user_role UNIQUE (user_id, role_id)
);
""",
"""
CREATE TABLE IF NOT EXISTS navigation_groups (
id SERIAL PRIMARY KEY,
slug VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
icon VARCHAR(64),
tooltip VARCHAR(255),
is_enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_navigation_groups_slug UNIQUE (slug)
);
""",
"""
CREATE TABLE IF NOT EXISTS navigation_links (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
slug VARCHAR(64) NOT NULL,
label VARCHAR(128) NOT NULL,
route_name VARCHAR(128),
href_override VARCHAR(512),
match_prefix VARCHAR(512),
sort_order INTEGER NOT NULL DEFAULT 100,
icon VARCHAR(64),
tooltip VARCHAR(255),
required_roles JSONB NOT NULL DEFAULT '[]'::jsonb,
is_enabled BOOLEAN NOT NULL DEFAULT true,
is_external BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_navigation_links_group_slug UNIQUE (group_id, slug)
);
""",
# pricing_settings
"""
CREATE TABLE IF NOT EXISTS pricing_settings (
@@ -471,6 +541,230 @@ class PricingSeed(BaseModel):
moisture_penalty_per_pct: float
class NavigationGroupSeed(BaseModel):
slug: str
label: str
sort_order: int = 100
icon: Optional[str] = None
tooltip: Optional[str] = None
is_enabled: bool = True
class NavigationLinkSeed(BaseModel):
slug: str
group_slug: str
label: str
route_name: Optional[str] = None
href_override: Optional[str] = None
match_prefix: Optional[str] = None
sort_order: int = 100
icon: Optional[str] = None
tooltip: Optional[str] = None
required_roles: list[str] = Field(default_factory=list)
is_enabled: bool = True
is_external: bool = False
parent_slug: Optional[str] = None
@field_validator("required_roles", mode="after")
def _normalise_roles(cls, value: list[str]) -> list[str]:
normalised = []
for role in value:
if not role:
continue
slug = role.strip().lower()
if slug and slug not in normalised:
normalised.append(slug)
return normalised
@field_validator("route_name")
def _route_or_href(cls, value: Optional[str], info):
href = info.data.get("href_override")
if not value and not href:
raise ValueError(
"navigation link requires route_name or href_override")
return value
DEFAULT_NAVIGATION_GROUPS: list[NavigationGroupSeed] = [
NavigationGroupSeed(
slug="workspace",
label="Workspace",
sort_order=10,
icon="briefcase",
tooltip="Primary work hub",
),
NavigationGroupSeed(
slug="insights",
label="Insights",
sort_order=20,
icon="insights",
tooltip="Analytics and reports",
),
NavigationGroupSeed(
slug="configuration",
label="Configuration",
sort_order=30,
icon="cog",
tooltip="Administration and settings",
),
NavigationGroupSeed(
slug="account",
label="Account",
sort_order=40,
icon="user",
tooltip="Session management",
),
]
DEFAULT_NAVIGATION_LINKS: list[NavigationLinkSeed] = [
NavigationLinkSeed(
slug="dashboard",
group_slug="workspace",
label="Dashboard",
route_name="dashboard.home",
match_prefix="/",
sort_order=10,
),
NavigationLinkSeed(
slug="projects",
group_slug="workspace",
label="Projects",
route_name="projects.project_list_page",
match_prefix="/projects",
sort_order=20,
),
NavigationLinkSeed(
slug="project-create",
group_slug="workspace",
label="New Project",
route_name="projects.create_project_form",
match_prefix="/projects/create",
sort_order=30,
required_roles=["project_manager", "admin"],
),
NavigationLinkSeed(
slug="imports",
group_slug="workspace",
label="Imports",
href_override="/imports/ui",
match_prefix="/imports",
sort_order=40,
required_roles=["analyst", "admin"],
),
NavigationLinkSeed(
slug="profitability",
group_slug="workspace",
label="Profitability Calculator",
route_name="calculations.profitability_form",
match_prefix="/calculations/profitability",
sort_order=50,
required_roles=["analyst", "admin"],
),
NavigationLinkSeed(
slug="opex",
group_slug="workspace",
label="Opex Planner",
route_name="calculations.opex_form",
match_prefix="/calculations/opex",
sort_order=60,
required_roles=["analyst", "admin"],
),
NavigationLinkSeed(
slug="capex",
group_slug="workspace",
label="Capex Planner",
route_name="calculations.capex_form",
match_prefix="/calculations/capex",
sort_order=70,
required_roles=["analyst", "admin"],
),
NavigationLinkSeed(
slug="simulations",
group_slug="insights",
label="Simulations",
href_override="/ui/simulations",
match_prefix="/ui/simulations",
sort_order=10,
required_roles=["analyst", "admin"],
),
NavigationLinkSeed(
slug="reporting",
group_slug="insights",
label="Reporting",
href_override="/ui/reporting",
match_prefix="/ui/reporting",
sort_order=20,
required_roles=["analyst", "admin"],
),
NavigationLinkSeed(
slug="settings",
group_slug="configuration",
label="Settings",
href_override="/ui/settings",
match_prefix="/ui/settings",
sort_order=10,
required_roles=["admin"],
),
NavigationLinkSeed(
slug="themes",
group_slug="configuration",
label="Themes",
href_override="/theme-settings",
match_prefix="/theme-settings",
sort_order=20,
required_roles=["admin"],
parent_slug="settings",
),
NavigationLinkSeed(
slug="currencies",
group_slug="configuration",
label="Currency Management",
href_override="/ui/currencies",
match_prefix="/ui/currencies",
sort_order=30,
required_roles=["admin"],
parent_slug="settings",
),
NavigationLinkSeed(
slug="logout",
group_slug="account",
label="Logout",
route_name="auth.logout",
match_prefix="/logout",
sort_order=10,
required_roles=["viewer", "analyst", "project_manager", "admin"],
),
NavigationLinkSeed(
slug="login",
group_slug="account",
label="Login",
route_name="auth.login_form",
match_prefix="/login",
sort_order=10,
required_roles=["anonymous"],
),
NavigationLinkSeed(
slug="register",
group_slug="account",
label="Register",
route_name="auth.register_form",
match_prefix="/register",
sort_order=20,
required_roles=["anonymous"],
),
NavigationLinkSeed(
slug="forgot-password",
group_slug="account",
label="Forgot Password",
route_name="auth.password_reset_request_form",
match_prefix="/forgot-password",
sort_order=30,
required_roles=["anonymous"],
),
]
DEFAULT_PROJECTS: list[ProjectSeed] = [
ProjectSeed(
name="Helios Copper",
@@ -528,7 +822,7 @@ DEFAULT_FINANCIAL_INPUTS: list[FinancialInputSeed] = [
FinancialInputSeed(
project_name="Helios Copper",
scenario_name="Base Case",
name="Processing Opex",
name="Opex",
category="opex",
cost_bucket="operating_variable",
amount=Decimal("75000000"),
@@ -787,6 +1081,198 @@ def ensure_default_pricing(engine: Engine, is_sqlite: bool) -> None:
)
def seed_navigation(engine: Engine, is_sqlite: bool) -> None:
group_insert_sql = text(
"""
INSERT INTO navigation_groups (slug, label, sort_order, icon, tooltip, is_enabled)
VALUES (:slug, :label, :sort_order, :icon, :tooltip, :is_enabled)
ON CONFLICT (slug) DO UPDATE SET
label = EXCLUDED.label,
sort_order = EXCLUDED.sort_order,
icon = EXCLUDED.icon,
tooltip = EXCLUDED.tooltip,
is_enabled = EXCLUDED.is_enabled
"""
)
link_insert_sql = text(
f"""
INSERT INTO navigation_links (
group_id, parent_link_id, slug, label, route_name, href_override,
match_prefix, sort_order, icon, tooltip, required_roles, is_enabled, is_external
)
VALUES (
:group_id, :parent_link_id, :slug, :label, :route_name, :href_override,
:match_prefix, :sort_order, :icon, :tooltip, :required_roles, :is_enabled, :is_external
)
ON CONFLICT (group_id, slug) DO UPDATE SET
parent_link_id = EXCLUDED.parent_link_id,
label = EXCLUDED.label,
route_name = EXCLUDED.route_name,
href_override = EXCLUDED.href_override,
match_prefix = EXCLUDED.match_prefix,
sort_order = EXCLUDED.sort_order,
icon = EXCLUDED.icon,
tooltip = EXCLUDED.tooltip,
required_roles = EXCLUDED.required_roles,
is_enabled = EXCLUDED.is_enabled,
is_external = EXCLUDED.is_external
"""
)
link_insert_sql = link_insert_sql.bindparams(
bindparam("required_roles", type_=JSON)
)
with engine.begin() as conn:
role_rows = conn.execute(text("SELECT name FROM roles")).fetchall()
available_roles = {row.name for row in role_rows}
def resolve_roles(raw_roles: list[str]) -> list[str]:
if not raw_roles:
return []
resolved: list[str] = []
missing: list[str] = []
for slug in raw_roles:
if slug == "anonymous":
if slug not in resolved:
resolved.append(slug)
continue
if slug in available_roles:
if slug not in resolved:
resolved.append(slug)
else:
missing.append(slug)
if missing:
logger.warning(
"Navigation seed roles %s are missing; defaulting link access to admin only",
", ".join(missing),
)
if "admin" in available_roles and "admin" not in resolved:
resolved.append("admin")
return resolved
group_ids: dict[str, int] = {}
for group_seed in DEFAULT_NAVIGATION_GROUPS:
conn.execute(
group_insert_sql,
group_seed.model_dump(),
)
row = conn.execute(
text("SELECT id FROM navigation_groups WHERE slug = :slug"),
{"slug": group_seed.slug},
).fetchone()
if row is not None:
group_ids[group_seed.slug] = row.id
if not group_ids:
logger.warning(
"Navigation seeding skipped because no groups were inserted")
return
link_ids: dict[tuple[str, str], int] = {}
parent_pending: list[NavigationLinkSeed] = []
for link_seed in DEFAULT_NAVIGATION_LINKS:
if link_seed.parent_slug:
parent_pending.append(link_seed)
continue
group_id = group_ids.get(link_seed.group_slug)
if group_id is None:
logger.warning(
"Skipping navigation link '%s' because group '%s' is missing",
link_seed.slug,
link_seed.group_slug,
)
continue
resolved_roles = resolve_roles(link_seed.required_roles)
payload = {
"group_id": group_id,
"parent_link_id": None,
"slug": link_seed.slug,
"label": link_seed.label,
"route_name": link_seed.route_name,
"href_override": link_seed.href_override,
"match_prefix": link_seed.match_prefix,
"sort_order": link_seed.sort_order,
"icon": link_seed.icon,
"tooltip": link_seed.tooltip,
"required_roles": resolved_roles,
"is_enabled": link_seed.is_enabled,
"is_external": link_seed.is_external,
}
conn.execute(link_insert_sql, payload)
row = conn.execute(
text(
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
),
{"group_id": group_id, "slug": link_seed.slug},
).fetchone()
if row is not None:
link_ids[(link_seed.group_slug, link_seed.slug)] = row.id
for link_seed in parent_pending:
group_id = group_ids.get(link_seed.group_slug)
if group_id is None:
logger.warning(
"Skipping child navigation link '%s' because group '%s' is missing",
link_seed.slug,
link_seed.group_slug,
)
continue
parent_key = (link_seed.group_slug, link_seed.parent_slug or "")
parent_id = link_ids.get(parent_key)
if parent_id is None:
parent_row = conn.execute(
text(
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
),
{"group_id": group_id, "slug": link_seed.parent_slug},
).fetchone()
parent_id = parent_row.id if parent_row else None
if parent_id is None:
logger.warning(
"Skipping child navigation link '%s' because parent '%s' is missing",
link_seed.slug,
link_seed.parent_slug,
)
continue
resolved_roles = resolve_roles(link_seed.required_roles)
payload = {
"group_id": group_id,
"parent_link_id": parent_id,
"slug": link_seed.slug,
"label": link_seed.label,
"route_name": link_seed.route_name,
"href_override": link_seed.href_override,
"match_prefix": link_seed.match_prefix,
"sort_order": link_seed.sort_order,
"icon": link_seed.icon,
"tooltip": link_seed.tooltip,
"required_roles": resolved_roles,
"is_enabled": link_seed.is_enabled,
"is_external": link_seed.is_external,
}
conn.execute(link_insert_sql, payload)
row = conn.execute(
text(
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
),
{"group_id": group_id, "slug": link_seed.slug},
).fetchone()
if row is not None:
link_ids[(link_seed.group_slug, link_seed.slug)] = row.id
def _project_id_by_name(conn, project_name: str) -> Optional[int]:
row = conn.execute(
text("SELECT id FROM projects WHERE name = :name"),
@@ -963,6 +1449,7 @@ def init_db(database_url: Optional[str] = None) -> None:
seed_roles(engine, is_sqlite)
seed_admin_user(engine, is_sqlite)
ensure_default_pricing(engine, is_sqlite)
seed_navigation(engine, is_sqlite)
ensure_default_projects(engine, is_sqlite)
ensure_default_scenarios(engine, is_sqlite)
ensure_default_financial_inputs(engine, is_sqlite)