feat: Refactor database initialization for SQLite compatibility

This commit is contained in:
2025-11-12 18:30:35 +01:00
parent ed4187970c
commit ad306bd0aa

View File

@@ -76,159 +76,312 @@ ENUM_DEFINITIONS = {
} }
# Minimal DDL for tables we seed / that bootstrap relies on # Minimal DDL for tables we seed / that bootstrap relies on
TABLE_DDLS = [
# roles
""" def _get_table_ddls(is_sqlite: bool) -> List[str]:
CREATE TABLE IF NOT EXISTS roles ( if is_sqlite:
id INTEGER PRIMARY KEY, return [
name VARCHAR(64) NOT NULL, # roles
display_name VARCHAR(128) NOT NULL, """
description TEXT, CREATE TABLE IF NOT EXISTS roles (
created_at TIMESTAMPTZ DEFAULT now(), id INTEGER PRIMARY KEY AUTOINCREMENT,
updated_at TIMESTAMPTZ DEFAULT now(), name TEXT NOT NULL UNIQUE,
CONSTRAINT uq_roles_name UNIQUE (name) display_name TEXT NOT NULL,
); description TEXT,
""", created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
# users updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
""" );
CREATE TABLE IF NOT EXISTS users ( """,
id SERIAL PRIMARY KEY, # users
email VARCHAR(255) NOT NULL, """
username VARCHAR(128) NOT NULL, CREATE TABLE IF NOT EXISTS users (
password_hash VARCHAR(255) NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
is_active BOOLEAN NOT NULL DEFAULT true, email TEXT NOT NULL UNIQUE,
is_superuser BOOLEAN NOT NULL DEFAULT false, username TEXT NOT NULL UNIQUE,
last_login_at TIMESTAMPTZ, password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(), is_active INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ DEFAULT now(), is_superuser INTEGER NOT NULL DEFAULT 0,
CONSTRAINT uq_users_email UNIQUE (email), last_login_at DATETIME,
CONSTRAINT uq_users_username UNIQUE (username) created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
); updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
""", );
# user_roles """,
""" # user_roles
CREATE TABLE IF NOT EXISTS user_roles ( """
user_id INTEGER NOT NULL, CREATE TABLE IF NOT EXISTS user_roles (
role_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
granted_at TIMESTAMPTZ DEFAULT now(), role_id INTEGER NOT NULL,
granted_by INTEGER, granted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id), granted_by INTEGER,
CONSTRAINT uq_user_roles_user_role UNIQUE (user_id, role_id) PRIMARY KEY (user_id, role_id)
); );
""", """,
# pricing_settings # pricing_settings
""" """
CREATE TABLE IF NOT EXISTS pricing_settings ( CREATE TABLE IF NOT EXISTS pricing_settings (
id SERIAL PRIMARY KEY, id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(128) NOT NULL, name TEXT NOT NULL UNIQUE,
slug VARCHAR(64) NOT NULL, slug TEXT NOT NULL UNIQUE,
description TEXT, description TEXT,
default_currency VARCHAR(3), default_currency TEXT,
default_payable_pct NUMERIC(5,2) DEFAULT 100.00 NOT NULL, default_payable_pct REAL DEFAULT 100.00 NOT NULL,
moisture_threshold_pct NUMERIC(5,2) DEFAULT 8.00 NOT NULL, moisture_threshold_pct REAL DEFAULT 8.00 NOT NULL,
moisture_penalty_per_pct NUMERIC(14,4) DEFAULT 0.0000 NOT NULL, moisture_penalty_per_pct REAL DEFAULT 0.0000 NOT NULL,
metadata JSONB, metadata TEXT,
created_at TIMESTAMPTZ DEFAULT now(), created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT now(), updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
CONSTRAINT uq_pricing_settings_slug UNIQUE (slug), );
CONSTRAINT uq_pricing_settings_name UNIQUE (name) """,
); # pricing_metal_settings
""", """
# pricing_metal_settings CREATE TABLE IF NOT EXISTS pricing_metal_settings (
""" id INTEGER PRIMARY KEY AUTOINCREMENT,
CREATE TABLE IF NOT EXISTS pricing_metal_settings ( pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE,
id SERIAL PRIMARY KEY, metal_code TEXT NOT NULL,
pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE, payable_pct REAL,
metal_code VARCHAR(32) NOT NULL, moisture_threshold_pct REAL,
payable_pct NUMERIC(5,2), moisture_penalty_per_pct REAL,
moisture_threshold_pct NUMERIC(5,2), data TEXT,
moisture_penalty_per_pct NUMERIC(14,4), created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
data JSONB, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT now(), UNIQUE (pricing_settings_id, metal_code)
updated_at TIMESTAMPTZ DEFAULT now(), );
CONSTRAINT uq_pricing_metal_settings_code UNIQUE (pricing_settings_id, metal_code) """,
); # pricing_impurity_settings
""", """
# pricing_impurity_settings CREATE TABLE IF NOT EXISTS pricing_impurity_settings (
""" id INTEGER PRIMARY KEY AUTOINCREMENT,
CREATE TABLE IF NOT EXISTS pricing_impurity_settings ( pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE,
id SERIAL PRIMARY KEY, impurity_code TEXT NOT NULL,
pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE, threshold_ppm REAL DEFAULT 0.0000 NOT NULL,
impurity_code VARCHAR(32) NOT NULL, penalty_per_ppm REAL DEFAULT 0.0000 NOT NULL,
threshold_ppm NUMERIC(14,4) DEFAULT 0.0000 NOT NULL, notes TEXT,
penalty_per_ppm NUMERIC(14,4) DEFAULT 0.0000 NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
notes TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT now(), UNIQUE (pricing_settings_id, impurity_code)
updated_at TIMESTAMPTZ DEFAULT now(), );
CONSTRAINT uq_pricing_impurity_settings_code UNIQUE (pricing_settings_id, impurity_code) """,
); # core domain tables: projects, scenarios, financial_inputs, simulation_parameters
""", """
# core domain tables: projects, scenarios, financial_inputs, simulation_parameters CREATE TABLE IF NOT EXISTS projects (
""" id INTEGER PRIMARY KEY AUTOINCREMENT,
CREATE TABLE IF NOT EXISTS projects ( name TEXT NOT NULL UNIQUE,
id SERIAL PRIMARY KEY, location TEXT,
name VARCHAR(255) NOT NULL, operation_type TEXT NOT NULL CHECK (operation_type IN ('open_pit', 'underground', 'in_situ_leach', 'placer', 'quarry', 'mountaintop_removal', 'other')),
location VARCHAR(255), description TEXT,
operation_type miningoperationtype NOT NULL, pricing_settings_id INTEGER REFERENCES pricing_settings(id) ON DELETE SET NULL,
description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
pricing_settings_id INTEGER REFERENCES pricing_settings(id) ON DELETE SET NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMPTZ DEFAULT now(), );
updated_at TIMESTAMPTZ DEFAULT now(), """,
CONSTRAINT uq_projects_name UNIQUE (name) """
); CREATE TABLE IF NOT EXISTS scenarios (
""", id INTEGER PRIMARY KEY AUTOINCREMENT,
""" project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
CREATE TABLE IF NOT EXISTS scenarios ( name TEXT NOT NULL,
id SERIAL PRIMARY KEY, description TEXT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
name VARCHAR(255) NOT NULL, start_date DATE,
description TEXT, end_date DATE,
status scenariostatus NOT NULL, discount_rate REAL,
start_date DATE, currency TEXT,
end_date DATE, primary_resource TEXT CHECK (primary_resource IN ('diesel', 'electricity', 'water', 'explosives', 'reagents', 'labor', 'equipment_hours', 'tailings_capacity') OR primary_resource IS NULL),
discount_rate NUMERIC(5,2), created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
currency VARCHAR(3), updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
primary_resource resourcetype, UNIQUE (project_id, name)
created_at TIMESTAMPTZ DEFAULT now(), );
updated_at TIMESTAMPTZ DEFAULT now(), """,
CONSTRAINT uq_scenarios_project_name UNIQUE (project_id, name) """
); CREATE TABLE IF NOT EXISTS financial_inputs (
""", id INTEGER PRIMARY KEY AUTOINCREMENT,
""" scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
CREATE TABLE IF NOT EXISTS financial_inputs ( name TEXT NOT NULL,
id SERIAL PRIMARY KEY, category TEXT NOT NULL CHECK (category IN ('capex', 'opex', 'revenue', 'contingency', 'other')),
scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE, cost_bucket TEXT CHECK (cost_bucket IN ('capital_initial', 'capital_sustaining', 'operating_fixed', 'operating_variable', 'maintenance', 'reclamation', 'royalties', 'general_admin') OR cost_bucket IS NULL),
name VARCHAR(255) NOT NULL, amount REAL NOT NULL,
category financialcategory NOT NULL, currency TEXT,
cost_bucket costbucket, effective_date DATE,
amount NUMERIC(18,2) NOT NULL, notes TEXT,
currency VARCHAR(3), created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
effective_date DATE, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
notes TEXT, UNIQUE (scenario_id, name)
created_at TIMESTAMPTZ DEFAULT now(), );
updated_at TIMESTAMPTZ DEFAULT now(), """,
CONSTRAINT uq_financial_inputs_scenario_name UNIQUE (scenario_id, name) """
); CREATE TABLE IF NOT EXISTS simulation_parameters (
""", id INTEGER PRIMARY KEY AUTOINCREMENT,
""" scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
CREATE TABLE IF NOT EXISTS simulation_parameters ( name TEXT NOT NULL,
id SERIAL PRIMARY KEY, distribution TEXT NOT NULL CHECK (distribution IN ('normal', 'triangular', 'uniform', 'lognormal', 'custom')),
scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE, variable TEXT CHECK (variable IN ('ore_grade', 'recovery_rate', 'metal_price', 'operating_cost', 'capital_cost', 'discount_rate', 'throughput') OR variable IS NULL),
name VARCHAR(255) NOT NULL, resource_type TEXT CHECK (resource_type IN ('diesel', 'electricity', 'water', 'explosives', 'reagents', 'labor', 'equipment_hours', 'tailings_capacity') OR resource_type IS NULL),
distribution distributiontype NOT NULL, mean_value REAL,
variable stochasticvariable, standard_deviation REAL,
resource_type resourcetype, minimum_value REAL,
mean_value NUMERIC(18,4), maximum_value REAL,
standard_deviation NUMERIC(18,4), unit TEXT,
minimum_value NUMERIC(18,4), configuration TEXT,
maximum_value NUMERIC(18,4), created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
unit VARCHAR(32), updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
configuration JSONB, );
created_at TIMESTAMPTZ DEFAULT now(), """,
updated_at TIMESTAMPTZ DEFAULT now() ]
); else:
""", # PostgreSQL DDLs
] return [
# roles
"""
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY,
name VARCHAR(64) NOT NULL,
display_name VARCHAR(128) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_roles_name UNIQUE (name)
);
""",
# users
"""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
username VARCHAR(128) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_superuser BOOLEAN NOT NULL DEFAULT false,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_users_email UNIQUE (email),
CONSTRAINT uq_users_username UNIQUE (username)
);
""",
# user_roles
"""
CREATE TABLE IF NOT EXISTS user_roles (
user_id INTEGER NOT NULL,
role_id INTEGER NOT NULL,
granted_at TIMESTAMPTZ DEFAULT now(),
granted_by INTEGER,
PRIMARY KEY (user_id, role_id),
CONSTRAINT uq_user_roles_user_role UNIQUE (user_id, role_id)
);
""",
# pricing_settings
"""
CREATE TABLE IF NOT EXISTS pricing_settings (
id SERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL,
description TEXT,
default_currency VARCHAR(3),
default_payable_pct NUMERIC(5,2) DEFAULT 100.00 NOT NULL,
moisture_threshold_pct NUMERIC(5,2) DEFAULT 8.00 NOT NULL,
moisture_penalty_per_pct NUMERIC(14,4) DEFAULT 0.0000 NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_pricing_settings_slug UNIQUE (slug),
CONSTRAINT uq_pricing_settings_name UNIQUE (name)
);
""",
# pricing_metal_settings
"""
CREATE TABLE IF NOT EXISTS pricing_metal_settings (
id SERIAL PRIMARY KEY,
pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE,
metal_code VARCHAR(32) NOT NULL,
payable_pct NUMERIC(5,2),
moisture_threshold_pct NUMERIC(5,2),
moisture_penalty_per_pct NUMERIC(14,4),
data JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_pricing_metal_settings_code UNIQUE (pricing_settings_id, metal_code)
);
""",
# pricing_impurity_settings
"""
CREATE TABLE IF NOT EXISTS pricing_impurity_settings (
id SERIAL PRIMARY KEY,
pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE,
impurity_code VARCHAR(32) NOT NULL,
threshold_ppm NUMERIC(14,4) DEFAULT 0.0000 NOT NULL,
penalty_per_ppm NUMERIC(14,4) DEFAULT 0.0000 NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_pricing_impurity_settings_code UNIQUE (pricing_settings_id, impurity_code)
);
""",
# core domain tables: projects, scenarios, financial_inputs, simulation_parameters
"""
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
location VARCHAR(255),
operation_type miningoperationtype NOT NULL,
description TEXT,
pricing_settings_id INTEGER REFERENCES pricing_settings(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_projects_name UNIQUE (name)
);
""",
"""
CREATE TABLE IF NOT EXISTS scenarios (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
status scenariostatus NOT NULL,
start_date DATE,
end_date DATE,
discount_rate NUMERIC(5,2),
currency VARCHAR(3),
primary_resource resourcetype,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_scenarios_project_name UNIQUE (project_id, name)
);
""",
"""
CREATE TABLE IF NOT EXISTS financial_inputs (
id SERIAL PRIMARY KEY,
scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
category financialcategory NOT NULL,
cost_bucket costbucket,
amount NUMERIC(18,2) NOT NULL,
currency VARCHAR(3),
effective_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CONSTRAINT uq_financial_inputs_scenario_name UNIQUE (scenario_id, name)
);
""",
"""
CREATE TABLE IF NOT EXISTS simulation_parameters (
id SERIAL PRIMARY KEY,
scenario_id INTEGER NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
distribution distributiontype NOT NULL,
variable stochasticvariable,
resource_type resourcetype,
mean_value NUMERIC(18,4),
standard_deviation NUMERIC(18,4),
minimum_value NUMERIC(18,4),
maximum_value NUMERIC(18,4),
unit VARCHAR(32),
configuration JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
""",
]
# Seeds # Seeds
DEFAULT_ROLES = [ DEFAULT_ROLES = [
@@ -404,6 +557,10 @@ def _get_database_url() -> str:
return DATABASE_URL return DATABASE_URL
def _is_sqlite(database_url: str) -> bool:
return database_url.startswith("sqlite://")
def _create_engine(database_url: Optional[str] = None) -> Engine: def _create_engine(database_url: Optional[str] = None) -> Engine:
database_url = database_url or _get_database_url() database_url = database_url or _get_database_url()
engine = create_engine(database_url, future=True) engine = create_engine(database_url, future=True)
@@ -422,7 +579,11 @@ def _create_enum_if_missing_sql(type_name: str, values: List[str]) -> str:
return sql return sql
def ensure_enums(engine: Engine) -> None: def ensure_enums(engine: Engine, is_sqlite: bool) -> None:
if is_sqlite:
# SQLite doesn't have enums, constraints are in table DDL
logger.debug("Skipping enum creation for SQLite")
return
with engine.begin() as conn: with engine.begin() as conn:
for name, vals in ENUM_DEFINITIONS.items(): for name, vals in ENUM_DEFINITIONS.items():
sql = _create_enum_if_missing_sql(name, vals) sql = _create_enum_if_missing_sql(name, vals)
@@ -445,7 +606,11 @@ def _fetch_enum_values(conn, type_name: str) -> Set[str]:
return {row.enumlabel for row in rows} return {row.enumlabel for row in rows}
def normalize_enum_values(engine: Engine) -> None: def normalize_enum_values(engine: Engine, is_sqlite: bool) -> None:
if is_sqlite:
# No enums to normalize in SQLite
logger.debug("Skipping enum normalization for SQLite")
return
with engine.begin() as conn: with engine.begin() as conn:
for type_name, expected_values in ENUM_DEFINITIONS.items(): for type_name, expected_values in ENUM_DEFINITIONS.items():
try: try:
@@ -485,9 +650,10 @@ def normalize_enum_values(engine: Engine) -> None:
existing_values.add(normalized) existing_values.add(normalized)
def ensure_tables(engine: Engine) -> None: def ensure_tables(engine: Engine, is_sqlite: bool) -> None:
table_ddls = _get_table_ddls(is_sqlite)
with engine.begin() as conn: with engine.begin() as conn:
for ddl in TABLE_DDLS: for ddl in table_ddls:
logger.debug("Executing DDL:\n%s", ddl) logger.debug("Executing DDL:\n%s", ddl)
conn.execute(text(ddl)) conn.execute(text(ddl))
@@ -524,14 +690,18 @@ CONSTRAINT_DDLS = [
] ]
def ensure_constraints(engine: Engine) -> None: def ensure_constraints(engine: Engine, is_sqlite: bool) -> None:
if is_sqlite:
# Constraints are already in table DDL for SQLite
logger.debug("Skipping constraint creation for SQLite")
return
with engine.begin() as conn: with engine.begin() as conn:
for ddl in CONSTRAINT_DDLS: for ddl in CONSTRAINT_DDLS:
logger.debug("Ensuring constraint via:\n%s", ddl) logger.debug("Ensuring constraint via:\n%s", ddl)
conn.execute(text(ddl)) conn.execute(text(ddl))
def seed_roles(engine: Engine) -> None: def seed_roles(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for r in DEFAULT_ROLES: for r in DEFAULT_ROLES:
seed = RoleSeed(**r) seed = RoleSeed(**r)
@@ -545,7 +715,7 @@ def seed_roles(engine: Engine) -> None:
) )
def seed_admin_user(engine: Engine) -> None: def seed_admin_user(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
# Use environment-configured admin settings when present so initializer # Use environment-configured admin settings when present so initializer
# aligns with the application's bootstrap configuration. # aligns with the application's bootstrap configuration.
@@ -592,7 +762,7 @@ def seed_admin_user(engine: Engine) -> None:
) )
def ensure_default_pricing(engine: Engine) -> None: def ensure_default_pricing(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
p = PricingSeed(**DEFAULT_PRICING) p = PricingSeed(**DEFAULT_PRICING)
# Try insert on slug conflict # Try insert on slug conflict
@@ -622,7 +792,7 @@ def _project_id_by_name(conn, project_name: str) -> Optional[int]:
return row.id if row else None return row.id if row else None
def ensure_default_projects(engine: Engine) -> None: def ensure_default_projects(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for project in DEFAULT_PROJECTS: for project in DEFAULT_PROJECTS:
conn.execute( conn.execute(
@@ -640,7 +810,7 @@ def ensure_default_projects(engine: Engine) -> None:
) )
def ensure_default_scenarios(engine: Engine) -> None: def ensure_default_scenarios(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for scenario in DEFAULT_SCENARIOS: for scenario in DEFAULT_SCENARIOS:
project_id = _project_id_by_name(conn, scenario.project_name) project_id = _project_id_by_name(conn, scenario.project_name)
@@ -654,34 +824,48 @@ def ensure_default_scenarios(engine: Engine) -> None:
payload = scenario.model_dump(exclude={"project_name"}) payload = scenario.model_dump(exclude={"project_name"})
payload.update({"project_id": project_id}) payload.update({"project_id": project_id})
conn.execute( if is_sqlite:
text( sql = """
""" INSERT INTO scenarios (
INSERT INTO scenarios ( project_id, name, description, status, discount_rate,
project_id, name, description, status, discount_rate, currency, primary_resource
currency, primary_resource )
) VALUES (
VALUES ( :project_id, :name, :description, :status,
:project_id, :name, :description, CAST(:status AS scenariostatus), :discount_rate, :currency, :primary_resource
:discount_rate, :currency, )
CASE WHEN :primary_resource IS NULL ON CONFLICT (project_id, name) DO UPDATE SET
THEN NULL description = EXCLUDED.description,
ELSE CAST(:primary_resource AS resourcetype) status = EXCLUDED.status,
END discount_rate = EXCLUDED.discount_rate,
) currency = EXCLUDED.currency,
ON CONFLICT (project_id, name) DO UPDATE SET primary_resource = EXCLUDED.primary_resource
description = EXCLUDED.description, """
status = EXCLUDED.status, else:
discount_rate = EXCLUDED.discount_rate, sql = """
currency = EXCLUDED.currency, INSERT INTO scenarios (
primary_resource = EXCLUDED.primary_resource project_id, name, description, status, discount_rate,
""" currency, primary_resource
), )
payload, VALUES (
) :project_id, :name, :description, CAST(:status AS scenariostatus),
:discount_rate, :currency,
CASE WHEN :primary_resource IS NULL
THEN NULL
ELSE CAST(:primary_resource AS resourcetype)
END
)
ON CONFLICT (project_id, name) DO UPDATE SET
description = EXCLUDED.description,
status = EXCLUDED.status,
discount_rate = EXCLUDED.discount_rate,
currency = EXCLUDED.currency,
primary_resource = EXCLUDED.primary_resource
"""
conn.execute(text(sql), payload)
def ensure_default_financial_inputs(engine: Engine) -> None: def ensure_default_financial_inputs(engine: Engine, is_sqlite: bool) -> None:
with engine.begin() as conn: with engine.begin() as conn:
for item in DEFAULT_FINANCIAL_INPUTS: for item in DEFAULT_FINANCIAL_INPUTS:
project_id = _project_id_by_name(conn, item.project_name) project_id = _project_id_by_name(conn, item.project_name)
@@ -711,32 +895,48 @@ def ensure_default_financial_inputs(engine: Engine) -> None:
payload = item.model_dump( payload = item.model_dump(
exclude={"project_name", "scenario_name"}, exclude={"project_name", "scenario_name"},
) )
if is_sqlite:
# Convert Decimal to float for SQLite
payload["amount"] = float(payload["amount"])
payload.update({"scenario_id": scenario_row.id}) payload.update({"scenario_id": scenario_row.id})
conn.execute( if is_sqlite:
text( sql = """
""" INSERT INTO financial_inputs (
INSERT INTO financial_inputs ( scenario_id, name, category, cost_bucket, amount, currency, notes
scenario_id, name, category, cost_bucket, amount, currency, notes )
) VALUES (
VALUES ( :scenario_id, :name, :category, :cost_bucket,
:scenario_id, :name, CAST(:category AS financialcategory), :amount, :currency, :notes
CASE WHEN :cost_bucket IS NULL THEN NULL )
ELSE CAST(:cost_bucket AS costbucket) ON CONFLICT (scenario_id, name) DO UPDATE SET
END, category = EXCLUDED.category,
:amount, cost_bucket = EXCLUDED.cost_bucket,
:currency, amount = EXCLUDED.amount,
:notes currency = EXCLUDED.currency,
) notes = EXCLUDED.notes
ON CONFLICT (scenario_id, name) DO UPDATE SET """
category = EXCLUDED.category, else:
cost_bucket = EXCLUDED.cost_bucket, sql = """
amount = EXCLUDED.amount, INSERT INTO financial_inputs (
currency = EXCLUDED.currency, scenario_id, name, category, cost_bucket, amount, currency, notes
notes = EXCLUDED.notes )
""" VALUES (
), :scenario_id, :name, CAST(:category AS financialcategory),
payload, CASE WHEN :cost_bucket IS NULL THEN NULL
) ELSE CAST(:cost_bucket AS costbucket)
END,
:amount,
:currency,
:notes
)
ON CONFLICT (scenario_id, name) DO UPDATE SET
category = EXCLUDED.category,
cost_bucket = EXCLUDED.cost_bucket,
amount = EXCLUDED.amount,
currency = EXCLUDED.currency,
notes = EXCLUDED.notes
"""
conn.execute(text(sql), payload)
def init_db(database_url: Optional[str] = None) -> None: def init_db(database_url: Optional[str] = None) -> None:
@@ -749,18 +949,20 @@ def init_db(database_url: Optional[str] = None) -> None:
- Ensure default pricing settings record exists. - Ensure default pricing settings record exists.
- Seed sample projects, scenarios, and financial inputs. - Seed sample projects, scenarios, and financial inputs.
""" """
database_url = database_url or _get_database_url()
is_sqlite = _is_sqlite(database_url)
engine = _create_engine(database_url) engine = _create_engine(database_url)
logger.info("Starting DB initialization using engine=%s", engine) logger.info("Starting DB initialization using engine=%s", engine)
ensure_enums(engine) ensure_enums(engine, is_sqlite)
normalize_enum_values(engine) normalize_enum_values(engine, is_sqlite)
ensure_tables(engine) ensure_tables(engine, is_sqlite)
ensure_constraints(engine) ensure_constraints(engine, is_sqlite)
seed_roles(engine) seed_roles(engine, is_sqlite)
seed_admin_user(engine) seed_admin_user(engine, is_sqlite)
ensure_default_pricing(engine) ensure_default_pricing(engine, is_sqlite)
ensure_default_projects(engine) ensure_default_projects(engine, is_sqlite)
ensure_default_scenarios(engine) ensure_default_scenarios(engine, is_sqlite)
ensure_default_financial_inputs(engine) ensure_default_financial_inputs(engine, is_sqlite)
logger.info("DB initialization complete") logger.info("DB initialization complete")