""" config.py — Application configuration and DB connection manager. Update DB_CONFIG with your remote MySQL server credentials. """ import mysql.connector from mysql.connector import pooling import logging # ─── Logging Setup ──────────────────────────────────────────────────────────── import sys import queue import threading _formatter = logging.Formatter( "%(asctime)s [%(levelname)s] %(name)s - %(message)s" ) # ── Console handler (development / stdout) ──────────────────────────────────── _stream_handler = logging.StreamHandler(stream=sys.stdout) # Force UTF-8 on Windows cp1252 consoles so Unicode chars don't raise errors. if hasattr(_stream_handler.stream, "reconfigure"): try: _stream_handler.stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass _stream_handler.setFormatter(_formatter) # ── Database log handler ─────────────────────────────────────────────────────── # Writes every log record to the app_log table asynchronously via a background # thread so DB I/O never blocks the logging call or the UI. # The handler is installed as soon as the pool is ready (after initialize_database). # Before the pool is ready, records are queued and flushed on first install. class _DBLogHandler(logging.Handler): """ Asynchronous logging handler that inserts records into app_log. Uses a daemon thread + Queue to keep DB writes off the main thread. Safe to create before the DB pool exists — records are queued until install() is called after initialize_database() succeeds. """ def __init__(self): super().__init__() self._queue = queue.Queue() self._ready = False # True once the pool is confirmed available self._thread = threading.Thread( target=self._worker, name="DBLogWorker", daemon=True) self._thread.start() def emit(self, record: logging.LogRecord): # Never log our own worker thread to avoid infinite recursion if record.name in ("config", "mysql.connector"): return try: self._queue.put_nowait({ "level": record.levelname, "logger_name": record.name[:100], "message": self.format(record), }) except Exception: pass """ config.py — Application configuration and DB connection manager. Update DB_CONFIG with your remote MySQL server credentials. """ import mysql.connector from mysql.connector import pooling import logging # ─── Logging Setup ──────────────────────────────────────────────────────────── import sys import queue import threading _formatter = logging.Formatter( "%(asctime)s [%(levelname)s] %(name)s - %(message)s" ) # ── Console handler (development / stdout) ──────────────────────────────────── _stream_handler = logging.StreamHandler(stream=sys.stdout) # Force UTF-8 on Windows cp1252 consoles so Unicode chars don't raise errors. if hasattr(_stream_handler.stream, "reconfigure"): try: _stream_handler.stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass _stream_handler.setFormatter(_formatter) # ── Database log handler ─────────────────────────────────────────────────────── # Writes every log record to the app_log table asynchronously via a background # thread so DB I/O never blocks the logging call or the UI. # The handler is installed as soon as the pool is ready (after initialize_database). # Before the pool is ready, records are queued and flushed on first install. class _DBLogHandler(logging.Handler): """ Asynchronous logging handler that inserts records into app_log. Uses a daemon thread + Queue to keep DB writes off the main thread. Safe to create before the DB pool exists — records are queued until install() is called after initialize_database() succeeds. """ def __init__(self): super().__init__() self._queue = queue.Queue() self._ready = False # True once the pool is confirmed available self._thread = threading.Thread( target=self._worker, name="DBLogWorker", daemon=True) self._thread.start() def emit(self, record: logging.LogRecord): # Never log our own worker thread to avoid infinite recursion if record.name in ("config", "mysql.connector"): return try: self._queue.put_nowait({ "level": record.levelname, "logger_name": record.name[:100], "message": self.format(record), }) except Exception: pass def install(self): """Signal the worker that the DB pool is ready to accept writes.""" self._ready = True def _worker(self): """Background thread: drain the queue into app_log.""" while True: try: record = self._queue.get(timeout=2) except queue.Empty: continue if not self._ready: # Put it back and wait — pool not yet initialised self._queue.put(record) threading.Event().wait(1) continue try: conn = get_connection() cur = conn.cursor() cur.execute( "INSERT INTO app_log (level, logger_name, message) " "VALUES (%s, %s, %s)", (record["level"], record["logger_name"], record["message"]), ) conn.commit() cur.close() conn.close() except Exception: pass # Silently discard if DB is temporarily unavailable db_log_handler = _DBLogHandler() db_log_handler.setFormatter(_formatter) logging.basicConfig( level=logging.INFO, handlers=[_stream_handler, db_log_handler], ) logger = logging.getLogger("config") # ─── DB Credential Storage (OS Keychain via keyring) ───────────────────────── # # DB connection details are stored in the OS native credential store: # Windows → Windows Credential Manager # macOS → Keychain # Linux → Secret Service / libsecret (or plaintext fallback) # # This replaces config.ini entirely — no local file is needed on any machine. # The user enters credentials once via the Settings dialog; keyring persists # them securely and they survive reboots, upgrades, and user profile migrations. # # keyring is a stdlib-level cross-platform abstraction; install with: # pip install keyring # import os as _os import json as _json APP_TITLE = "Website Checker" APP_VERSION = "1.0.0" _KEYRING_SVC = "WebChecker" # service name in the OS credential store _KEYRING_KEY = "db_config" # single credential entry stores JSON blob def _keyring_set(data: dict) -> None: """Persist DB config dict as a JSON blob in the OS keychain.""" import keyring keyring.set_password(_KEYRING_SVC, _KEYRING_KEY, _json.dumps(data)) def _keyring_get() -> dict: """Read DB config dict from the OS keychain. Returns {} if not found.""" try: import keyring raw = keyring.get_password(_KEYRING_SVC, _KEYRING_KEY) if raw: return _json.loads(raw) except Exception as e: logger.warning(f"keyring read failed: {e}") return {} def load_config() -> dict: """ Load DB connection settings from the OS keychain. Returns a dict with keys: host, port, database, user, password. Returns empty dict if no credentials are stored yet (first run). On first startup after migration from config.ini, automatically imports existing credentials from config.ini into the keychain and removes them from the file so the file is no longer required. """ # Try keychain first data = _keyring_get() if data.get("host") and data.get("database") and data.get("user"): return data # One-time migration: pull from config.ini if it still exists. # IMPORTANT: decrypt_value() uses Windows DPAPI which is machine+user-scoped. # A config.ini created on a different machine cannot be decrypted here — # we catch RuntimeError (and any other error) per-field and skip gracefully, # letting the user re-enter credentials via the Settings dialog instead of # crashing the application. try: import configparser as _cp _cfg_file = "config.ini" if _os.path.exists(_cfg_file): from utils.config_crypto import decrypt_value cfg = _cp.ConfigParser() cfg.read(_cfg_file, encoding="utf-8") if cfg.has_section("database"): section = cfg["database"] def _safe_decrypt(raw: str) -> str: """Decrypt a DPAPI value; return empty string on any failure.""" try: return decrypt_value(raw) except Exception as dec_err: logger.warning( f"config.ini migration: could not decrypt a value " f"(DPAPI is machine-scoped — this is expected on a new " f"machine). Skipping migration. Detail: {dec_err}" ) return "" migrated = { "host": section.get("host", ""), "port": section.getint("port", 3306), "database": section.get("database", ""), "user": _safe_decrypt(section.get("user", "")), "password": _safe_decrypt(section.get("password", "")), } # Only migrate if we successfully decrypted a usable credential set if migrated.get("host") and migrated.get("database") and migrated.get("user"): _keyring_set(migrated) logger.info( "DB credentials migrated from config.ini to OS keychain. " "config.ini [database] section is no longer needed." ) return migrated elif migrated.get("host"): # File found but decryption failed (different machine) — # log clearly and fall through to prompt the user logger.warning( "config.ini found but credentials could not be decrypted " "(created on a different machine). " "Please re-enter connection details in the Settings dialog." ) except Exception as e: logger.warning(f"config.ini migration attempt failed: {e}") return {} def save_config(host: str, port: int, database: str, user: str, password: str): """Persist DB connection settings to the OS keychain.""" data = { "host": host, "port": port, "database": database, "user": user, "password": password, } try: _keyring_set(data) logger.info( f"DB credentials saved to OS keychain " f"({host}:{port}/{database})." ) except Exception as e: logger.error(f"Failed to save credentials to OS keychain: {e}") raise RuntimeError( f"Could not save to the OS keychain: {e}\n\n" "Ensure the keyring package is installed: pip install keyring" ) from e def migrate_plaintext_config(): """ One-time migration runner called on every startup. - Imports config.ini [database] into the OS keychain (handled by load_config). - Migrates config.ini [email]/[groq]/[crypto] into app_settings (DB). Safe to call repeatedly — all operations are idempotent no-ops once done. """ # Trigger the config.ini → keychain migration via load_config load_config() def config_exists() -> bool: """Return True if DB credentials are stored in the OS keychain.""" data = _keyring_get() return bool(data.get("host") and data.get("database") and data.get("user")) def reload_db_config(): """ Re-read credentials from the OS keychain and update DB_CONFIG in place. Also resets the connection pool so the next get_connection() uses new creds. """ global _pool, DB_CONFIG data = load_config() DB_CONFIG.update({ "host": data.get("host", DB_CONFIG["host"]), "port": data.get("port", DB_CONFIG["port"]), "database": data.get("database", DB_CONFIG["database"]), "user": data.get("user", DB_CONFIG["user"]), "password": data.get("password", DB_CONFIG["password"]), }) _pool = None # force pool recreation on next connection logger.info("DB_CONFIG reloaded from OS keychain.") # ─── Database Configuration ─────────────────────────────────────────────────── # Populated from the OS keychain at import time; falls back to placeholder # strings so the module is importable even before first-run setup has completed. # The try/except ensures a corrupt or inaccessible keychain entry never crashes # the import — the user will simply be shown the Settings dialog on first run. try: _ini = load_config() except Exception as _load_err: logger.warning(f"Could not load DB config at startup: {_load_err}") _ini = {} DB_CONFIG = { "host": _ini.get("host", "your-mysql-host"), "port": _ini.get("port", 3306), "database": _ini.get("database", "website_checker"), "user": _ini.get("user", "your-db-user"), "password": _ini.get("password", "your-db-password"), "connection_timeout": 10, } # ─── Connection Pool ────────────────────────────────────────────────────────── _pool = None def get_connection_pool(): global _pool if _pool is None: try: _pool = pooling.MySQLConnectionPool( pool_name="app_pool", pool_size=5, **DB_CONFIG ) logger.info("Database connection pool initialised.") except mysql.connector.Error as e: logger.error(f"Failed to create connection pool: {e}") raise return _pool def get_connection(): """Return a connection from the pool.""" return get_connection_pool().get_connection() def initialize_database(): """Create all required tables if they do not exist.""" ddl_statements = [ """ CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(100) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, role ENUM('admin','user') NOT NULL DEFAULT 'user', full_name VARCHAR(200), is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS websites ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, url TEXT NOT NULL, check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily', visibility ENUM('all','assigned') NOT NULL DEFAULT 'all', note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS website_credentials ( id INT AUTO_INCREMENT PRIMARY KEY, website_id INT NOT NULL, label VARCHAR(100), username VARCHAR(200), password TEXT, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_checks ( id INT AUTO_INCREMENT PRIMARY KEY, website_id INT NOT NULL, user_id INT NOT NULL, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_note TEXT, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS activity_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, action VARCHAR(100) NOT NULL, entity VARCHAR(100), entity_id INT, detail TEXT, logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shifts ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, days_of_week VARCHAR(7) NOT NULL DEFAULT '23456', start_time TIME NOT NULL DEFAULT '08:00:00', end_time TIME NOT NULL DEFAULT '17:00:00', is_active TINYINT(1) NOT NULL DEFAULT 1, note TEXT, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_users ( shift_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (shift_id, user_id), FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_websites ( shift_id INT NOT NULL, website_id INT NOT NULL, sort_order INT NOT NULL DEFAULT 0, PRIMARY KEY (shift_id, website_id), FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS login_attempts ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(100) NOT NULL, attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP, ip_address VARCHAR(45), INDEX idx_username_time (username, attempted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS website_users ( website_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (website_id, user_id), FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS ai_analysis_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, file_names TEXT NOT NULL, model VARCHAR(100) NOT NULL, verdict ENUM('PURSUE','PASS','UNCLEAR') NULL, criteria_snapshot TEXT NULL, summary_text MEDIUMTEXT NOT NULL, analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS ai_criteria ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT NOT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS app_log ( id INT AUTO_INCREMENT PRIMARY KEY, level VARCHAR(10) NOT NULL, logger_name VARCHAR(100) NOT NULL, message TEXT NOT NULL, logged_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), INDEX idx_app_log_level (level), INDEX idx_app_log_time (logged_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS bid_tracker ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(300) NOT NULL, url TEXT NOT NULL, source VARCHAR(200) NULL, solicitation_number VARCHAR(100) NULL, status ENUM('open','monitoring','awarded','no_bid','cancelled') NOT NULL DEFAULT 'open', due_date DATE NULL, notes TEXT NULL, added_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS bid_updates ( id INT AUTO_INCREMENT PRIMARY KEY, bid_id INT NOT NULL, user_id INT, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bid_id) REFERENCES bid_tracker(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS app_settings ( key_name VARCHAR(100) NOT NULL PRIMARY KEY, value TEXT NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Key-value store for application configuration. ' 'Replaces config.ini sections: email, groq, crypto.'; """, ] conn = None try: conn = get_connection() cursor = conn.cursor() for stmt in ddl_statements: cursor.execute(stmt) conn.commit() logger.info("Database schema initialised successfully.") # ── Safe migrations for existing deployments ─────────────────────── # Add check_type column to websites if it doesn't exist yet cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'websites' AND COLUMN_NAME = 'check_type' """ ) (has_col,) = cursor.fetchone() if not has_col: cursor.execute( "ALTER TABLE websites ADD COLUMN check_type ENUM('daily','weekly') " "NOT NULL DEFAULT 'daily' AFTER url" ) conn.commit() logger.info("Migration: added check_type column to websites table.") # Add failed_attempts / locked_until columns to users if absent cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'failed_attempts' """ ) (has_fa,) = cursor.fetchone() if not has_fa: cursor.execute( "ALTER TABLE users " "ADD COLUMN failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER is_active, " "ADD COLUMN locked_until DATETIME NULL AFTER failed_attempts" ) conn.commit() logger.info("Migration: added failed_attempts and locked_until columns to users table.") # Add visibility column to websites if absent cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'websites' AND COLUMN_NAME = 'visibility' """ ) (has_vis,) = cursor.fetchone() if not has_vis: cursor.execute( "ALTER TABLE websites ADD COLUMN visibility " "ENUM('all','assigned') NOT NULL DEFAULT 'all' AFTER check_type" ) conn.commit() logger.info("Migration: added visibility column to websites table.") # Confirm presence of tables added post-initial-deployment for new_table in ("ai_criteria", "ai_analysis_log", "app_log", "bid_tracker", "bid_updates"): cursor.execute( """ SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s """, (new_table,) ) (table_exists,) = cursor.fetchone() if table_exists: logger.info(f"Table '{new_table}' confirmed present.") else: logger.warning(f"Table '{new_table}' was not created -- check DDL.") # Seed default admin if users table is empty cursor.execute("SELECT COUNT(*) FROM users") (count,) = cursor.fetchone() if count == 0: import bcrypt as _bcrypt default_pw = _bcrypt.hashpw(b"admin123", _bcrypt.gensalt(rounds=12)).decode("utf-8") cursor.execute( "INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,'admin','System Admin')", ("admin", default_pw) ) conn.commit() logger.info("Default admin account seeded (username: admin / password: admin123).") # ── users.email column (added in this release) ───────────────────────── cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'email' """ ) (has_email,) = cursor.fetchone() if not has_email: cursor.execute( "ALTER TABLE users ADD COLUMN email VARCHAR(255) NULL DEFAULT NULL " "AFTER full_name" ) conn.commit() logger.info("Migration: added email column to users table.") # ── app_settings: migrate config.ini → DB on first post-upgrade start ── cursor.execute( """ SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_settings' """ ) (has_settings,) = cursor.fetchone() if has_settings: logger.info("app_settings table confirmed present.") _migrate_config_ini_to_db(cursor, conn) cursor.close() except mysql.connector.Error as e: logger.error(f"Database initialisation error: {e}") raise finally: if conn: conn.close() # ─── app_settings DB helpers ────────────────────────────────────────────────── # These replace all config.ini reads/writes for email, groq, and crypto settings. # The DB connection settings remain in the local .db_config file (bootstrap only). def _migrate_config_ini_to_db(cursor, conn): """ One-time migration: read email, groq, and crypto sections from config.ini (if present) and upsert them into app_settings. This runs silently on every startup but is a no-op once the keys exist in the DB or if config.ini is absent. """ import configparser as _cfgp, os as _os _cfg_file = "config.ini" if not _os.path.exists(_cfg_file): return cfg = _cfgp.ConfigParser() cfg.read(_cfg_file, encoding="utf-8") mappings = [] # email section if cfg.has_section("email"): for key in ("enabled", "smtp_host", "smtp_port", "smtp_user", "smtp_password", "security", "use_tls", "recipients", "send_time", "last_sent_date"): val = cfg.get("email", key, fallback=None) if val is not None: mappings.append((f"email.{key}", val)) # groq section if cfg.has_section("groq"): for key in ("api_key", "model"): val = cfg.get("groq", key, fallback=None) if val is not None: mappings.append((f"groq.{key}", val)) # crypto section (salt) if cfg.has_section("crypto"): val = cfg.get("crypto", "salt", fallback=None) if val is not None: mappings.append(("crypto.salt", val)) if not mappings: return for k, v in mappings: cursor.execute( "INSERT INTO app_settings (key_name, value) VALUES (%s, %s) " "ON DUPLICATE KEY UPDATE value=value", # don't overwrite existing (k, v), ) conn.commit() logger.info(f"Migrated {len(mappings)} setting(s) from config.ini to app_settings.") def get_setting(key: str, default: str = "") -> str: """Read a value from the app_settings table. Returns default if not found.""" try: conn = get_connection() cur = conn.cursor() cur.execute("SELECT value FROM app_settings WHERE key_name=%s", (key,)) row = cur.fetchone() cur.close() conn.close() return row[0] if row and row[0] is not None else default except Exception as e: logger.warning(f"get_setting({key!r}) failed: {e}") return default def set_setting(key: str, value: str) -> None: """Upsert a key-value pair into app_settings.""" try: conn = get_connection() cur = conn.cursor() cur.execute( "INSERT INTO app_settings (key_name, value) VALUES (%s, %s) " "ON DUPLICATE KEY UPDATE value=%s, updated_at=CURRENT_TIMESTAMP", (key, value, value), ) conn.commit() cur.close() conn.close() except Exception as e: logger.error(f"set_setting({key!r}) failed: {e}") def get_settings_dict(prefix: str) -> dict: """Return all app_settings rows whose key starts with prefix as a plain dict.""" try: conn = get_connection() cur = conn.cursor() cur.execute( "SELECT key_name, value FROM app_settings WHERE key_name LIKE %s", (prefix + "%",), ) rows = cur.fetchall() cur.close() conn.close() return {r[0]: (r[1] or "") for r in rows} except Exception as e: logger.warning(f"get_settings_dict({prefix!r}) failed: {e}") return {} def install(self): """Signal the worker that the DB pool is ready to accept writes.""" self._ready = True def _worker(self): """Background thread: drain the queue into app_log.""" while True: try: record = self._queue.get(timeout=2) except queue.Empty: continue if not self._ready: # Put it back and wait — pool not yet initialised self._queue.put(record) threading.Event().wait(1) continue try: conn = get_connection() cur = conn.cursor() cur.execute( "INSERT INTO app_log (level, logger_name, message) " "VALUES (%s, %s, %s)", (record["level"], record["logger_name"], record["message"]), ) conn.commit() cur.close() conn.close() except Exception: pass # Silently discard if DB is temporarily unavailable db_log_handler = _DBLogHandler() db_log_handler.setFormatter(_formatter) logging.basicConfig( level=logging.INFO, handlers=[_stream_handler, db_log_handler], ) logger = logging.getLogger("config") # ─── Connection Pool ────────────────────────────────────────────────────────── _pool = None def get_connection_pool(): global _pool if _pool is None: try: _pool = pooling.MySQLConnectionPool( pool_name="app_pool", pool_size=5, **DB_CONFIG ) logger.info("Database connection pool initialised.") except mysql.connector.Error as e: logger.error(f"Failed to create connection pool: {e}") raise return _pool def get_connection(): """Return a connection from the pool.""" return get_connection_pool().get_connection() def initialize_database(): """Create all required tables if they do not exist.""" ddl_statements = [ """ CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(100) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, role ENUM('admin','user') NOT NULL DEFAULT 'user', full_name VARCHAR(200), is_active TINYINT(1) NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS websites ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, url TEXT NOT NULL, check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily', visibility ENUM('all','assigned') NOT NULL DEFAULT 'all', note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS website_credentials ( id INT AUTO_INCREMENT PRIMARY KEY, website_id INT NOT NULL, label VARCHAR(100), username VARCHAR(200), password TEXT, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_checks ( id INT AUTO_INCREMENT PRIMARY KEY, website_id INT NOT NULL, user_id INT NOT NULL, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_note TEXT, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS activity_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, action VARCHAR(100) NOT NULL, entity VARCHAR(100), entity_id INT, detail TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shifts ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(200) NOT NULL, days_of_week VARCHAR(7) NOT NULL DEFAULT '23456', start_time TIME NOT NULL DEFAULT '08:00:00', end_time TIME NOT NULL DEFAULT '17:00:00', is_active TINYINT(1) NOT NULL DEFAULT 1, note TEXT, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_users ( shift_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (shift_id, user_id), FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS shift_websites ( shift_id INT NOT NULL, website_id INT NOT NULL, sort_order INT NOT NULL DEFAULT 0, PRIMARY KEY (shift_id, website_id), FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS login_attempts ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(100) NOT NULL, attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP, ip_address VARCHAR(45), INDEX idx_username_time (username, attempted_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS website_users ( website_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (website_id, user_id), FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS ai_analysis_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, file_names TEXT NOT NULL, model VARCHAR(100) NOT NULL, verdict ENUM('PURSUE','PASS','UNCLEAR') NULL, criteria_snapshot TEXT NULL, summary_text MEDIUMTEXT NOT NULL, analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS ai_criteria ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, description TEXT NOT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, sort_order INT NOT NULL DEFAULT 0, created_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS app_log ( id INT AUTO_INCREMENT PRIMARY KEY, level VARCHAR(10) NOT NULL, logger_name VARCHAR(100) NOT NULL, message TEXT NOT NULL, logged_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3), INDEX idx_app_log_level (level), INDEX idx_app_log_time (logged_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS bid_tracker ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(300) NOT NULL, url TEXT NOT NULL, source VARCHAR(200) NULL, solicitation_number VARCHAR(100) NULL, status ENUM('open','monitoring','awarded','no_bid','cancelled') NOT NULL DEFAULT 'open', due_date DATE NULL, notes TEXT NULL, added_by INT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, """ CREATE TABLE IF NOT EXISTS bid_updates ( id INT AUTO_INCREMENT PRIMARY KEY, bid_id INT NOT NULL, user_id INT, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bid_id) REFERENCES bid_tracker(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, ] conn = None try: conn = get_connection() cursor = conn.cursor() for stmt in ddl_statements: cursor.execute(stmt) conn.commit() logger.info("Database schema initialised successfully.") # ── Safe migrations for existing deployments ─────────────────────── # Add check_type column to websites if it doesn't exist yet cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'websites' AND COLUMN_NAME = 'check_type' """ ) (has_col,) = cursor.fetchone() if not has_col: cursor.execute( "ALTER TABLE websites ADD COLUMN check_type ENUM('daily','weekly') " "NOT NULL DEFAULT 'daily' AFTER url" ) conn.commit() logger.info("Migration: added check_type column to websites table.") # Add failed_attempts / locked_until columns to users if absent cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = 'failed_attempts' """ ) (has_fa,) = cursor.fetchone() if not has_fa: cursor.execute( "ALTER TABLE users " "ADD COLUMN failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER is_active, " "ADD COLUMN locked_until DATETIME NULL AFTER failed_attempts" ) conn.commit() logger.info("Migration: added failed_attempts and locked_until columns to users table.") # Add visibility column to websites if absent cursor.execute( """ SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'websites' AND COLUMN_NAME = 'visibility' """ ) (has_vis,) = cursor.fetchone() if not has_vis: cursor.execute( "ALTER TABLE websites ADD COLUMN visibility " "ENUM('all','assigned') NOT NULL DEFAULT 'all' AFTER check_type" ) conn.commit() logger.info("Migration: added visibility column to websites table.") # Confirm presence of tables added post-initial-deployment for new_table in ("ai_criteria", "ai_analysis_log", "app_log", "bid_tracker", "bid_updates"): cursor.execute( """ SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s """, (new_table,) ) (table_exists,) = cursor.fetchone() if table_exists: logger.info(f"Table '{new_table}' confirmed present.") else: logger.warning(f"Table '{new_table}' was not created -- check DDL.") # Seed default admin if users table is empty cursor.execute("SELECT COUNT(*) FROM users") (count,) = cursor.fetchone() if count == 0: import bcrypt as _bcrypt default_pw = _bcrypt.hashpw(b"admin123", _bcrypt.gensalt(rounds=12)).decode("utf-8") cursor.execute( "INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,'admin','System Admin')", ("admin", default_pw) ) conn.commit() logger.info("Default admin account seeded (username: admin / password: admin123).") cursor.close() except mysql.connector.Error as e: logger.error(f"Database initialisation error: {e}") raise finally: if conn: conn.close()