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