From ad306bd0aaf3b9ca3a9f5c488700ce3d34b1bbc6 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Wed, 12 Nov 2025 18:30:35 +0100 Subject: [PATCH] feat: Refactor database initialization for SQLite compatibility --- scripts/init_db.py | 650 +++++++++++++++++++++++++++++---------------- 1 file changed, 426 insertions(+), 224 deletions(-) diff --git a/scripts/init_db.py b/scripts/init_db.py index 1c5af96..46412c5 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -76,159 +76,312 @@ ENUM_DEFINITIONS = { } # Minimal DDL for tables we seed / that bootstrap relies on -TABLE_DDLS = [ - # 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() - ); - """, -] + + +def _get_table_ddls(is_sqlite: bool) -> List[str]: + if is_sqlite: + return [ + # roles + """ + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """, + # users + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + is_superuser INTEGER NOT NULL DEFAULT 0, + last_login_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """, + # user_roles + """ + CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + granted_by INTEGER, + PRIMARY KEY (user_id, role_id) + ); + """, + # pricing_settings + """ + CREATE TABLE IF NOT EXISTS pricing_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + description TEXT, + default_currency TEXT, + default_payable_pct REAL DEFAULT 100.00 NOT NULL, + moisture_threshold_pct REAL DEFAULT 8.00 NOT NULL, + moisture_penalty_per_pct REAL DEFAULT 0.0000 NOT NULL, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """, + # pricing_metal_settings + """ + CREATE TABLE IF NOT EXISTS pricing_metal_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE, + metal_code TEXT NOT NULL, + payable_pct REAL, + moisture_threshold_pct REAL, + moisture_penalty_per_pct REAL, + data TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (pricing_settings_id, metal_code) + ); + """, + # pricing_impurity_settings + """ + CREATE TABLE IF NOT EXISTS pricing_impurity_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pricing_settings_id INTEGER NOT NULL REFERENCES pricing_settings(id) ON DELETE CASCADE, + impurity_code TEXT NOT NULL, + threshold_ppm REAL DEFAULT 0.0000 NOT NULL, + penalty_per_ppm REAL DEFAULT 0.0000 NOT NULL, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (pricing_settings_id, impurity_code) + ); + """, + # core domain tables: projects, scenarios, financial_inputs, simulation_parameters + """ + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + location TEXT, + operation_type TEXT NOT NULL CHECK (operation_type IN ('open_pit', 'underground', 'in_situ_leach', 'placer', 'quarry', 'mountaintop_removal', 'other')), + description TEXT, + pricing_settings_id INTEGER REFERENCES pricing_settings(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS scenarios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')), + start_date DATE, + end_date DATE, + discount_rate REAL, + currency TEXT, + primary_resource TEXT CHECK (primary_resource IN ('diesel', 'electricity', 'water', 'explosives', 'reagents', 'labor', 'equipment_hours', 'tailings_capacity') OR primary_resource IS NULL), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + 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, + name TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('capex', 'opex', 'revenue', 'contingency', 'other')), + 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), + amount REAL NOT NULL, + currency TEXT, + effective_date DATE, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + 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, + name TEXT NOT NULL, + distribution TEXT NOT NULL CHECK (distribution IN ('normal', 'triangular', 'uniform', 'lognormal', 'custom')), + variable TEXT CHECK (variable IN ('ore_grade', 'recovery_rate', 'metal_price', 'operating_cost', 'capital_cost', 'discount_rate', 'throughput') OR variable IS NULL), + resource_type TEXT CHECK (resource_type IN ('diesel', 'electricity', 'water', 'explosives', 'reagents', 'labor', 'equipment_hours', 'tailings_capacity') OR resource_type IS NULL), + mean_value REAL, + standard_deviation REAL, + minimum_value REAL, + maximum_value REAL, + unit TEXT, + configuration TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + """, + ] + 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 DEFAULT_ROLES = [ @@ -404,6 +557,10 @@ def _get_database_url() -> str: return DATABASE_URL +def _is_sqlite(database_url: str) -> bool: + return database_url.startswith("sqlite://") + + def _create_engine(database_url: Optional[str] = None) -> Engine: database_url = database_url or _get_database_url() 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 -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: for name, vals in ENUM_DEFINITIONS.items(): 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} -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: for type_name, expected_values in ENUM_DEFINITIONS.items(): try: @@ -485,9 +650,10 @@ def normalize_enum_values(engine: Engine) -> None: 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: - for ddl in TABLE_DDLS: + for ddl in table_ddls: logger.debug("Executing DDL:\n%s", 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: for ddl in CONSTRAINT_DDLS: logger.debug("Ensuring constraint via:\n%s", 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: for r in DEFAULT_ROLES: 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: # Use environment-configured admin settings when present so initializer # 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: p = PricingSeed(**DEFAULT_PRICING) # 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 -def ensure_default_projects(engine: Engine) -> None: +def ensure_default_projects(engine: Engine, is_sqlite: bool) -> None: with engine.begin() as conn: for project in DEFAULT_PROJECTS: 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: for scenario in DEFAULT_SCENARIOS: 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.update({"project_id": project_id}) - conn.execute( - text( - """ - INSERT INTO scenarios ( - project_id, name, description, status, discount_rate, - currency, primary_resource - ) - 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 - """ - ), - payload, - ) + if is_sqlite: + sql = """ + INSERT INTO scenarios ( + project_id, name, description, status, discount_rate, + currency, primary_resource + ) + VALUES ( + :project_id, :name, :description, :status, + :discount_rate, :currency, :primary_resource + ) + 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 + """ + else: + sql = """ + INSERT INTO scenarios ( + project_id, name, description, status, discount_rate, + currency, primary_resource + ) + 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: for item in DEFAULT_FINANCIAL_INPUTS: 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( 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}) - conn.execute( - text( - """ - INSERT INTO financial_inputs ( - scenario_id, name, category, cost_bucket, amount, currency, notes - ) - VALUES ( - :scenario_id, :name, CAST(:category AS financialcategory), - 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 - """ - ), - payload, - ) + if is_sqlite: + sql = """ + INSERT INTO financial_inputs ( + scenario_id, name, category, cost_bucket, amount, currency, notes + ) + VALUES ( + :scenario_id, :name, :category, :cost_bucket, + :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 + """ + else: + sql = """ + INSERT INTO financial_inputs ( + scenario_id, name, category, cost_bucket, amount, currency, notes + ) + VALUES ( + :scenario_id, :name, CAST(:category AS financialcategory), + 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: @@ -749,18 +949,20 @@ def init_db(database_url: Optional[str] = None) -> None: - Ensure default pricing settings record exists. - 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) logger.info("Starting DB initialization using engine=%s", engine) - ensure_enums(engine) - normalize_enum_values(engine) - ensure_tables(engine) - ensure_constraints(engine) - seed_roles(engine) - seed_admin_user(engine) - ensure_default_pricing(engine) - ensure_default_projects(engine) - ensure_default_scenarios(engine) - ensure_default_financial_inputs(engine) + ensure_enums(engine, is_sqlite) + normalize_enum_values(engine, is_sqlite) + ensure_tables(engine, is_sqlite) + ensure_constraints(engine, is_sqlite) + seed_roles(engine, is_sqlite) + seed_admin_user(engine, is_sqlite) + ensure_default_pricing(engine, is_sqlite) + ensure_default_projects(engine, is_sqlite) + ensure_default_scenarios(engine, is_sqlite) + ensure_default_financial_inputs(engine, is_sqlite) logger.info("DB initialization complete")