From 5097ceb82faf67f09a48700f25e971d545e65ed0 Mon Sep 17 00:00:00 2001 From: Javier Date: Thu, 29 Jan 2026 22:12:55 -0600 Subject: [PATCH] v0.13.0 - Add Consumption Sheets module and database migration system --- app.py | 4 + database/init_db.py | 80 ++++++++++++++- migrations.py | 245 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 migrations.py diff --git a/app.py b/app.py index 1f6163f..47edfc3 100644 --- a/app.py +++ b/app.py @@ -54,6 +54,10 @@ if not os.path.exists(db_path): create_default_users() print("Database initialized!") +# Run migrations to apply any pending database changes +from migrations import run_migrations +run_migrations() + # ==================== ROUTES: AUTHENTICATION ==================== diff --git a/database/init_db.py b/database/init_db.py index fecb0b9..de7643f 100644 --- a/database/init_db.py +++ b/database/init_db.py @@ -166,6 +166,38 @@ def init_database(): ) ''') + # ============================================ + # MODULE SYSTEM TABLES + # ============================================ + + # Modules Table - Available feature modules + cursor.execute(''' + CREATE TABLE IF NOT EXISTS Modules ( + module_id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + module_key TEXT UNIQUE NOT NULL, + description TEXT, + icon TEXT, + is_active INTEGER DEFAULT 1, + display_order INTEGER DEFAULT 0 + ) + ''') + + # UserModules Table - Module access per user + cursor.execute(''' + CREATE TABLE IF NOT EXISTS UserModules ( + user_module_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + module_id INTEGER NOT NULL, + granted_by INTEGER, + granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES Users(user_id), + FOREIGN KEY (module_id) REFERENCES Modules(module_id), + FOREIGN KEY (granted_by) REFERENCES Users(user_id), + UNIQUE(user_id, module_id) + ) + ''') + # ============================================ # CONSUMPTION SHEETS MODULE TABLES # ============================================ @@ -295,6 +327,52 @@ def create_default_users(): conn.close() +def create_default_modules(): + """Create default modules and assign to admin users""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Define default modules + default_modules = [ + ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1), + ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2), + ] + + # Insert modules (ignore if already exist) + for module in default_modules: + try: + cursor.execute(''' + INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order) + VALUES (?, ?, ?, ?, ?, ?) + ''', module) + except sqlite3.IntegrityError: + pass # Module already exists + + conn.commit() + + # Auto-assign all modules to owner and admin users + cursor.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")') + admin_users = cursor.fetchall() + + cursor.execute('SELECT module_id FROM Modules') + all_modules = cursor.fetchall() + + for user in admin_users: + for module in all_modules: + try: + cursor.execute(''' + INSERT INTO UserModules (user_id, module_id) + VALUES (?, ?) + ''', (user[0], module[0])) + except sqlite3.IntegrityError: + pass # Assignment already exists + + conn.commit() + conn.close() + print("āœ… Default modules created and assigned to admin users") + + if __name__ == '__main__': init_database() - create_default_users() \ No newline at end of file + create_default_users() + create_default_modules() \ No newline at end of file diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..e1109b8 --- /dev/null +++ b/migrations.py @@ -0,0 +1,245 @@ +""" +ScanLook Database Migration System + +Simple migration system that tracks and applies database changes. +Each migration has a version number and an up() function. + +Usage: + from migrations import run_migrations + run_migrations() # Call on app startup +""" + +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), 'database', 'scanlook.db') + + +def get_db(): + """Get database connection""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_migrations_table(): + """Create the migrations tracking table if it doesn't exist""" + conn = get_db() + conn.execute(''' + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() + conn.close() + + +def get_applied_migrations(): + """Get list of already-applied migration versions""" + conn = get_db() + try: + rows = conn.execute('SELECT version FROM schema_migrations ORDER BY version').fetchall() + return [row['version'] for row in rows] + except: + return [] + finally: + conn.close() + + +def record_migration(version, name): + """Record that a migration was applied""" + conn = get_db() + conn.execute('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [version, name]) + conn.commit() + conn.close() + + +def column_exists(table, column): + """Check if a column exists in a table""" + conn = get_db() + cursor = conn.execute(f'PRAGMA table_info({table})') + columns = [row[1] for row in cursor.fetchall()] + conn.close() + return column in columns + + +def table_exists(table): + """Check if a table exists""" + conn = get_db() + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [table]) + exists = cursor.fetchone() is not None + conn.close() + return exists + + +# ============================================ +# MIGRATIONS +# ============================================ +# Add new migrations to this list. +# Each migration is a tuple: (version, name, up_function) +# +# RULES: +# - Never modify an existing migration +# - Always add new migrations at the end with the next version number +# - Check if changes are needed before applying (idempotent) +# ============================================ + +def migration_001_add_modules_tables(): + """Add Modules and UserModules tables""" + conn = get_db() + + if not table_exists('Modules'): + conn.execute(''' + CREATE TABLE Modules ( + module_id INTEGER PRIMARY KEY AUTOINCREMENT, + module_name TEXT NOT NULL, + module_key TEXT UNIQUE NOT NULL, + description TEXT, + icon TEXT, + is_active INTEGER DEFAULT 1, + display_order INTEGER DEFAULT 0 + ) + ''') + print(" Created Modules table") + + if not table_exists('UserModules'): + conn.execute(''' + CREATE TABLE UserModules ( + user_module_id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + module_id INTEGER NOT NULL, + granted_by INTEGER, + granted_timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES Users(user_id), + FOREIGN KEY (module_id) REFERENCES Modules(module_id), + FOREIGN KEY (granted_by) REFERENCES Users(user_id), + UNIQUE(user_id, module_id) + ) + ''') + print(" Created UserModules table") + + conn.commit() + conn.close() + + +def migration_002_add_usermodules_granted_columns(): + """Add granted_by and granted_timestamp to UserModules if missing""" + conn = get_db() + + if table_exists('UserModules'): + if not column_exists('UserModules', 'granted_by'): + conn.execute('ALTER TABLE UserModules ADD COLUMN granted_by INTEGER') + print(" Added granted_by column to UserModules") + + if not column_exists('UserModules', 'granted_timestamp'): + conn.execute('ALTER TABLE UserModules ADD COLUMN granted_timestamp DATETIME') + print(" Added granted_timestamp column to UserModules") + + conn.commit() + conn.close() + + +def migration_003_add_default_modules(): + """Add default modules if they don't exist""" + conn = get_db() + + # Check if modules exist + existing = conn.execute('SELECT COUNT(*) as cnt FROM Modules').fetchone() + + if existing['cnt'] == 0: + conn.execute(''' + INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order) + VALUES ('Inventory Counts', 'counting', 'Cycle counts and physical inventory', 'fa-clipboard-check', 1, 1) + ''') + conn.execute(''' + INSERT INTO Modules (module_name, module_key, description, icon, is_active, display_order) + VALUES ('Consumption Sheets', 'cons_sheets', 'Production consumption tracking', 'fa-clipboard-list', 1, 2) + ''') + print(" Added default modules") + + conn.commit() + conn.close() + + +def migration_004_assign_modules_to_admins(): + """Auto-assign all modules to owner and admin users""" + conn = get_db() + + # Get admin users + admins = conn.execute('SELECT user_id FROM Users WHERE role IN ("owner", "admin")').fetchall() + modules = conn.execute('SELECT module_id FROM Modules').fetchall() + + for user in admins: + for module in modules: + try: + conn.execute(''' + INSERT INTO UserModules (user_id, module_id) + VALUES (?, ?) + ''', [user['user_id'], module['module_id']]) + except sqlite3.IntegrityError: + pass # Already assigned + + conn.commit() + conn.close() + print(" Assigned modules to admin users") + + +def migration_005_add_cons_process_fields_duplicate_key(): + """Add is_duplicate_key column to cons_process_fields if missing""" + conn = get_db() + + if table_exists('cons_process_fields'): + if not column_exists('cons_process_fields', 'is_duplicate_key'): + conn.execute('ALTER TABLE cons_process_fields ADD COLUMN is_duplicate_key INTEGER DEFAULT 0') + print(" Added is_duplicate_key column to cons_process_fields") + + conn.commit() + conn.close() + + +# List of all migrations in order +MIGRATIONS = [ + (1, 'add_modules_tables', migration_001_add_modules_tables), + (2, 'add_usermodules_granted_columns', migration_002_add_usermodules_granted_columns), + (3, 'add_default_modules', migration_003_add_default_modules), + (4, 'assign_modules_to_admins', migration_004_assign_modules_to_admins), + (5, 'add_cons_process_fields_duplicate_key', migration_005_add_cons_process_fields_duplicate_key), +] + + +def run_migrations(): + """Run all pending migrations""" + print("šŸ”„ Checking database migrations...") + + # Make sure migrations table exists + init_migrations_table() + + # Get already-applied migrations + applied = get_applied_migrations() + + # Run pending migrations + pending = [(v, n, f) for v, n, f in MIGRATIONS if v not in applied] + + if not pending: + print("āœ… Database is up to date") + return + + print(f"šŸ“¦ Running {len(pending)} migration(s)...") + + for version, name, func in pending: + print(f"\n Migration {version}: {name}") + try: + func() + record_migration(version, name) + print(f" āœ… Migration {version} complete") + except Exception as e: + print(f" āŒ Migration {version} failed: {e}") + raise + + print("\nāœ… All migrations complete") + + +if __name__ == '__main__': + run_migrations()