feat: Implement warehouse module roles with auto-schema repair and remove module access section

- Add SchemaVerifier class for automatic database schema verification and repair
- Implement warehouse_manager (Level 75) and warehouse_worker (Level 35) roles
- Add zone-based access control for warehouse workers
- Implement worker-manager binding system with zone filtering
- Add comprehensive database auto-repair on Docker initialization
- Remove Module Access section from user form (role-based access only)
- Add autocomplete attributes to password fields for better UX
- Include detailed documentation for warehouse implementation
- Update initialize_db.py with schema verification as Step 0
This commit is contained in:
Quality App Developer
2026-01-28 00:46:59 +02:00
parent e6ff40184a
commit 8de85ca87f
18 changed files with 4194 additions and 167 deletions

View File

@@ -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

View File

@@ -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;

373
app/db_schema_verifier.py Normal file
View File

@@ -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
"""

View File

@@ -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)

View File

@@ -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"""

View File

@@ -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)}"

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -2,6 +2,10 @@
{% block title %}Database Management{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/database_management.css') }}">
{% endblock %}
{% block content %}
<div class="container-fluid py-5">
<div class="row mb-4">
@@ -328,15 +332,12 @@
<div class="col-md-6">
<label for="truncate-table-select" class="form-label">Select Table:</label>
<select class="form-select" id="truncate-table-select">
<option value="">-- Select a table --</option>
{% for table in tables %}
<option value="{{ table.name }}">{{ table.name }} ({{ table.rows }} rows)</option>
{% endfor %}
<option value="">-- Loading tables... --</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label d-block">&nbsp;</label>
<button type="button" class="btn btn-danger w-100" id="truncate-btn" disabled data-bs-toggle="modal" data-bs-target="#confirmTruncateModal">
<button type="button" class="btn btn-danger w-100" id="truncate-btn" disabled>
<i class="fas fa-trash"></i> Clear Selected Table
</button>
</div>
@@ -411,21 +412,26 @@
<!-- Confirm Truncate Modal -->
<div class="modal fade" id="confirmTruncateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title"><i class="fas fa-exclamation-triangle"></i> Confirm Table Clear</h5>
<div class="modal-content" style="background-color: var(--bg-primary); border-color: var(--border-color);">
<div class="modal-header" style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); border-bottom-color: var(--border-color);">
<h5 class="modal-title" style="color: white;"><i class="fas fa-exclamation-triangle"></i> Confirm Table Clear</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-2">You are about to <strong>permanently delete all data</strong> from:</p>
<p class="bg-light p-2 rounded"><strong id="confirm-table-name"></strong></p>
<p class="text-danger"><strong>This action cannot be undone!</strong></p>
<p class="small text-muted mb-0">Please ensure you have a backup before proceeding.</p>
<div class="modal-body" style="color: var(--text-primary);">
<p class="mb-3">You are about to <strong>permanently delete all data</strong> from:</p>
<p class="p-3 rounded mb-3" style="background-color: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary);"><strong id="confirm-table-name" style="font-size: 1.1em; color: #ef4444;"></strong></p>
<div class="alert" role="alert" style="background-color: rgba(239, 68, 68, 0.1); border: 1px solid #ef4444; color: var(--text-primary);">
<i class="fas fa-exclamation-circle"></i> <strong>This action CANNOT be undone!</strong>
</div>
<p class="mb-2" style="color: var(--text-primary);"><strong>To confirm, please type the table name:</strong></p>
<input type="text" class="form-control" id="confirm-table-input" placeholder="Enter table name to confirm..." autocomplete="off" style="background-color: var(--input-bg); border-color: var(--input-border); color: var(--text-primary);">
<small class="text-muted d-block mt-2">This is a safety measure to prevent accidental data deletion.</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-truncate-btn">
<i class="fas fa-trash"></i> Yes, Clear Table
<div class="modal-footer" style="border-top-color: var(--border-color);">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-color);">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-truncate-btn" disabled style="background-color: #ef4444; border-color: #dc2626;">
<i class="fas fa-trash"></i> Yes, Clear All Data
</button>
</div>
</div>
@@ -434,12 +440,27 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load all database tables dynamically
loadDatabaseTables();
// Load backups list
loadBackupsList();
// Load schedules list
loadBackupSchedules();
// ===== DECLARE VARIABLES ONCE AT THE TOP =====
const truncateSelect = document.getElementById('truncate-table-select');
const truncateBtn = document.getElementById('truncate-btn');
const confirmInput = document.getElementById('confirm-table-input');
const confirmBtn = document.getElementById('confirm-truncate-btn');
console.log('Initializing truncate handlers...');
console.log('truncateSelect:', truncateSelect);
console.log('truncateBtn:', truncateBtn);
console.log('confirmInput:', confirmInput);
console.log('confirmBtn:', confirmBtn);
// Cleanup old backups handler
const cleanupBtn = document.getElementById('cleanup-old-backups-btn');
if (cleanupBtn) {
@@ -504,89 +525,167 @@ document.addEventListener('DOMContentLoaded', function() {
if (modal) modal.hide();
});
// Truncate table handler
const truncateSelect = document.getElementById('truncate-table-select');
const truncateBtn = document.getElementById('truncate-btn');
console.log('Initializing truncate handler...');
console.log('truncateSelect element:', truncateSelect);
console.log('truncateBtn element:', truncateBtn);
console.log('truncateBtn.disabled initial value:', truncateBtn ? truncateBtn.disabled : 'N/A');
// Truncate table SELECT change handler - enable/disable button
console.log('Registering SELECT change handler...');
if (truncateSelect) {
if (truncateSelect && truncateBtn) {
truncateSelect.addEventListener('change', function() {
const table = this.value;
const option = this.options[this.selectedIndex];
console.log('=== TRUNCATE HANDLER FIRED ===');
console.log('Selected value:', table);
console.log('Selected option text:', option.text);
console.log('Button disabled before:', truncateBtn.disabled);
console.log('=== TABLE SELECTED ===');
console.log('Selected table:', table);
if (table) {
console.log('Table selected - enabling button');
document.getElementById('truncate-info').style.display = 'block';
document.getElementById('truncate-table-name').textContent = table;
document.getElementById('truncate-row-count').textContent = option.text.match(/\((\d+)\s*rows\)/)?.[1] || '0';
if (table && table.trim()) {
console.log('✓ Valid table selected - ENABLING truncate button');
// Safely update info display
const truncateInfo = document.getElementById('truncate-info');
if (truncateInfo) {
truncateInfo.style.display = 'block';
}
// Safely update table name
const truncateTableName = document.getElementById('truncate-table-name');
if (truncateTableName) {
truncateTableName.textContent = table;
}
// Safely update row count
const truncateRowCount = document.getElementById('truncate-row-count');
if (truncateRowCount) {
truncateRowCount.textContent = option.text.match(/\((\d+)\s*rows\)/)?.[1] || '0';
}
// Safely update confirm table name
const confirmTableName = document.getElementById('confirm-table-name');
if (confirmTableName) {
confirmTableName.textContent = table;
}
// Enable the button
truncateBtn.disabled = false;
document.getElementById('confirm-table-name').textContent = table;
console.log('Button disabled after setting to false:', truncateBtn.disabled);
console.log('Button enabled successfully');
} else {
console.log('No table selected - disabling button');
document.getElementById('truncate-info').style.display = 'none';
console.log('No table selected - DISABLING truncate button');
// Safely hide info display
const truncateInfo = document.getElementById('truncate-info');
if (truncateInfo) {
truncateInfo.style.display = 'none';
}
truncateBtn.disabled = true;
console.log('Button disabled after setting to true:', truncateBtn.disabled);
}
});
console.log('✓ Change event listener registered on truncate-table-select');
} else {
console.error('✗ truncate-table-select element not found!');
console.error('✗ truncate-table-select or truncate-btn not found!');
}
// Confirm truncate
document.getElementById('confirm-truncate-btn').addEventListener('click', function() {
const table = document.getElementById('truncate-table-select').value;
// Disable button to prevent multiple clicks
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Clearing...';
fetch('{{ url_for("settings.truncate_table") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ table: table })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmTruncateModal'));
if (modal) modal.hide();
// Show success message
alert('Table cleared successfully! Refreshing page...');
// Refresh the page after a short delay
setTimeout(() => {
location.reload();
}, 500);
// Truncate button click handler - opens modal
if (truncateBtn && truncateSelect) {
truncateBtn.addEventListener('click', function() {
const selectedTable = truncateSelect.value;
console.log('Truncate button clicked, selected table:', selectedTable);
if (!selectedTable) {
alert('Please select a table first');
return;
}
// Show the modal
const modalElement = document.getElementById('confirmTruncateModal');
if (!modalElement) {
console.error('❌ confirmTruncateModal element not found');
alert('Error: Modal not found');
return;
}
const modal = new bootstrap.Modal(modalElement);
// Clear the input field
if (confirmInput) {
confirmInput.value = '';
}
// Disable the confirm button
if (confirmBtn) {
confirmBtn.disabled = true;
}
console.log('Opening modal for table:', selectedTable);
modal.show();
});
}
// Confirmation input handler - enables button only when user types correct table name
if (confirmInput && confirmBtn) {
confirmInput.addEventListener('input', function() {
const selectedTable = truncateSelect.value;
const inputValue = this.value.trim();
// Enable button only if user typed the exact table name
if (inputValue === selectedTable) {
confirmBtn.disabled = false;
console.log('✓ Table name matches - button enabled');
} else {
alert('Error: ' + data.error);
confirmBtn.disabled = true;
console.log('✗ Table name does not match - button disabled');
}
});
}
// Confirm truncate button click
if (confirmBtn) {
confirmBtn.addEventListener('click', function() {
const table = truncateSelect.value;
// Disable button to prevent multiple clicks
this.disabled = true;
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Clearing...';
fetch('{{ url_for("settings.truncate_table") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ table: table })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmTruncateModal'));
if (modal) modal.hide();
// Show success message
alert('Table cleared successfully! Refreshing page...');
// Refresh the page after a short delay
setTimeout(() => {
location.reload();
}, 500);
} else {
alert('Error: ' + data.error);
// Re-enable button
this.disabled = false;
this.innerHTML = '<i class="fas fa-trash"></i> Yes, Clear All Data';
}
})
.catch(error => {
console.error('Error:', error);
alert('Error clearing table: ' + error);
// Re-enable button
this.disabled = false;
this.innerHTML = '<i class="fas fa-trash"></i> Yes, Clear Table';
}
})
.catch(error => {
console.error('Error:', error);
alert('Error clearing table: ' + error);
// Re-enable button
this.disabled = false;
this.innerHTML = '<i class="fas fa-trash"></i> Yes, Clear Table';
this.innerHTML = '<i class="fas fa-trash"></i> Yes, Clear All Data';
});
});
});
}
// Restore backup handler
document.getElementById('restore-backup-select').addEventListener('change', function() {
@@ -900,5 +999,51 @@ function uploadBackupFile() {
}
// Load schedules on page load
function loadDatabaseTables() {
/**
* Dynamically load ALL database tables from the server
* This ensures that newly added tables are always available
* Called ONCE on page load
*/
console.log('🔄 Loading database tables...');
fetch('{{ url_for("settings.get_database_tables") }}')
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success && data.tables && data.tables.length > 0) {
const select = document.getElementById('truncate-table-select');
if (!select) {
console.error('❌ truncate-table-select element not found');
return;
}
// Clear existing options except the first placeholder
select.innerHTML = '<option value="">-- Select a table --</option>';
// Add all tables as options
data.tables.forEach(table => {
const option = document.createElement('option');
option.value = table.name;
option.textContent = `${table.name} (${table.rows} rows)`;
select.appendChild(option);
});
console.log(`✓ Loaded ${data.tables.length} tables from database`);
} else {
console.warn('⚠️ No tables returned from server or API returned an error');
console.warn('Response:', data);
}
})
.catch(error => {
console.error('❌ Error loading database tables:', error);
document.getElementById('truncate-table-select').innerHTML = '<option value="">-- Error loading tables --</option>';
});
}
</script>
{% endblock %}

