from sqlalchemy import text from sqlalchemy.engine import Engine def _table_columns(engine: Engine, table_name: str) -> set[str]: with engine.connect() as conn: rows = conn.execute(text(f"PRAGMA table_info({table_name})")).mappings().all() return {row["name"] for row in rows} def run_startup_migrations(engine: Engine) -> None: if engine.dialect.name != "sqlite": return user_columns = _table_columns(engine, "users") statements: list[str] = [] if "preferred_home_view" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN preferred_home_view VARCHAR(16) NOT NULL DEFAULT 'week'") if "preferred_month_view_mode" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN preferred_month_view_mode VARCHAR(16) NOT NULL DEFAULT 'flat'") if "entry_mode" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN entry_mode VARCHAR(16) NOT NULL DEFAULT 'manual'") if "working_days_csv" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN working_days_csv VARCHAR(32) NOT NULL DEFAULT '0,1,2,3,4'") if "count_vacation_as_worktime" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN count_vacation_as_worktime BOOLEAN NOT NULL DEFAULT 0") if "count_holiday_as_worktime" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN count_holiday_as_worktime BOOLEAN NOT NULL DEFAULT 0") if "count_sick_as_worktime" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN count_sick_as_worktime BOOLEAN NOT NULL DEFAULT 0") if "automatic_break_rules_enabled" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN automatic_break_rules_enabled BOOLEAN NOT NULL DEFAULT 0") if "default_break_minutes" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN default_break_minutes INTEGER NOT NULL DEFAULT 0") if "overtime_start_date" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN overtime_start_date DATE") if "overtime_expiry_days" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN overtime_expiry_days INTEGER") if "expire_negative_overtime" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN expire_negative_overtime BOOLEAN NOT NULL DEFAULT 0") if "vacation_days_total" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN vacation_days_total INTEGER NOT NULL DEFAULT 0") if "vacation_show_in_header" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN vacation_show_in_header BOOLEAN NOT NULL DEFAULT 1") if "workhours_counter_enabled" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_enabled BOOLEAN NOT NULL DEFAULT 0") if "workhours_counter_show_in_header" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_show_in_header BOOLEAN NOT NULL DEFAULT 0") if "workhours_counter_start_date" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_start_date DATE") if "workhours_counter_end_date" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_end_date DATE") if "workhours_counter_manual_offset_minutes" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_manual_offset_minutes INTEGER NOT NULL DEFAULT 0") if "workhours_counter_target_minutes" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_target_minutes INTEGER") if "workhours_counter_target_email_enabled" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_target_email_enabled BOOLEAN NOT NULL DEFAULT 0") if "workhours_counter_warning_last_sent_on" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_warning_last_sent_on DATE") if "workhours_counter_warning_last_sent_key" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN workhours_counter_warning_last_sent_key VARCHAR(120)") if "federal_state" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN federal_state VARCHAR(8)") if "email_verified" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT 1") if "email_verification_token_hash" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN email_verification_token_hash VARCHAR(128)") if "email_verification_expires_at" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN email_verification_expires_at DATETIME") if "email_verification_sent_at" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN email_verification_sent_at DATETIME") if "mfa_method" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN mfa_method VARCHAR(16) NOT NULL DEFAULT 'none'") if "mfa_totp_secret_encrypted" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN mfa_totp_secret_encrypted TEXT") if "mfa_email_code_hash" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN mfa_email_code_hash VARCHAR(255)") if "mfa_email_code_expires_at" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN mfa_email_code_expires_at DATETIME") if "mfa_email_code_sent_at" not in user_columns: statements.append("ALTER TABLE users ADD COLUMN mfa_email_code_sent_at DATETIME") email_config_columns = _table_columns(engine, "email_server_config") if "registration_admin_notify_enabled" not in email_config_columns: statements.append("ALTER TABLE email_server_config ADD COLUMN registration_admin_notify_enabled BOOLEAN NOT NULL DEFAULT 1") if "registration_admin_notify_admin_ids_csv" not in email_config_columns: statements.append("ALTER TABLE email_server_config ADD COLUMN registration_admin_notify_admin_ids_csv VARCHAR(1024)") time_entry_columns = _table_columns(engine, "time_entries") if "break_rule_mode" not in time_entry_columns: statements.append("ALTER TABLE time_entries ADD COLUMN break_rule_mode VARCHAR(16) NOT NULL DEFAULT 'manual'") if not statements: return with engine.begin() as conn: for statement in statements: conn.execute(text(statement)) conn.execute(text("UPDATE users SET entry_mode = 'auto_until_today' WHERE entry_mode = 'auto'")) conn.execute( text("CREATE INDEX IF NOT EXISTS ix_users_email_verification_token_hash ON users (email_verification_token_hash)") ) conn.execute( text( """ CREATE TABLE IF NOT EXISTS overtime_adjustments ( id VARCHAR(36) PRIMARY KEY NOT NULL, user_id VARCHAR(36) NOT NULL, date DATE NOT NULL, minutes INTEGER NOT NULL, notes TEXT, created_at DATETIME NOT NULL, FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, CONSTRAINT uq_user_overtime_adjustment_date UNIQUE (user_id, date) ) """ ) ) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_overtime_adjustments_user_id ON overtime_adjustments (user_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_overtime_adjustments_date ON overtime_adjustments (date)")) conn.execute( text( """ CREATE TABLE IF NOT EXISTS auto_entry_skips ( id VARCHAR(36) PRIMARY KEY NOT NULL, user_id VARCHAR(36) NOT NULL, date DATE NOT NULL, created_at DATETIME NOT NULL, FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE, CONSTRAINT uq_user_auto_entry_skip_date UNIQUE (user_id, date) ) """ ) ) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_auto_entry_skips_user_id ON auto_entry_skips (user_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_auto_entry_skips_date ON auto_entry_skips (date)")) conn.execute( text( """ CREATE TABLE IF NOT EXISTS site_content ( id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, key VARCHAR(64) NOT NULL UNIQUE, markdown_text TEXT NOT NULL DEFAULT '', updated_by_user_id VARCHAR(36), updated_at DATETIME, FOREIGN KEY(updated_by_user_id) REFERENCES users (id) ON DELETE SET NULL ) """ ) ) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_site_content_key ON site_content (key)")) conn.execute( text( """ CREATE TABLE IF NOT EXISTS support_tickets ( id VARCHAR(36) PRIMARY KEY NOT NULL, user_id VARCHAR(36), category VARCHAR(24) NOT NULL DEFAULT 'problem', status VARCHAR(24) NOT NULL DEFAULT 'open', name VARCHAR(255) NOT NULL DEFAULT '', email VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, message TEXT NOT NULL, admin_notes TEXT, source_ip_hash VARCHAR(128), source_user_agent VARCHAR(512), created_at DATETIME NOT NULL, updated_at DATETIME, closed_at DATETIME, FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL ) """ ) ) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_email ON support_tickets (email)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_source_ip_hash ON support_tickets (source_ip_hash)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_support_tickets_created_at ON support_tickets (created_at)"))