520 lines
21 KiB
Python
520 lines
21 KiB
Python
"""
|
|
config.py — Application configuration and DB connection manager.
|
|
Web version: credentials loaded from environment variables / .env file.
|
|
No OS keyring dependency — suitable for server deployment.
|
|
"""
|
|
|
|
import os
|
|
import mysql.connector
|
|
from mysql.connector import pooling
|
|
import logging
|
|
import queue
|
|
import threading
|
|
import sys
|
|
|
|
# ─── Load .env before anything reads os.environ ───────────────────────────────
|
|
# Must happen at the very top of this module — DB_CONFIG is built at import
|
|
# time, so dotenv must populate os.environ before those lines execute.
|
|
try:
|
|
from dotenv import load_dotenv
|
|
load_dotenv() # looks for .env in cwd, then parent directories
|
|
except ImportError:
|
|
pass # python-dotenv not installed — rely on real env vars
|
|
|
|
# ─── Logging Setup ────────────────────────────────────────────────────────────
|
|
_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s")
|
|
|
|
_stream_handler = logging.StreamHandler(stream=sys.stdout)
|
|
_stream_handler.setFormatter(_formatter)
|
|
|
|
APP_TITLE = "Bid Checker"
|
|
APP_VERSION = "1.0.0"
|
|
|
|
|
|
# ─── Database Log Handler (async, queued) ─────────────────────────────────────
|
|
class _DBLogHandler(logging.Handler):
|
|
"""Async handler that writes log records to the app_log DB table."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._queue = queue.Queue()
|
|
self._ready = False
|
|
self._thread = threading.Thread(target=self._worker, name="DBLogWorker", daemon=True)
|
|
self._thread.start()
|
|
|
|
def emit(self, record: logging.LogRecord):
|
|
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 worker that the DB pool is ready."""
|
|
self._ready = True
|
|
|
|
def _worker(self):
|
|
while True:
|
|
try:
|
|
record = self._queue.get(timeout=2)
|
|
except queue.Empty:
|
|
continue
|
|
if not self._ready:
|
|
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
|
|
|
|
|
|
db_log_handler = _DBLogHandler()
|
|
db_log_handler.setFormatter(_formatter)
|
|
|
|
logger = logging.getLogger("config")
|
|
|
|
# ─── DB Configuration (environment variables) ─────────────────────────────────
|
|
DB_CONFIG = {
|
|
"host": os.environ.get("DB_HOST", "localhost"),
|
|
"port": int(os.environ.get("DB_PORT", 3306)),
|
|
"database": os.environ.get("DB_NAME", "website_checker"),
|
|
"user": os.environ.get("DB_USER", "wc_user"),
|
|
"password": os.environ.get("DB_PASSWORD", ""),
|
|
"connection_timeout": 10,
|
|
"charset": "utf8mb4",
|
|
}
|
|
|
|
# ─── Connection Pool ──────────────────────────────────────────────────────────
|
|
_pool = None
|
|
_pool_lock = threading.Lock()
|
|
|
|
|
|
def get_connection_pool():
|
|
global _pool
|
|
if _pool is None:
|
|
with _pool_lock:
|
|
if _pool is None:
|
|
try:
|
|
_pool = pooling.MySQLConnectionPool(
|
|
pool_name="app_pool",
|
|
pool_size=10,
|
|
**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()
|
|
|
|
|
|
# ─── Schema DDL ───────────────────────────────────────────────────────────────
|
|
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),
|
|
email VARCHAR(255) NULL DEFAULT NULL,
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
|
locked_until DATETIME NULL,
|
|
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,
|
|
INDEX idx_activity_log_time (logged_at),
|
|
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 password_reset_tokens (
|
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
user_id INT NOT NULL,
|
|
token VARCHAR(64) NOT NULL UNIQUE,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
INDEX idx_prt_token (token),
|
|
INDEX idx_prt_expires (expires_at)
|
|
) 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;
|
|
""",
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS shift_reminder_log (
|
|
user_id INT NOT NULL,
|
|
shift_id INT NOT NULL,
|
|
sent_date DATE NOT NULL,
|
|
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (user_id, shift_id, sent_date),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE
|
|
) 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.")
|
|
|
|
# ── ip_address column on login_attempts (web-only addition) ───────
|
|
# The desktop does not capture IP addresses; this adds the column
|
|
# safely if deploying the web app alongside an existing desktop DB.
|
|
cursor.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = 'login_attempts'
|
|
AND COLUMN_NAME = 'ip_address'
|
|
"""
|
|
)
|
|
(has_ip,) = cursor.fetchone()
|
|
if not has_ip:
|
|
cursor.execute(
|
|
"ALTER TABLE login_attempts "
|
|
"ADD COLUMN ip_address VARCHAR(45) NULL AFTER attempted_at"
|
|
)
|
|
conn.commit()
|
|
logger.info("Migration: added ip_address column to login_attempts table.")
|
|
|
|
# ── activity_log: rename created_at → logged_at ────────────────
|
|
# The desktop app uses logged_at; earlier web-only installs may have
|
|
# created the table with created_at. Rename it if that's the case.
|
|
cursor.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = 'activity_log'
|
|
AND COLUMN_NAME = 'created_at'
|
|
"""
|
|
)
|
|
(has_created_at,) = cursor.fetchone()
|
|
if has_created_at:
|
|
cursor.execute(
|
|
"ALTER TABLE activity_log "
|
|
"CHANGE COLUMN created_at logged_at DATETIME DEFAULT CURRENT_TIMESTAMP"
|
|
)
|
|
conn.commit()
|
|
logger.info("Migration: renamed activity_log.created_at to logged_at.")
|
|
|
|
# ── activity_log: add index on logged_at if missing ────────────
|
|
cursor.execute(
|
|
"""
|
|
SELECT COUNT(*) FROM information_schema.STATISTICS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = 'activity_log'
|
|
AND INDEX_NAME = 'idx_activity_log_time'
|
|
"""
|
|
)
|
|
(has_idx,) = cursor.fetchone()
|
|
if not has_idx:
|
|
cursor.execute(
|
|
"ALTER TABLE activity_log "
|
|
"ADD INDEX idx_activity_log_time (logged_at)"
|
|
)
|
|
conn.commit()
|
|
logger.info("Migration: added idx_activity_log_time index to activity_log.")
|
|
|
|
# ── Seed app_settings from environment variables (first-run bootstrap) ─
|
|
# Uses INSERT IGNORE so values already saved via the Admin UI are never
|
|
# overwritten — .env only fills in keys that are completely absent.
|
|
env_seeds = [
|
|
("groq.api_key", os.environ.get("GROQ_API_KEY", "")),
|
|
("groq.model", os.environ.get("GROQ_MODEL", "")),
|
|
("email.smtp_host", os.environ.get("SMTP_HOST", "")),
|
|
("email.smtp_port", os.environ.get("SMTP_PORT", "")),
|
|
("email.smtp_user", os.environ.get("SMTP_USER", "")),
|
|
("email.smtp_password",os.environ.get("SMTP_PASSWORD", "")),
|
|
("email.smtp_from", os.environ.get("SMTP_FROM", "")),
|
|
]
|
|
for k, v in env_seeds:
|
|
if v:
|
|
cursor.execute(
|
|
"""INSERT INTO app_settings (key_name, value) VALUES (%s, %s)
|
|
ON DUPLICATE KEY UPDATE
|
|
value = IF(value IS NULL OR value = '', VALUES(value), value)""",
|
|
(k, v),
|
|
)
|
|
# Seed non-env defaults (INSERT IGNORE so existing values are never overwritten)
|
|
default_seeds = [
|
|
("registration.enabled", "0"),
|
|
]
|
|
for k, v in default_seeds:
|
|
cursor.execute(
|
|
"INSERT IGNORE INTO app_settings (key_name, value) VALUES (%s, %s)",
|
|
(k, v),
|
|
)
|
|
conn.commit()
|
|
logger.info("app_settings seeded from environment variables (env→DB, blank-only overwrite).")
|
|
|
|
cursor.close()
|
|
except mysql.connector.Error as e:
|
|
logger.error(f"Database initialisation error: {e}")
|
|
raise
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
|
|
# ─── app_settings helpers ─────────────────────────────────────────────────────
|
|
|
|
# Map app_settings keys → environment variable names so .env values are
|
|
# used as fallbacks when the DB row is absent or empty.
|
|
_ENV_FALLBACKS = {
|
|
"groq.api_key": "GROQ_API_KEY",
|
|
"groq.model": "GROQ_MODEL",
|
|
"email.smtp_host": "SMTP_HOST",
|
|
"email.smtp_port": "SMTP_PORT",
|
|
"email.smtp_user": "SMTP_USER",
|
|
"email.smtp_password": "SMTP_PASSWORD",
|
|
"email.smtp_from": "SMTP_FROM",
|
|
}
|
|
|
|
|
|
def get_setting(key: str, default: str = "") -> str:
|
|
"""Read a value from app_settings, falling back to env var then default."""
|
|
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()
|
|
if row and row[0]:
|
|
return row[0]
|
|
except Exception as e:
|
|
logger.warning(f"get_setting({key!r}) failed: {e}")
|
|
# Fall back to environment variable if mapped
|
|
env_var = _ENV_FALLBACKS.get(key)
|
|
if env_var:
|
|
env_val = os.environ.get(env_var, "")
|
|
if env_val:
|
|
return env_val
|
|
return default
|
|
|
|
|
|
def set_setting(key: str, value: str) -> None:
|
|
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:
|
|
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 {} |