import os import sys import sqlite3 import hashlib import pandas as pd def get_db_path(): """ Returns the absolute path to the SQLite database. If the application is running inside a Docker container, it uses /app/data/nbu_insights.db. If running locally, it defaults to a local './data' directory. """ container_data_dir = "/app/data" if os.path.exists(container_data_dir) or os.environ.get("NBU_INSIGHTS_RUNNING") == "1": db_dir = container_data_dir else: base_dir = os.path.dirname(os.path.abspath(__file__)) db_dir = os.path.join(base_dir, "data") os.makedirs(db_dir, exist_ok=True) return os.path.join(db_dir, "nbu_insights.db") def get_connection(): """ Establishes and returns a connection to the SQLite database. Rows are configured to be accessible by column names like a dictionary. """ db_path = get_db_path() conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row return conn def hash_password(password, salt=None): """ Hashes a password using PBKDF2-SHA256 with 100,000 iterations and a unique salt. Returns string 'salt_hex:hash_hex' which is safe to store in the DB. """ if salt is None: salt = os.urandom(16) elif isinstance(salt, str): salt = bytes.fromhex(salt) key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) return salt.hex() + ":" + key.hex() def verify_password(stored_password_hash, provided_password): """ Verifies a password against its stored PBKDF2-SHA256 hash. """ try: salt_hex, key_hex = stored_password_hash.split(":") salt = bytes.fromhex(salt_hex) expected_key_hex = hashlib.pbkdf2_hmac('sha256', provided_password.encode('utf-8'), salt, 100000).hex() return key_hex == expected_key_hex except Exception: return False def init_db(): """ Initializes the SQLite database tables according to v2.0/v3.0 specs. """ conn = get_connection() cursor = conn.cursor() # Processed files table cursor.execute(""" CREATE TABLE IF NOT EXISTS processed_files ( file_hash TEXT PRIMARY KEY, file_name TEXT, upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """) # Backup jobs table cursor.execute(""" CREATE TABLE IF NOT EXISTS backup_jobs ( job_id INTEGER PRIMARY KEY, client TEXT, policy TEXT, type TEXT, exit_code INTEGER, start_time TIMESTAMP, finish_time TIMESTAMP, duration_secs INTEGER, mbytes REAL, files_count INTEGER, primary_server TEXT, media_server TEXT, is_rerun_success INTEGER DEFAULT 0 ); """) # Technician actions table cursor.execute(""" CREATE TABLE IF NOT EXISTS job_actions ( job_id INTEGER PRIMARY KEY, action_taken TEXT, status TEXT DEFAULT 'Pendente', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(job_id) REFERENCES backup_jobs(job_id) ); """) # Users table cursor.execute(""" CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, password_hash TEXT, role TEXT DEFAULT 'user' -- 'admin' or 'user' ); """) conn.commit() conn.close() def is_file_processed(file_hash): """ Checks if a CSV file (based on its unique MD5 hash) has already been processed. """ conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT 1 FROM processed_files WHERE file_hash = ?", (file_hash,)) row = cursor.fetchone() conn.close() return row is not None def mark_file_processed(file_hash, file_name): """ Logs a file as processed. """ conn = get_connection() cursor = conn.cursor() cursor.execute( "INSERT OR IGNORE INTO processed_files (file_hash, file_name) VALUES (?, ?)", (file_hash, file_name) ) conn.commit() conn.close() def save_jobs(jobs_df): """ Saves or updates jobs into the backup_jobs table using SQL UPSERT. """ conn = get_connection() cursor = conn.cursor() for _, row in jobs_df.iterrows(): start_time = str(row['Start Time']) if not pd.isna(row['Start Time']) else None finish_time = str(row['Finish Time']) if not pd.isna(row['Finish Time']) else None cursor.execute(""" INSERT INTO backup_jobs ( job_id, client, policy, type, exit_code, start_time, finish_time, duration_secs, mbytes, files_count, primary_server, media_server, is_rerun_success ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(job_id) DO UPDATE SET client = excluded.client, policy = excluded.policy, type = excluded.type, exit_code = excluded.exit_code, start_time = excluded.start_time, finish_time = excluded.finish_time, duration_secs = excluded.duration_secs, mbytes = excluded.mbytes, files_count = excluded.files_count, primary_server = excluded.primary_server, media_server = excluded.media_server, is_rerun_success = excluded.is_rerun_success; """, ( int(row['Job ID']), row['Client'], row['Policy'], row['Type'], int(row['Exit Code']), start_time, finish_time, int(row['Duration_Sec']), float(row['MBytes']), int(row['# of Files']), row['Primary Server'], row['Media Server'], int(row['is_rerun_success']) )) conn.commit() conn.close() def save_action(job_id, action_taken, status): """ Saves or updates a mitigation action for a failed backup job. """ conn = get_connection() cursor = conn.cursor() cursor.execute(""" INSERT INTO job_actions (job_id, action_taken, status, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(job_id) DO UPDATE SET action_taken = excluded.action_taken, status = excluded.status, updated_at = CURRENT_TIMESTAMP; """, (job_id, action_taken, status)) conn.commit() conn.close() def get_historical_jobs(): """ Retrieves all records from the backup_jobs table joined with job_actions. """ conn = get_connection() cursor = conn.cursor() cursor.execute(""" SELECT j.*, a.action_taken, COALESCE(a.status, 'Pendente') as status, a.updated_at FROM backup_jobs j LEFT JOIN job_actions a ON j.job_id = a.job_id """) rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] # --- User Management CRUD Methods --- def create_user(username, password, role='user'): """ Hashes the password and creates a new user inside the SQLite store. Username is stored in lowercase to ensure uniqueness. """ conn = get_connection() cursor = conn.cursor() password_hash = hash_password(password) try: cursor.execute( "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", (username.lower().strip(), password_hash, role) ) conn.commit() success = True except sqlite3.IntegrityError: # Username collision success = False conn.close() return success def get_user(username): """ Retrieves user profile dictionary by username. """ conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT * FROM users WHERE username = ?", (username.lower().strip(),)) row = cursor.fetchone() conn.close() return dict(row) if row else None def has_admin_user(): """ Returns True if there is at least one administrator user registered. Used for routing to the initial bootstrap setup screen. """ conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT 1 FROM users WHERE role = 'admin' LIMIT 1") row = cursor.fetchone() conn.close() return row is not None def get_all_users(): """ Returns lists of all user details (excluding their hashed passwords). """ conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT username, role FROM users ORDER BY username") rows = cursor.fetchall() conn.close() return [dict(row) for row in rows] def delete_user(username): """ Removes a user by their username. """ conn = get_connection() cursor = conn.cursor() cursor.execute("DELETE FROM users WHERE username = ?", (username.lower().strip(),)) conn.commit() conn.close() def reset_all_data(): """ Destructive helper: Truncates all backup jobs, processed file hashes, and technician actions. User accounts are NOT deleted. """ conn = get_connection() cursor = conn.cursor() cursor.execute("DELETE FROM backup_jobs") cursor.execute("DELETE FROM job_actions") cursor.execute("DELETE FROM processed_files") conn.commit() conn.close()