diff --git a/app/access_control.py b/app/access_control.py index 963becd..2daaf81 100644 --- a/app/access_control.py +++ b/app/access_control.py @@ -11,25 +11,37 @@ ROLES = { 'name': 'Super Administrator', 'description': 'Full system access to all modules and features', 'level': 100, - 'modules': ['quality', 'settings'] - }, - 'manager': { - 'name': 'Manager', - 'description': 'Full access to assigned modules and quality control', - 'level': 70, - 'modules': ['quality'] - }, - 'worker': { - 'name': 'Worker', - 'description': 'Limited access to view and create quality inspections', - 'level': 50, - 'modules': ['quality'] + 'modules': ['quality', 'settings', 'warehouse'] }, 'admin': { 'name': 'Administrator', 'description': 'Administrative access - can manage users and system configuration', 'level': 90, - 'modules': ['quality', 'settings'] + 'modules': ['quality', 'settings', 'warehouse'] + }, + 'manager': { + 'name': 'Manager - Quality', + 'description': 'Full access to quality module and quality control', + 'level': 70, + 'modules': ['quality'] + }, + 'warehouse_manager': { + 'name': 'Manager - Warehouse', + 'description': 'Full access to warehouse module - input and reports', + 'level': 75, + 'modules': ['warehouse'] + }, + 'worker': { + 'name': 'Worker - Quality', + 'description': 'Limited access to quality inspections - input only', + 'level': 50, + 'modules': ['quality'] + }, + 'warehouse_worker': { + 'name': 'Worker - Warehouse', + 'description': 'Limited access to warehouse - input pages only, no reports', + 'level': 35, + 'modules': ['warehouse'] } } @@ -101,7 +113,72 @@ MODULE_PERMISSIONS = { 'superadmin': ['view', 'edit'], 'admin': ['view', 'edit'], 'manager': [], - 'worker': [] + 'worker': [], + 'warehouse_manager': [], + 'warehouse_worker': [] + } + } + }, + 'warehouse': { + 'name': 'Warehouse Module', + 'sections': { + 'input': { + 'name': 'Warehouse Data Input', + 'actions': { + 'view': 'View warehouse input pages', + 'create': 'Create warehouse entries', + 'edit': 'Edit warehouse entries', + 'delete': 'Delete warehouse entries' + }, + 'superadmin': ['view', 'create', 'edit', 'delete'], + 'admin': ['view', 'create', 'edit', 'delete'], + 'manager': [], + 'worker': [], + 'warehouse_manager': ['view', 'create', 'edit', 'delete'], + 'warehouse_worker': ['view', 'create', 'edit'] + }, + 'reports': { + 'name': 'Warehouse Reports & Analytics', + 'actions': { + 'view': 'View warehouse reports', + 'export': 'Export warehouse data', + 'download': 'Download reports', + 'analytics': 'View analytics' + }, + 'superadmin': ['view', 'export', 'download', 'analytics'], + 'admin': ['view', 'export', 'download', 'analytics'], + 'manager': [], + 'worker': [], + 'warehouse_manager': ['view', 'export', 'download', 'analytics'], + 'warehouse_worker': [] + }, + 'locations': { + 'name': 'Location Management', + 'actions': { + 'view': 'View locations', + 'create': 'Create locations', + 'edit': 'Edit locations', + 'delete': 'Delete locations' + }, + 'superadmin': ['view', 'create', 'edit', 'delete'], + 'admin': ['view', 'create', 'edit', 'delete'], + 'manager': [], + 'worker': [], + 'warehouse_manager': ['view', 'create', 'edit', 'delete'], + 'warehouse_worker': ['view'] + }, + 'management': { + 'name': 'Warehouse User Management', + 'actions': { + 'manage_workers': 'Manage assigned workers', + 'manage_zones': 'Manage warehouse zones' + }, + 'superadmin': ['manage_workers', 'manage_zones'], + 'admin': ['manage_workers', 'manage_zones'], + 'manager': [], + 'worker': [], + 'warehouse_manager': ['manage_workers'], + 'warehouse_worker': [] } } } @@ -277,3 +354,192 @@ def get_user_permissions(user_role): permissions[module][section] = allowed_actions return permissions + + +# Warehouse-specific access control helpers + +def can_access_warehouse_input(user_role): + """ + Check if user can access warehouse INPUT pages + + Args: + user_role (str): User's role + + Returns: + bool: True if user can access input pages (managers and workers) + """ + return check_permission(user_role, 'warehouse', 'input', 'view') + + +def can_access_warehouse_reports(user_role): + """ + Check if user can access warehouse REPORT/ANALYTICS pages + + Args: + user_role (str): User's role + + Returns: + bool: True if user can access reports (managers only) + """ + return check_permission(user_role, 'warehouse', 'reports', 'view') + + +def can_manage_warehouse_workers(user_role): + """ + Check if user can manage warehouse workers (assign/unassign) + + Args: + user_role (str): User's role + + Returns: + bool: True if user can manage workers (managers only) + """ + return check_permission(user_role, 'warehouse', 'management', 'manage_workers') + + +# Zone-restricted access functions + +def get_worker_warehouse_zone(user_id): + """ + Get the warehouse zone restriction for a warehouse_worker + + Args: + user_id (int): Worker user ID + + Returns: + str or None: Zone name if restricted, None if all zones allowed + """ + try: + from app.database import get_db + db = get_db() + cursor = db.cursor() + + cursor.execute(""" + SELECT warehouse_zone FROM worker_manager_bindings + WHERE worker_id = %s AND is_active = 1 + LIMIT 1 + """, (user_id,)) + + result = cursor.fetchone() + if result: + return result[0] # Return zone name or None + return None + except Exception as e: + logger.error(f"Error getting worker zone: {e}") + return None + + +def get_manager_workers(manager_id): + """ + Get all active workers assigned to a manager + + Args: + manager_id (int): Manager user ID + + Returns: + list: List of dicts with worker info {id, username, full_name, zone} + """ + try: + from app.database import get_db + db = get_db() + cursor = db.cursor() + + cursor.execute(""" + SELECT u.id, u.username, u.full_name, wmb.warehouse_zone + FROM worker_manager_bindings wmb + JOIN users u ON wmb.worker_id = u.id + WHERE wmb.manager_id = %s AND wmb.is_active = 1 + ORDER BY u.full_name + """, (manager_id,)) + + results = [] + for row in cursor.fetchall(): + results.append({ + 'id': row[0], + 'username': row[1], + 'full_name': row[2], + 'zone': row[3] + }) + return results + except Exception as e: + logger.error(f"Error getting manager workers: {e}") + return [] + + +def validate_worker_zone_access(worker_id, manager_id, zone): + """ + Validate that a worker can access a specific zone + + Args: + worker_id (int): Worker user ID + manager_id (int): Expected manager ID + zone (str): Zone name to validate + + Returns: + bool: True if worker can access zone + """ + try: + from app.database import get_db + db = get_db() + cursor = db.cursor() + + cursor.execute(""" + SELECT warehouse_zone FROM worker_manager_bindings + WHERE worker_id = %s AND manager_id = %s AND is_active = 1 + LIMIT 1 + """, (worker_id, manager_id)) + + result = cursor.fetchone() + if not result: + return False # Binding doesn't exist + + assigned_zone = result[0] + + # If no zone restriction (NULL), worker can access all zones + if assigned_zone is None: + return True + + # If zone is restricted, must match the requested zone + return assigned_zone == zone + except Exception as e: + logger.error(f"Error validating worker zone access: {e}") + return False + + +def build_zone_filter_sql(user_id, user_role): + """ + Build WHERE clause SQL fragment for zone-filtered queries + + For managers: returns all data from assigned workers + For workers: returns only data from their assigned zone + For others: returns no data or all data depending on role + + Args: + user_id (int): User ID + user_role (str): User's role + + Returns: + str: SQL WHERE fragment (empty string if no filter needed) + """ + if user_role in ['superadmin', 'admin']: + return "" # No filter - see everything + + if user_role == 'warehouse_manager': + # Manager sees data from all assigned workers + try: + workers = get_manager_workers(user_id) + if not workers: + # Manager has no workers assigned - see own data only + return f"AND created_by_user_id = {user_id}" + worker_ids = [w['id'] for w in workers] + return f"AND (created_by_user_id IN ({','.join(map(str, worker_ids))}) OR created_by_user_id = {user_id})" + except Exception as e: + logger.error(f"Error building manager zone filter: {e}") + return f"AND created_by_user_id = {user_id}" + + if user_role == 'warehouse_worker': + # Worker sees only their own data in their assigned zone + return f"AND created_by_user_id = {user_id}" + + return "" # Default - no filtering + diff --git a/app/db_migrations/add_warehouse_roles_and_bindings.sql b/app/db_migrations/add_warehouse_roles_and_bindings.sql new file mode 100644 index 0000000..e8cb653 --- /dev/null +++ b/app/db_migrations/add_warehouse_roles_and_bindings.sql @@ -0,0 +1,80 @@ +-- Warehouse Module Roles & Worker-Manager Binding Migration +-- This migration adds: +-- 1. Two new warehouse roles (warehouse_manager, warehouse_worker) +-- 2. Worker-manager binding table for hierarchical access control + +-- ============================================================ +-- 1. Insert new warehouse roles +-- ============================================================ + +-- Note: These roles should be inserted if they don't exist +-- This is typically handled by the Python init script, but this +-- SQL is provided for reference or manual database setup + +INSERT IGNORE INTO roles (name, description, level) VALUES +('warehouse_manager', 'Manager - Warehouse - Full warehouse module access', 75), +('warehouse_worker', 'Worker - Warehouse - Input-only warehouse access', 35); + +-- Verify insertion +SELECT id, name, level FROM roles WHERE name LIKE 'warehouse_%' ORDER BY level DESC; + +-- ============================================================ +-- 2. Create worker_manager_bindings table +-- ============================================================ + +CREATE TABLE IF NOT EXISTS worker_manager_bindings ( + id INT AUTO_INCREMENT PRIMARY KEY, + manager_id INT NOT NULL, + worker_id INT NOT NULL, + warehouse_zone VARCHAR(100), + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_binding (manager_id, worker_id), + FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (worker_id) REFERENCES users(id) ON DELETE CASCADE, + CHECK (manager_id != worker_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 3. Example queries for worker-manager binding management +-- ============================================================ + +-- Assign a worker to a manager +-- INSERT INTO worker_manager_bindings (manager_id, worker_id, warehouse_zone) +-- VALUES (5, 12, NULL); -- Manager ID 5 oversees Worker ID 12 + +-- Get all workers for a specific manager +-- SELECT u.id, u.username, u.full_name, wmb.warehouse_zone +-- FROM worker_manager_bindings wmb +-- JOIN users u ON wmb.worker_id = u.id +-- WHERE wmb.manager_id = 5 AND wmb.is_active = 1 +-- ORDER BY u.full_name; + +-- Get manager for a specific worker +-- SELECT m.id, m.username, m.full_name +-- FROM worker_manager_bindings wmb +-- JOIN users m ON wmb.manager_id = m.id +-- WHERE wmb.worker_id = 12 AND wmb.is_active = 1; + +-- Deactivate a binding (soft delete) +-- UPDATE worker_manager_bindings SET is_active = 0 WHERE id = 1; + +-- ============================================================ +-- 4. Verification Queries +-- ============================================================ + +-- Verify warehouse roles exist +-- SELECT COUNT(*) as warehouse_role_count FROM roles WHERE name LIKE 'warehouse_%'; + +-- Verify worker_manager_bindings table exists +-- SELECT TABLE_NAME FROM information_schema.TABLES +-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'worker_manager_bindings'; + +-- List all role hierarchy (for reference) +-- SELECT name, level, CASE +-- WHEN level >= 90 THEN 'Admin' +-- WHEN level >= 70 THEN 'Manager' +-- WHEN level >= 35 THEN 'Worker' +-- ELSE 'Unknown' +-- END as category FROM roles ORDER BY level DESC; diff --git a/app/db_schema_verifier.py b/app/db_schema_verifier.py new file mode 100644 index 0000000..7847a3f --- /dev/null +++ b/app/db_schema_verifier.py @@ -0,0 +1,373 @@ +""" +Database Schema Verification & Repair Module +Checks existing database structure and adds missing tables/columns/data +Safely handles both fresh databases and existing installations +""" +import pymysql +import logging + +logger = logging.getLogger(__name__) + + +class SchemaVerifier: + """Verify and repair database schema structure""" + + def __init__(self, connection): + self.conn = connection + self.cursor = connection.cursor() + self.changes_made = [] + + def verify_and_repair(self): + """ + Main verification and repair process + + Returns: + tuple: (success: bool, changes_summary: str) + """ + try: + logger.info("=" * 60) + logger.info("Starting database schema verification...") + logger.info("=" * 60) + + # 1. Verify tables + self.verify_tables() + + # 2. Verify columns in existing tables + self.verify_columns() + + # 3. Verify reference data (roles, etc) + self.verify_reference_data() + + # Commit all changes + self.conn.commit() + + # Generate summary + summary = self.generate_summary() + logger.info("=" * 60) + logger.info("✓ Database schema verification complete") + logger.info("=" * 60) + + return True, summary + + except Exception as e: + logger.error(f"✗ Schema verification failed: {e}") + self.conn.rollback() + return False, f"Error: {str(e)}" + + def table_exists(self, table_name): + """Check if table exists in database""" + try: + self.cursor.execute(f""" + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s + """, (table_name,)) + return self.cursor.fetchone()[0] > 0 + except Exception as e: + logger.error(f"Error checking if table {table_name} exists: {e}") + return False + + def column_exists(self, table_name, column_name): + """Check if column exists in table""" + try: + self.cursor.execute(f""" + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = %s + AND COLUMN_NAME = %s + """, (table_name, column_name)) + return self.cursor.fetchone()[0] > 0 + except Exception as e: + logger.error(f"Error checking column {table_name}.{column_name}: {e}") + return False + + def get_table_columns(self, table_name): + """Get all columns for a table""" + try: + self.cursor.execute(f""" + SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, EXTRA + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s + """, (table_name,)) + return {row[0]: row for row in self.cursor.fetchall()} + except Exception as e: + logger.error(f"Error getting columns for {table_name}: {e}") + return {} + + def verify_tables(self): + """Verify all required tables exist""" + logger.info("Verifying tables...") + + # Define all required tables with their schemas + tables_to_verify = { + 'users': self.get_users_schema, + 'user_credentials': self.get_user_credentials_schema, + 'user_modules': self.get_user_modules_schema, + 'user_permissions': self.get_user_permissions_schema, + 'roles': self.get_roles_schema, + 'worker_manager_bindings': self.get_worker_manager_bindings_schema, + 'application_settings': self.get_application_settings_schema, + 'audit_logs': self.get_audit_logs_schema, + 'backup_schedules': self.get_backup_schedules_schema, + } + + for table_name, schema_getter in tables_to_verify.items(): + if not self.table_exists(table_name): + logger.info(f" ⚠ Table '{table_name}' missing - creating...") + try: + sql = schema_getter() + self.cursor.execute(sql) + self.changes_made.append(f"Created table '{table_name}'") + logger.info(f" ✓ Created table '{table_name}'") + except Exception as e: + logger.error(f" ✗ Failed to create table '{table_name}': {e}") + raise + else: + logger.info(f" ✓ Table '{table_name}' exists") + + def verify_columns(self): + """Verify all columns exist in tables""" + logger.info("Verifying table columns...") + + # Define required columns for each table + columns_to_verify = { + 'users': [ + ('id', 'INT', 'NO', 'PRI', 'auto_increment'), + ('username', 'VARCHAR(100)', 'NO', 'UNI', ''), + ('email', 'VARCHAR(255)', 'YES', 'UNI', ''), + ('full_name', 'VARCHAR(255)', 'YES', '', ''), + ('role', 'VARCHAR(100)', 'YES', 'MUL', ''), + ('is_active', 'TINYINT(1)', 'YES', '', ''), + ('created_at', 'TIMESTAMP', 'YES', '', 'DEFAULT_GENERATED'), + ('updated_at', 'TIMESTAMP', 'YES', '', 'DEFAULT_GENERATED'), + ], + 'worker_manager_bindings': [ + ('id', 'INT', 'NO', 'PRI', 'auto_increment'), + ('manager_id', 'INT', 'NO', 'MUL', ''), + ('worker_id', 'INT', 'NO', '', ''), + ('warehouse_zone', 'VARCHAR(100)', 'YES', '', ''), + ('is_active', 'TINYINT(1)', 'YES', '', ''), + ('created_at', 'TIMESTAMP', 'YES', '', 'DEFAULT_GENERATED'), + ('updated_at', 'TIMESTAMP', 'YES', '', 'DEFAULT_GENERATED'), + ], + 'roles': [ + ('id', 'INT', 'NO', 'PRI', 'auto_increment'), + ('name', 'VARCHAR(100)', 'NO', 'UNI', ''), + ('description', 'TEXT', 'YES', '', ''), + ('level', 'INT', 'YES', '', ''), + ('created_at', 'TIMESTAMP', 'YES', '', 'DEFAULT_GENERATED'), + ('updated_at', 'TIMESTAMP', 'YES', '', 'DEFAULT_GENERATED'), + ], + } + + for table_name, required_columns in columns_to_verify.items(): + if not self.table_exists(table_name): + continue + + logger.info(f" Checking columns in '{table_name}'...") + existing_columns = self.get_table_columns(table_name) + + for col_name, col_type, nullable, key, extra in required_columns: + if col_name not in existing_columns: + logger.info(f" ⚠ Column '{col_name}' missing - adding...") + try: + self.add_column(table_name, col_name, col_type, nullable) + self.changes_made.append(f"Added column '{table_name}.{col_name}'") + logger.info(f" ✓ Added column '{col_name}' to '{table_name}'") + except Exception as e: + logger.error(f" ✗ Failed to add column: {e}") + raise + else: + logger.info(f" ✓ Column '{col_name}' exists") + + def add_column(self, table_name, column_name, column_type, nullable='YES'): + """Add a missing column to a table""" + null_clause = 'NULL' if nullable == 'YES' else 'NOT NULL' + sql = f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type} {null_clause}" + self.cursor.execute(sql) + + def verify_reference_data(self): + """Verify and add missing reference data (roles, etc)""" + logger.info("Verifying reference data...") + + if not self.table_exists('roles'): + logger.info(" ⚠ Roles table doesn't exist, skipping reference data") + return + + # Define all required roles + required_roles = [ + ('superadmin', 'Super Administrator - Full system access', 100), + ('admin', 'Administrator - Administrative access', 90), + ('manager', 'Manager - Quality - Full access to assigned modules', 70), + ('warehouse_manager', 'Manager - Warehouse - Full warehouse module access', 75), + ('worker', 'Worker - Quality - Limited access', 50), + ('warehouse_worker', 'Worker - Warehouse - Input-only warehouse access', 35), + ] + + logger.info(" Verifying roles...") + for role_name, role_desc, role_level in required_roles: + self.cursor.execute("SELECT id FROM roles WHERE name = %s", (role_name,)) + + if not self.cursor.fetchone(): + try: + self.cursor.execute( + "INSERT INTO roles (name, description, level) VALUES (%s, %s, %s)", + (role_name, role_desc, role_level) + ) + self.changes_made.append(f"Added role '{role_name}'") + logger.info(f" ✓ Added role '{role_name}'") + except Exception as e: + logger.error(f" ✗ Failed to add role '{role_name}': {e}") + else: + logger.info(f" ✓ Role '{role_name}' exists") + + def generate_summary(self): + """Generate a summary of changes made""" + if not self.changes_made: + summary = "✓ Database structure is correct - no changes needed" + else: + summary = f"✓ Database repair complete - {len(self.changes_made)} change(s) made:\n" + for i, change in enumerate(self.changes_made, 1): + summary += f" {i}. {change}\n" + + return summary + + # Schema definitions + @staticmethod + def get_users_schema(): + return """ + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE, + full_name VARCHAR(255), + role VARCHAR(100), + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_role (role), + INDEX idx_username (username), + FOREIGN KEY (role) REFERENCES roles(name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_user_credentials_schema(): + return """ + CREATE TABLE IF NOT EXISTS user_credentials ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_roles_schema(): + return """ + CREATE TABLE IF NOT EXISTS roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + level INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_user_modules_schema(): + return """ + CREATE TABLE IF NOT EXISTS user_modules ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + module_name VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_user_module (user_id, module_name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_user_permissions_schema(): + return """ + CREATE TABLE IF NOT EXISTS user_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + module_name VARCHAR(100) NOT NULL, + section_name VARCHAR(100) NOT NULL, + action_name VARCHAR(100) NOT NULL, + granted TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_permission (user_id, module_name, section_name, action_name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_worker_manager_bindings_schema(): + return """ + CREATE TABLE IF NOT EXISTS worker_manager_bindings ( + id INT AUTO_INCREMENT PRIMARY KEY, + manager_id INT NOT NULL, + worker_id INT NOT NULL, + warehouse_zone VARCHAR(100), + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_binding (manager_id, worker_id), + FOREIGN KEY (manager_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (worker_id) REFERENCES users(id) ON DELETE CASCADE, + CHECK (manager_id != worker_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_application_settings_schema(): + return """ + CREATE TABLE IF NOT EXISTS application_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + setting_key VARCHAR(255) UNIQUE NOT NULL, + setting_value LONGTEXT, + setting_type VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_audit_logs_schema(): + return """ + CREATE TABLE IF NOT EXISTS audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + action VARCHAR(255) NOT NULL, + entity_type VARCHAR(100), + entity_id INT, + details JSON, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_action (action), + INDEX idx_created_at (created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + + @staticmethod + def get_backup_schedules_schema(): + return """ + CREATE TABLE IF NOT EXISTS backup_schedules ( + id INT AUTO_INCREMENT PRIMARY KEY, + schedule_name VARCHAR(255) NOT NULL, + frequency VARCHAR(50) NOT NULL, + last_backup TIMESTAMP, + next_backup TIMESTAMP, + retention_days INT DEFAULT 30, + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ diff --git a/app/modules/quality/routes.py b/app/modules/quality/routes.py index ee4a15f..7bc8903 100644 --- a/app/modules/quality/routes.py +++ b/app/modules/quality/routes.py @@ -63,15 +63,11 @@ def fg_scan(): operator_code, cp_code, oc1_code, oc2_code, defect_code, date, time ) - # Flash appropriate message based on defect code - if int(defect_code) == 0 or defect_code == '000': - flash(f'✅ APPROVED scan recorded for {cp_code}. Total approved: {approved_count}') - else: - flash(f'❌ REJECTED scan recorded for {cp_code} (defect: {defect_code}). Total rejected: {rejected_count}') + # Note: Flash messages are NOT used here - JS notifications handle this on the frontend + # This prevents wide flash message alerts from appearing except Exception as e: logger.error(f"Error saving finish goods scan data: {e}") - flash(f"Error saving scan data: {str(e)}", 'error') # Check if this is an AJAX request (for scan-to-boxes feature) if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.accept_mimetypes.best == 'application/json': @@ -86,7 +82,6 @@ def fg_scan(): scan_groups = get_latest_scans(limit=25) except Exception as e: logger.error(f"Error fetching latest scans: {e}") - flash(f"Error fetching scan data: {str(e)}", 'error') scan_groups = [] return render_template('modules/quality/fg_scan.html', scan_groups=scan_groups) diff --git a/app/modules/settings/routes.py b/app/modules/settings/routes.py index 950dffc..e7aae50 100644 --- a/app/modules/settings/routes.py +++ b/app/modules/settings/routes.py @@ -185,7 +185,6 @@ def create_user(): password = request.form.get('password', '').strip() confirm_password = request.form.get('confirm_password', '').strip() is_active = request.form.get('is_active') == 'on' - modules = request.form.getlist('modules') # Validation errors = [] @@ -202,8 +201,6 @@ def create_user(): errors.append("Passwords do not match") if len(password) < 8: errors.append("Password must be at least 8 characters") - if not modules: - errors.append("Select at least one module") if errors: conn = get_db() @@ -251,13 +248,6 @@ def create_user(): (user_id, password_hash) ) - # Grant module access - for module in modules: - cursor.execute( - "INSERT IGNORE INTO user_modules (user_id, module_name) VALUES (%s, %s)", - (user_id, module) - ) - conn.commit() cursor.close() @@ -311,11 +301,10 @@ def edit_user(user_id): if request.method == 'GET': cursor.close() - available_modules = ['quality', 'settings'] return render_template('modules/settings/user_form.html', user=user, roles=roles, - available_modules=available_modules, + available_modules=['quality', 'settings'], user_modules=user_modules) # Handle POST - Update user @@ -326,7 +315,6 @@ def edit_user(user_id): password = request.form.get('password', '').strip() confirm_password = request.form.get('confirm_password', '').strip() is_active = request.form.get('is_active') == 'on' - modules = request.form.getlist('modules') # Validation errors = [] @@ -339,16 +327,13 @@ def edit_user(user_id): errors.append("Passwords do not match") if password and len(password) < 8: errors.append("Password must be at least 8 characters") - if not modules: - errors.append("Select at least one module") if errors: cursor.close() - available_modules = ['quality', 'settings'] return render_template('modules/settings/user_form.html', user=user, roles=roles, - available_modules=available_modules, + available_modules=['quality', 'settings'], user_modules=user_modules, error="; ".join(errors)) @@ -366,14 +351,6 @@ def edit_user(user_id): (password_hash, user_id) ) - # Update module access - cursor.execute("DELETE FROM user_modules WHERE user_id = %s", (user_id,)) - for module in modules: - cursor.execute( - "INSERT INTO user_modules (user_id, module_name) VALUES (%s, %s)", - (user_id, module) - ) - conn.commit() cursor.close() @@ -381,11 +358,10 @@ def edit_user(user_id): except Exception as e: cursor.close() - available_modules = ['quality', 'settings'] return render_template('modules/settings/user_form.html', user=user, roles=roles, - available_modules=available_modules, + available_modules=['quality', 'settings'], user_modules=user_modules, error=f"Error updating user: {str(e)}") @@ -862,6 +838,40 @@ def restore_database(): return jsonify({'error': str(e)}), 500 +@settings_bp.route('/api/database/tables', methods=['GET']) +def get_database_tables(): + """Get list of all database tables (dynamically fetched)""" + if 'user_id' not in session: + return jsonify({'error': 'Unauthorized'}), 401 + + try: + conn = get_db() + cursor = conn.cursor() + + # Get list of all tables with their row counts + cursor.execute(""" + SELECT TABLE_NAME, TABLE_ROWS + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + ORDER BY TABLE_NAME + """) + + tables = [{'name': row[0], 'rows': row[1] or 0} for row in cursor.fetchall()] + cursor.close() + + return jsonify({ + 'success': True, + 'tables': tables, + 'count': len(tables) + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + @settings_bp.route('/api/database/truncate', methods=['POST']) def truncate_table(): """Truncate (clear) a database table""" diff --git a/app/modules/settings/warehouse_worker_management.py b/app/modules/settings/warehouse_worker_management.py new file mode 100644 index 0000000..d4f1b48 --- /dev/null +++ b/app/modules/settings/warehouse_worker_management.py @@ -0,0 +1,329 @@ +""" +Warehouse Worker Management Functions +Handles worker-manager bindings and zone assignments for the warehouse module +""" +from app.database import get_db +import logging + +logger = logging.getLogger(__name__) + + +def assign_worker_to_manager(manager_id, worker_id, warehouse_zone=None): + """ + Assign a warehouse worker to a manager with optional zone restriction + + Args: + manager_id (int): Manager user ID + worker_id (int): Worker user ID + warehouse_zone (str, optional): Zone restriction (e.g., "Cold Storage", "High Shelf") + + Returns: + tuple: (success: bool, message: str) + """ + try: + if manager_id == worker_id: + return False, "Cannot assign a worker to themselves" + + db = get_db() + cursor = db.cursor() + + # Check if binding already exists + cursor.execute(""" + SELECT id FROM worker_manager_bindings + WHERE manager_id = %s AND worker_id = %s + """, (manager_id, worker_id)) + + existing = cursor.fetchone() + + if existing: + # Update existing binding + cursor.execute(""" + UPDATE worker_manager_bindings + SET warehouse_zone = %s, is_active = 1, updated_at = NOW() + WHERE manager_id = %s AND worker_id = %s + """, (warehouse_zone, manager_id, worker_id)) + message = f"Worker assignment updated (zone: {warehouse_zone or 'all zones'})" + else: + # Create new binding + cursor.execute(""" + INSERT INTO worker_manager_bindings (manager_id, worker_id, warehouse_zone) + VALUES (%s, %s, %s) + """, (manager_id, worker_id, warehouse_zone)) + message = f"Worker assigned to manager (zone: {warehouse_zone or 'all zones'})" + + db.commit() + logger.info(f"✓ {message} - Manager ID: {manager_id}, Worker ID: {worker_id}") + return True, message + + except Exception as e: + logger.error(f"Error assigning worker to manager: {e}") + return False, f"Error: {str(e)}" + + +def unassign_worker_from_manager(manager_id, worker_id, soft_delete=True): + """ + Remove a worker from a manager's supervision + + Args: + manager_id (int): Manager user ID + worker_id (int): Worker user ID + soft_delete (bool): If True, set is_active=0; if False, delete record + + Returns: + tuple: (success: bool, message: str) + """ + try: + db = get_db() + cursor = db.cursor() + + if soft_delete: + cursor.execute(""" + UPDATE worker_manager_bindings + SET is_active = 0, updated_at = NOW() + WHERE manager_id = %s AND worker_id = %s + """, (manager_id, worker_id)) + else: + cursor.execute(""" + DELETE FROM worker_manager_bindings + WHERE manager_id = %s AND worker_id = %s + """, (manager_id, worker_id)) + + db.commit() + logger.info(f"✓ Worker unassigned - Manager ID: {manager_id}, Worker ID: {worker_id}") + return True, "Worker unassigned successfully" + + except Exception as e: + logger.error(f"Error unassigning worker: {e}") + return False, f"Error: {str(e)}" + + +def get_worker_binding_info(worker_id): + """ + Get binding information for a worker (who manages them, what zone) + + Args: + worker_id (int): Worker user ID + + Returns: + dict: {manager_id, manager_username, manager_name, zone, is_active} or None + """ + try: + db = get_db() + cursor = db.cursor() + + cursor.execute(""" + SELECT m.id, m.username, m.full_name, wmb.warehouse_zone, wmb.is_active + FROM worker_manager_bindings wmb + JOIN users m ON wmb.manager_id = m.id + WHERE wmb.worker_id = %s + LIMIT 1 + """, (worker_id,)) + + result = cursor.fetchone() + if result: + return { + 'manager_id': result[0], + 'manager_username': result[1], + 'manager_name': result[2], + 'zone': result[3], + 'is_active': result[4] + } + return None + + except Exception as e: + logger.error(f"Error getting worker binding info: {e}") + return None + + +def get_available_workers_for_assignment(exclude_manager_id=None): + """ + Get all warehouse_worker users available for assignment + + Args: + exclude_manager_id (int, optional): Manager ID to exclude from results + + Returns: + list: List of dicts {id, username, full_name, current_manager} + """ + try: + db = get_db() + cursor = db.cursor() + + query = """ + SELECT u.id, u.username, u.full_name, + (SELECT m.full_name FROM worker_manager_bindings wmb + JOIN users m ON wmb.manager_id = m.id + WHERE wmb.worker_id = u.id AND wmb.is_active = 1 LIMIT 1) as current_manager + FROM users u + WHERE u.role = 'warehouse_worker' + ORDER BY u.full_name + """ + + cursor.execute(query) + + results = [] + for row in cursor.fetchall(): + results.append({ + 'id': row[0], + 'username': row[1], + 'full_name': row[2], + 'current_manager': row[3] + }) + + return results + + except Exception as e: + logger.error(f"Error getting available workers: {e}") + return [] + + +def get_manager_assigned_workers(manager_id, include_inactive=False): + """ + Get all workers assigned to a specific manager + + Args: + manager_id (int): Manager user ID + include_inactive (bool): Whether to include inactive bindings + + Returns: + list: List of dicts {id, username, full_name, zone, is_active} + """ + try: + db = get_db() + cursor = db.cursor() + + query = """ + SELECT u.id, u.username, u.full_name, wmb.warehouse_zone, wmb.is_active + FROM worker_manager_bindings wmb + JOIN users u ON wmb.worker_id = u.id + WHERE wmb.manager_id = %s + """ + + params = [manager_id] + + if not include_inactive: + query += " AND wmb.is_active = 1" + + query += " ORDER BY u.full_name" + + cursor.execute(query, params) + + results = [] + for row in cursor.fetchall(): + results.append({ + 'id': row[0], + 'username': row[1], + 'full_name': row[2], + 'zone': row[3], + 'is_active': row[4] + }) + + return results + + except Exception as e: + logger.error(f"Error getting manager's workers: {e}") + return [] + + +def validate_zone_name(zone_name): + """ + Validate zone name format (alphanumeric, spaces, hyphens, underscores allowed) + + Args: + zone_name (str): Zone name to validate + + Returns: + tuple: (is_valid: bool, message: str) + """ + if not zone_name: + return True, "No zone restriction" # NULL zone is valid (all zones) + + if not isinstance(zone_name, str): + return False, "Zone must be a string" + + zone_name = zone_name.strip() + + if len(zone_name) > 100: + return False, "Zone name too long (max 100 characters)" + + if len(zone_name) < 2: + return False, "Zone name too short (min 2 characters)" + + # Allow alphanumeric, spaces, hyphens, underscores + import re + if not re.match(r'^[a-zA-Z0-9\s\-_]+$', zone_name): + return False, "Zone name contains invalid characters" + + return True, "Valid zone name" + + +def get_warehouse_zones(): + """ + Get list of all warehouse zones in use + + Returns: + list: List of zone names currently assigned to workers + """ + try: + db = get_db() + cursor = db.cursor() + + cursor.execute(""" + SELECT DISTINCT warehouse_zone + FROM worker_manager_bindings + WHERE is_active = 1 AND warehouse_zone IS NOT NULL + ORDER BY warehouse_zone + """) + + zones = [row[0] for row in cursor.fetchall()] + return zones + + except Exception as e: + logger.error(f"Error getting warehouse zones: {e}") + return [] + + +def reassign_worker(worker_id, new_manager_id, warehouse_zone=None): + """ + Reassign a worker from current manager to a new manager + + Args: + worker_id (int): Worker user ID + new_manager_id (int): New manager user ID + warehouse_zone (str, optional): New zone restriction + + Returns: + tuple: (success: bool, message: str) + """ + try: + if worker_id == new_manager_id: + return False, "Cannot assign a worker to themselves" + + db = get_db() + cursor = db.cursor() + + # Deactivate old binding + cursor.execute(""" + UPDATE worker_manager_bindings + SET is_active = 0 + WHERE worker_id = %s AND is_active = 1 + """, (worker_id,)) + + # Create new binding + cursor.execute(""" + INSERT INTO worker_manager_bindings (manager_id, worker_id, warehouse_zone) + VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE + is_active = 1, + warehouse_zone = %s, + updated_at = NOW() + """, (new_manager_id, worker_id, warehouse_zone, warehouse_zone)) + + db.commit() + + logger.info(f"✓ Worker reassigned - Worker ID: {worker_id}, New Manager ID: {new_manager_id}") + return True, "Worker reassigned successfully" + + except Exception as e: + logger.error(f"Error reassigning worker: {e}") + return False, f"Error: {str(e)}" diff --git a/app/static/css/database_management.css b/app/static/css/database_management.css new file mode 100644 index 0000000..d6d59d6 --- /dev/null +++ b/app/static/css/database_management.css @@ -0,0 +1,167 @@ +/* Database Management Module - Theme-Aware Styling */ + +/* Modal Styling with Theme Support */ +#confirmTruncateModal .modal-content { + background-color: var(--bg-primary); + border-color: var(--border-color); + border-radius: 8px; + box-shadow: 0 10px 40px var(--card-shadow-hover); +} + +#confirmTruncateModal .modal-header { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + border-bottom-color: var(--border-color); + border-radius: 8px 8px 0 0; +} + +#confirmTruncateModal .modal-header h5 { + color: white; + margin: 0; + font-weight: 600; +} + +#confirmTruncateModal .modal-body { + color: var(--text-primary); + padding: 20px; +} + +#confirmTruncateModal .modal-body p { + color: var(--text-primary); +} + +#confirmTruncateModal .modal-body strong { + font-weight: 600; +} + +/* Table name display box */ +#confirmTruncateModal .table-name-box { + background-color: var(--bg-secondary); + border: 2px solid #ef4444; + border-radius: 6px; + padding: 15px; + margin: 15px 0; +} + +#confirmTruncateModal .table-name-box strong { + color: #ef4444; + font-size: 1.1em; + word-break: break-all; +} + +/* Warning alert */ +#confirmTruncateModal .warning-alert { + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid #ef4444; + border-radius: 6px; + padding: 12px 15px; + margin: 15px 0; + color: var(--text-primary); +} + +#confirmTruncateModal .warning-alert i { + color: #ef4444; + margin-right: 8px; +} + +/* Input field styling */ +#confirmTruncateModal #confirm-table-input { + background-color: var(--input-bg); + border: 1px solid var(--input-border); + color: var(--text-primary); + border-radius: 6px; + padding: 10px 12px; + font-size: 14px; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +#confirmTruncateModal #confirm-table-input:focus { + background-color: var(--input-bg); + border-color: #ef4444; + color: var(--text-primary); + box-shadow: 0 0 0 0.2rem rgba(239, 68, 68, 0.15); + outline: none; +} + +#confirmTruncateModal #confirm-table-input::placeholder { + color: var(--text-secondary); + opacity: 0.7; +} + +/* Modal footer */ +#confirmTruncateModal .modal-footer { + background-color: var(--bg-secondary); + border-top: 1px solid var(--border-color); + border-radius: 0 0 8px 8px; + padding: 15px 20px; +} + +/* Cancel button */ +#confirmTruncateModal .btn-secondary { + background-color: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-color); + transition: all 0.3s ease; +} + +#confirmTruncateModal .btn-secondary:hover:not(:disabled) { + background-color: var(--border-color); + color: var(--text-primary); +} + +/* Delete button */ +#confirmTruncateModal .btn-danger { + background-color: #ef4444; + border-color: #dc2626; + color: white; + font-weight: 600; + transition: all 0.3s ease; +} + +#confirmTruncateModal .btn-danger:hover:not(:disabled) { + background-color: #dc2626; + border-color: #b91c1c; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +#confirmTruncateModal .btn-danger:disabled { + background-color: #9ca3af; + border-color: #6b7280; + cursor: not-allowed; + opacity: 0.6; +} + +/* Modal backdrop */ +.modal-backdrop.show { + background-color: rgba(0, 0, 0, 0.5); +} + +/* Help text styling */ +#confirmTruncateModal .text-muted { + color: var(--text-secondary) !important; + font-size: 0.85em; +} + +/* Label styling */ +#confirmTruncateModal .modal-body > p:first-of-type { + font-size: 0.95em; +} + +/* Dark mode specific adjustments */ +[data-theme="dark"] #confirmTruncateModal .modal-body strong { + color: #ef4444; +} + +[data-theme="dark"] #confirmTruncateModal .table-name-box strong { + color: #ff6b6b; +} + +[data-theme="dark"] #confirmTruncateModal .warning-alert { + background-color: rgba(239, 68, 68, 0.2); +} + +[data-theme="dark"] #confirmTruncateModal #confirm-table-input:focus { + background-color: var(--input-bg); + border-color: #ff6b6b; + box-shadow: 0 0 0 0.2rem rgba(255, 107, 107, 0.15); +} diff --git a/app/templates/modules/quality/fg_scan.html b/app/templates/modules/quality/fg_scan.html index 15bcd9f..ef3f286 100644 --- a/app/templates/modules/quality/fg_scan.html +++ b/app/templates/modules/quality/fg_scan.html @@ -921,12 +921,58 @@ function showNotification(message, type) { const notification = document.createElement('div'); notification.className = `notification notification-${type}`; notification.textContent = message; - notification.style.opacity = '1'; + // Apply inline styles to ensure proper positioning and appearance + notification.style.cssText = ` + position: fixed; + top: 30px; + right: 30px; + padding: 12px 20px; + border-radius: 6px; + z-index: 9999; + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + font-weight: 600; + font-size: 14px; + white-space: nowrap; + opacity: 1; + animation: slideInRight 0.3s ease-out, slideOutRight 0.3s ease-in 2.7s forwards; + `; document.body.appendChild(notification); + // Ensure animations exist + if (!document.getElementById('notification-styles')) { + const style = document.createElement('style'); + style.id = 'notification-styles'; + style.textContent = ` + @keyframes slideInRight { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + @keyframes slideOutRight { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(400px); + opacity: 0; + } + } + `; + document.head.appendChild(style); + } + + // Remove notification after animation completes setTimeout(() => { - notification.style.opacity = '0'; - setTimeout(() => notification.remove(), 300); + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } }, 3000); } diff --git a/app/templates/modules/settings/database_management.html b/app/templates/modules/settings/database_management.html index 93c5db0..c6844ce 100644 --- a/app/templates/modules/settings/database_management.html +++ b/app/templates/modules/settings/database_management.html @@ -2,6 +2,10 @@ {% block title %}Database Management{% endblock %} +{% block extra_css %} + +{% endblock %} + {% block content %}
@@ -328,15 +332,12 @@
-
@@ -411,21 +412,26 @@