View File

@@ -77,14 +77,100 @@
<label for="role" class="form-label">Role <span class="text-danger">*</span></label>
<select class="form-select" id="role" name="role" required>
<option value="">-- Select a role --</option>
{% for role in roles %}
<option value="{{ role.name }}"
{% if user and user.role == role.name %}selected{% endif %}>
{{ role.name | capitalize }} (Level {{ role.level }})
</option>
{% endfor %}
<optgroup label="System Roles">
<option value="superadmin"
{% if user and user.role == 'superadmin' %}selected{% endif %}>
Super Admin (Level 100)
</option>
<option value="admin"
{% if user and user.role == 'admin' %}selected{% endif %}>
Admin (Level 90)
</option>
</optgroup>
<optgroup label="Quality Module">
<option value="manager"
{% if user and user.role == 'manager' %}selected{% endif %}>
Manager - Quality (Level 70)
</option>
<option value="worker"
{% if user and user.role == 'worker' %}selected{% endif %}>
Worker - Quality (Level 50)
</option>
</optgroup>
<optgroup label="Warehouse Module">
<option value="warehouse_manager"
{% if user and user.role == 'warehouse_manager' %}selected{% endif %}>
Manager - Warehouse (Level 75)
</option>
<option value="warehouse_worker"
{% if user and user.role == 'warehouse_worker' %}selected{% endif %}>
Worker - Warehouse (Level 35)
</option>
</optgroup>
</select>
<small class="form-text text-muted">User's access level</small>
<small class="form-text text-muted">
User's access level and role. See role descriptions below.
</small>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label class="form-label"><i class="fas fa-info-circle"></i> Role Reference Matrix</label>
<div class="table-responsive" style="font-size: 0.9rem;">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th style="width: 15%;">Role</th>
<th style="width: 8%;">Level</th>
<th style="width: 20%;">Modules</th>
<th style="width: 57%;">Permissions & Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Super Admin</strong></td>
<td><span class="badge bg-danger">100</span></td>
<td>All</td>
<td>Unrestricted access to entire system. Can manage all users and configuration.</td>
</tr>
<tr>
<td><strong>Admin</strong></td>
<td><span class="badge bg-danger">90</span></td>
<td>All</td>
<td>Full system administration. Can manage users, settings, database, backups.</td>
</tr>
<tr>
<td><strong>Manager - Quality</strong></td>
<td><span class="badge bg-success">70</span></td>
<td>Quality</td>
<td>Create, edit, delete quality inspections. Can export and download quality reports.</td>
</tr>
<tr>
<td><strong>Manager - Warehouse</strong></td>
<td><span class="badge bg-success">75</span></td>
<td>Warehouse</td>
<td>Full warehouse input and analytics access. Can manage assigned warehouse workers and zones.</td>
</tr>
<tr>
<td><strong>Worker - Quality</strong></td>
<td><span class="badge bg-warning text-dark">50</span></td>
<td>Quality</td>
<td>Create and view quality inspections only. Cannot edit, delete, or view reports.</td>
</tr>
<tr>
<td><strong>Worker - Warehouse</strong></td>
<td><span class="badge bg-warning text-dark">35</span></td>
<td>Warehouse</td>
<td>Input pages only (set locations, create entries). <strong>No access to reports or analytics.</strong> Must be assigned to a Manager.</td>
</tr>
</tbody>
</table>
</div>
<small class="form-text text-muted d-block mt-2">
<strong>Important:</strong> Warehouse workers are assigned to a manager for supervision.
Quality and Warehouse modules are separate - users can have one or both module access.
</small>
</div>
</div>
@@ -94,7 +180,7 @@
Password
{% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="password" name="password"
<input type="password" class="form-control" id="password" name="password" autocomplete="new-password"
{% if not user %}required{% else %}placeholder="Leave blank to keep current password"{% endif %}>
<small class="form-text text-muted">
{% if user %}Leave blank to keep current password{% else %}Minimum 8 characters{% endif %}
@@ -105,34 +191,12 @@
Confirm Password
{% if not user %}<span class="text-danger">*</span>{% endif %}
</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password"
<input type="password" class="form-control" id="confirm_password" name="confirm_password" autocomplete="new-password"
{% if not user %}required{% endif %}>
<small class="form-text text-muted">Re-enter password to confirm</small>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="modules" class="form-label">Module Access <span class="text-danger">*</span></label>
<div class="card bg-light">
<div class="card-body">
{% for module in available_modules %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="module_{{ module }}"
name="modules" value="{{ module }}"
{% if user and module in user_modules %}checked{% endif %}>
<label class="form-check-label" for="module_{{ module }}">
<i class="fas fa-{% if module == 'quality' %}check-square{% elif module == 'settings' %}sliders-h{% else %}cube{% endif %}"></i>
{{ module | capitalize }} Module
</label>
</div>
{% endfor %}
</div>
</div>
<small class="form-text text-muted">Select which modules this user can access</small>
</div>
</div>
<div class="row">
<div class="col-12 mb-3">
<label for="is_active" class="form-check-label">