04/24 Migrated info which stored in .ini to database and keyring
This commit is contained in:
@@ -86,7 +86,8 @@ class App(tk.Tk):
|
|||||||
# Re-open setup so the user can correct the credentials
|
# Re-open setup so the user can correct the credentials
|
||||||
self._show_setup(on_complete=self._init_db)
|
self._show_setup(on_complete=self._init_db)
|
||||||
return
|
return
|
||||||
# Encrypt any plain-text credentials in config.ini (one-time migration)
|
# One-time migration: import config.ini [database] → OS keychain,
|
||||||
|
# and config.ini [email]/[groq]/[crypto] → app_settings DB table.
|
||||||
try:
|
try:
|
||||||
from config import migrate_plaintext_config
|
from config import migrate_plaintext_config
|
||||||
migrate_plaintext_config()
|
migrate_plaintext_config()
|
||||||
|
|||||||
+2
-20
@@ -2,24 +2,6 @@
|
|||||||
host = 67.217.62.199
|
host = 67.217.62.199
|
||||||
port = 3306
|
port = 3306
|
||||||
database = webchecker
|
database = webchecker
|
||||||
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAYidUx+/EF8dBvkm7qVrCTNghUdDMslBCZBBTjfFXnuIAAAAADoAAAAACAAAgAAAArWRcoWRv2DWdaf+Np9mP3VUerkem6fgxbPN9RP6vE+sQAAAA+IYMqcdW0pBoZ5HdhykWrEAAAADsUqFCIW6brBYISrc+XdSS0WqYBBP3jvoBXUuAtmyhI4g8j9C+R1y27Jd/VUy/5hybmayzzeAsj2a+dK+rr8yR
|
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAZfPmuxpaSlrA7baq6FuVA3mxjxM4Kjk3n2c3ntJuEukAAAAADoAAAAACAAAgAAAApAOcDY8BjopNdccaEtymsKrh8pe9OCv5OOjrEURz8e8QAAAAynWoAwfUckM2KPojaAmLqkAAAADFRX+8SXg/SnhOad3blnMygOYi6RyTF+Ei3+ShbjHEgnLkgl1Sf3xruM7AcRmCeHcB3he0n8q5clagpKUYiEdI
|
||||||
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAykVXkmPXEYXlFlNlaFvKD2SdOE+KF+FwGjxyIua1vlUAAAAADoAAAAACAAAgAAAAlUEcvqr1nvxQ2liPnoA7aJc5mqZ9OpZ69l1YctCrhxcQAAAAmEg7tJjEkA+4Eg2jzvP+7EAAAADHWK0G9UJn4ZmNlsMB4WQtZduNzSeIoT/TuKB8X7hNMASwz9wXTw/KxX2iNi9C0Tg3JgqaWjv/rLJQeJF8MeTF
|
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAL9rzNnENEcALpm87GOHhRkA6go80lhJXm8atP9sLtfkAAAAADoAAAAACAAAgAAAAF37pL18fWvsyYKJQaK9v9Xwq1bb2YPoQfcQKjTm9QTcQAAAADqJfCkgtQsUEZR12YnDALkAAAADl2cNUaxU40qObsA0mQ+XVuQ1qMOZytHhM5XpEHMTIwUmwvGuCRJrb6+D7EVdyLxS6PCoStrell6alDGg6UzRk
|
||||||
|
|
||||||
[crypto]
|
|
||||||
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
|
|
||||||
|
|
||||||
[groq]
|
|
||||||
api_key = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAvjBskxdtcNpYs3pBI8ISyRDRKIgTpRQf39CfrILduhUAAAAADoAAAAACAAAgAAAAdkFkBiC1kD4X0L3a6eoe8z2s+O/KYriTZfl8L6qOc+JAAAAABW4zpNffQtP5IMFXi3W/93xl9XCt3lbTxegcdqxOxVXrp1hRyolT5YPFdB1gjL53ywCDA+Ls4yRKGMBekl49Q0AAAAAms4kvJoK2EhZzfvcHkvDhoixVelv89fLw2ZS1yKPtMrNJFaoGd+IkOCcLAnI6eMiy5PYtPe2t2vtlZ09i1nJZ
|
|
||||||
model = llama-3.3-70b-versatile
|
|
||||||
|
|
||||||
[email]
|
|
||||||
enabled = true
|
|
||||||
smtp_host = mail.ltservicesinc.com
|
|
||||||
smtp_port = 465
|
|
||||||
smtp_user = donotreply@ltservicesinc.com
|
|
||||||
smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAh3WeFBaZFJ3jXPYBcRFrzeouer4eNi8f1V4e9htctPQAAAAADoAAAAACAAAgAAAApIGw/W7uxwcLxCFmKJtbd0lsr0Ipem4mjBeaF7ePQa4QAAAANsAPqIR2ODnOCJC3BEIY80AAAAAXFi4ah2FcXjr8cWMirk5zHMDBRBKWu2MU/UvTNWuY5VR4br/uZ7ZSk9wExBCr/vuLq7eJ7v5JnoIG570fIlue
|
|
||||||
security = ssl
|
|
||||||
use_tls = false
|
|
||||||
recipients = da.nguyen8744@gmail.com
|
|
||||||
send_time = 18:00
|
|
||||||
|
|
||||||
|
|||||||
@@ -169,134 +169,153 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger("config")
|
logger = logging.getLogger("config")
|
||||||
|
|
||||||
# ─── Config File Load / Save ──────────────────────────────────────────────────
|
# ─── DB Credential Storage (OS Keychain via keyring) ─────────────────────────
|
||||||
import configparser as _cp
|
#
|
||||||
|
# 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 os as _os
|
||||||
|
import json as _json
|
||||||
|
|
||||||
CONFIG_FILE = "config.ini"
|
APP_TITLE = "Website Checker"
|
||||||
APP_TITLE = "Website Checker"
|
APP_VERSION = "1.0.0"
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
# Sensitive fields that are DPAPI-encrypted in config.ini
|
def _keyring_set(data: dict) -> None:
|
||||||
_DB_SENSITIVE = {"user", "password"}
|
"""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:
|
def load_config() -> dict:
|
||||||
"""
|
"""
|
||||||
Load DB settings from config.ini.
|
Load DB connection settings from the OS keychain.
|
||||||
Returns a dict with keys: host, port, database, user, password.
|
Returns a dict with keys: host, port, database, user, password.
|
||||||
Sensitive fields (user, password) are decrypted transparently via DPAPI.
|
Returns empty dict if no credentials are stored yet (first run).
|
||||||
Returns empty dict if the file does not exist or is incomplete.
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
from utils.config_crypto import decrypt_value
|
# Try keychain first
|
||||||
cfg = _cp.ConfigParser()
|
data = _keyring_get()
|
||||||
if not _os.path.exists(CONFIG_FILE):
|
if data.get("host") and data.get("database") and data.get("user"):
|
||||||
return {}
|
return data
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
|
||||||
if "database" not in cfg:
|
# One-time migration: pull from config.ini if it still exists
|
||||||
return {}
|
|
||||||
section = cfg["database"]
|
|
||||||
try:
|
try:
|
||||||
return {
|
import configparser as _cp
|
||||||
"host": section.get("host", ""),
|
_cfg_file = "config.ini"
|
||||||
"port": section.getint("port", 3306),
|
if _os.path.exists(_cfg_file):
|
||||||
"database": section.get("database", ""),
|
from utils.config_crypto import decrypt_value
|
||||||
"user": decrypt_value(section.get("user", "")),
|
cfg = _cp.ConfigParser()
|
||||||
"password": decrypt_value(section.get("password", "")),
|
cfg.read(_cfg_file, encoding="utf-8")
|
||||||
}
|
if cfg.has_section("database"):
|
||||||
except RuntimeError as exc:
|
section = cfg["database"]
|
||||||
logger.error(f"Failed to decrypt DB credentials: {exc}")
|
migrated = {
|
||||||
raise
|
"host": section.get("host", ""),
|
||||||
|
"port": section.getint("port", 3306),
|
||||||
|
"database": section.get("database", ""),
|
||||||
|
"user": decrypt_value(section.get("user", "")),
|
||||||
|
"password": decrypt_value(section.get("password", "")),
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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):
|
def save_config(host: str, port: int, database: str, user: str, password: str):
|
||||||
"""Persist DB connection settings to config.ini (sensitive fields DPAPI-encrypted)."""
|
"""Persist DB connection settings to the OS keychain."""
|
||||||
from utils.config_crypto import encrypt_value
|
data = {
|
||||||
# Preserve any existing non-database sections (email, crypto, groq)
|
|
||||||
cfg = _cp.ConfigParser()
|
|
||||||
if _os.path.exists(CONFIG_FILE):
|
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
|
||||||
cfg["database"] = {
|
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": str(port),
|
"port": port,
|
||||||
"database": database,
|
"database": database,
|
||||||
"user": encrypt_value(user),
|
"user": user,
|
||||||
"password": encrypt_value(password),
|
"password": password,
|
||||||
}
|
}
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
try:
|
||||||
cfg.write(fh)
|
_keyring_set(data)
|
||||||
logger.info(f"Configuration saved to {CONFIG_FILE} (credentials encrypted).")
|
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():
|
def migrate_plaintext_config():
|
||||||
"""
|
"""
|
||||||
One-time migration: if config.ini contains plain-text DB credentials
|
One-time migration runner called on every startup.
|
||||||
(no 'dpapi:' prefix) encrypt them in-place using Windows DPAPI.
|
- Imports config.ini [database] into the OS keychain (handled by load_config).
|
||||||
Safe to call on every startup — is a no-op when already encrypted.
|
- Migrates config.ini [email]/[groq]/[crypto] into app_settings (DB).
|
||||||
|
Safe to call repeatedly — all operations are idempotent no-ops once done.
|
||||||
"""
|
"""
|
||||||
from utils.config_crypto import encrypt_value, is_encrypted
|
# Trigger the config.ini → keychain migration via load_config
|
||||||
if not _os.path.exists(CONFIG_FILE):
|
load_config()
|
||||||
return
|
|
||||||
cfg = _cp.ConfigParser()
|
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
# DB section
|
|
||||||
for key in ("user", "password"):
|
|
||||||
if cfg.has_option("database", key):
|
|
||||||
raw = cfg.get("database", key)
|
|
||||||
if raw and not is_encrypted(raw):
|
|
||||||
cfg.set("database", key, encrypt_value(raw))
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
# Email section
|
|
||||||
if cfg.has_option("email", "smtp_password"):
|
|
||||||
raw = cfg.get("email", "smtp_password")
|
|
||||||
if raw and not is_encrypted(raw):
|
|
||||||
cfg.set("email", "smtp_password", encrypt_value(raw))
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
# Groq section
|
|
||||||
if cfg.has_option("groq", "api_key"):
|
|
||||||
raw = cfg.get("groq", "api_key")
|
|
||||||
if raw and not is_encrypted(raw):
|
|
||||||
cfg.set("groq", "api_key", encrypt_value(raw))
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
|
||||||
cfg.write(fh)
|
|
||||||
logger.info("config.ini: plain-text credentials encrypted with Windows DPAPI.")
|
|
||||||
|
|
||||||
|
|
||||||
def config_exists() -> bool:
|
def config_exists() -> bool:
|
||||||
"""Return True if config.ini contains all required connection fields."""
|
"""Return True if DB credentials are stored in the OS keychain."""
|
||||||
ini = load_config()
|
data = _keyring_get()
|
||||||
return bool(ini.get("host") and ini.get("database") and ini.get("user"))
|
return bool(data.get("host") and data.get("database") and data.get("user"))
|
||||||
|
|
||||||
|
|
||||||
def reload_db_config():
|
def reload_db_config():
|
||||||
"""
|
"""
|
||||||
Re-read config.ini and update DB_CONFIG in place.
|
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.
|
Also resets the connection pool so the next get_connection() uses new creds.
|
||||||
"""
|
"""
|
||||||
global _pool, DB_CONFIG
|
global _pool, DB_CONFIG
|
||||||
ini = load_config()
|
data = load_config()
|
||||||
DB_CONFIG.update({
|
DB_CONFIG.update({
|
||||||
"host": ini.get("host", DB_CONFIG["host"]),
|
"host": data.get("host", DB_CONFIG["host"]),
|
||||||
"port": ini.get("port", DB_CONFIG["port"]),
|
"port": data.get("port", DB_CONFIG["port"]),
|
||||||
"database": ini.get("database", DB_CONFIG["database"]),
|
"database": data.get("database", DB_CONFIG["database"]),
|
||||||
"user": ini.get("user", DB_CONFIG["user"]),
|
"user": data.get("user", DB_CONFIG["user"]),
|
||||||
"password": ini.get("password", DB_CONFIG["password"]),
|
"password": data.get("password", DB_CONFIG["password"]),
|
||||||
})
|
})
|
||||||
_pool = None # force pool recreation on next connection
|
_pool = None # force pool recreation on next connection
|
||||||
logger.info("DB_CONFIG reloaded from config.ini.")
|
logger.info("DB_CONFIG reloaded from OS keychain.")
|
||||||
|
|
||||||
|
|
||||||
# ─── Database Configuration ───────────────────────────────────────────────────
|
# ─── Database Configuration ───────────────────────────────────────────────────
|
||||||
# Populated from config.ini at runtime; falls back to placeholder strings so
|
# Populated from the OS keychain at import time; falls back to placeholder
|
||||||
# the module is importable even before first-run setup has completed.
|
# strings so the module is importable even before first-run setup has completed.
|
||||||
_ini = load_config()
|
_ini = load_config()
|
||||||
DB_CONFIG = {
|
DB_CONFIG = {
|
||||||
"host": _ini.get("host", "your-mysql-host"),
|
"host": _ini.get("host", "your-mysql-host"),
|
||||||
@@ -511,6 +530,15 @@ def initialize_database():
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
conn = None
|
||||||
@@ -606,6 +634,36 @@ def initialize_database():
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Default admin account seeded (username: admin / password: admin123).")
|
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()
|
cursor.close()
|
||||||
except mysql.connector.Error as e:
|
except mysql.connector.Error as e:
|
||||||
logger.error(f"Database initialisation error: {e}")
|
logger.error(f"Database initialisation error: {e}")
|
||||||
@@ -613,6 +671,112 @@ def initialize_database():
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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):
|
def install(self):
|
||||||
"""Signal the worker that the DB pool is ready to accept writes."""
|
"""Signal the worker that the DB pool is ready to accept writes."""
|
||||||
self._ready = True
|
self._ready = True
|
||||||
@@ -1098,6 +1262,4 @@ def initialize_database():
|
|||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -10,3 +10,4 @@ pypdf>=3.0.0
|
|||||||
python-docx>=1.0.0
|
python-docx>=1.0.0
|
||||||
pywin32>=306
|
pywin32>=306
|
||||||
docx2txt>=0.8
|
docx2txt>=0.8
|
||||||
|
keyring
|
||||||
|
|||||||
+110
-22
@@ -2,14 +2,18 @@
|
|||||||
utils/crypto.py — Fernet symmetric encryption for website credentials.
|
utils/crypto.py — Fernet symmetric encryption for website credentials.
|
||||||
|
|
||||||
Key derivation:
|
Key derivation:
|
||||||
- A 32-byte random salt is generated on first use and stored in config.ini
|
- A 32-byte random salt is generated on first use and stored in the
|
||||||
under [crypto] / salt.
|
app_settings table (key: 'crypto.salt') instead of config.ini.
|
||||||
- The Fernet key is derived from the salt + a fixed application secret
|
- The Fernet key is derived from the salt + a fixed application secret
|
||||||
using PBKDF2-HMAC-SHA256 (100,000 iterations).
|
using PBKDF2-HMAC-SHA256 (100,000 iterations).
|
||||||
- This means credentials are tied to the specific config.ini file on the
|
- Because the salt lives in the shared MySQL database, any machine that
|
||||||
operator's machine; moving config.ini to another machine retains access.
|
connects to the same DB can decrypt credentials without needing a local
|
||||||
|
config.ini — making the app fully portable across machines.
|
||||||
|
|
||||||
Migration:
|
Migration:
|
||||||
|
- On first start after this change, if config.ini still contains a
|
||||||
|
[crypto]/salt entry it is automatically migrated to app_settings and
|
||||||
|
removed from the file.
|
||||||
- _decrypt() tries Fernet first; if that fails it returns the raw value
|
- _decrypt() tries Fernet first; if that fails it returns the raw value
|
||||||
unchanged so that plaintext legacy credentials are still readable.
|
unchanged so that plaintext legacy credentials are still readable.
|
||||||
- Callers should re-encrypt on next write (update_website handles this).
|
- Callers should re-encrypt on next write (update_website handles this).
|
||||||
@@ -18,7 +22,6 @@ Migration:
|
|||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import configparser
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
@@ -27,30 +30,116 @@ from cryptography.hazmat.primitives import hashes
|
|||||||
logger = logging.getLogger("crypto")
|
logger = logging.getLogger("crypto")
|
||||||
|
|
||||||
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
|
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
|
||||||
_CONFIG_FILE = "config.ini"
|
|
||||||
_ITERATIONS = 100_000
|
_ITERATIONS = 100_000
|
||||||
|
_SETTING_KEY = "crypto.salt"
|
||||||
_fernet: "Fernet | None" = None
|
_fernet: "Fernet | None" = None
|
||||||
|
|
||||||
|
|
||||||
# ─── Key bootstrap ────────────────────────────────────────────────────────────
|
# ─── Key bootstrap ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ensure_app_settings_table() -> bool:
|
||||||
|
"""
|
||||||
|
Guarantee the app_settings table exists before we try to read/write it.
|
||||||
|
Returns True if the table is available, False if it could not be created
|
||||||
|
(e.g. the DB pool itself is not yet ready).
|
||||||
|
|
||||||
|
This guard is necessary because crypto.py can be called during login —
|
||||||
|
before initialize_database() has had a chance to run on a fresh install
|
||||||
|
or an upgraded database that doesn't yet have the app_settings table.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config import get_connection
|
||||||
|
conn = get_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Crypto: could not ensure app_settings table: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_salt() -> bytes:
|
def _get_or_create_salt() -> bytes:
|
||||||
"""Read salt from config.ini [crypto] section; create and persist if absent."""
|
"""
|
||||||
cfg = configparser.ConfigParser()
|
Read the Fernet salt from app_settings, falling back to config.ini for
|
||||||
cfg.read(_CONFIG_FILE, encoding="utf-8")
|
legacy installs, and generating a fresh salt for brand-new installs.
|
||||||
|
|
||||||
if "crypto" in cfg and cfg["crypto"].get("salt"):
|
Order of precedence:
|
||||||
return base64.b64decode(cfg["crypto"]["salt"])
|
1. app_settings table (primary — shared across machines via the DB)
|
||||||
|
2. config.ini [crypto]/salt (legacy migration path)
|
||||||
|
3. Generate a new random salt and persist it to app_settings
|
||||||
|
|
||||||
# Generate a fresh 32-byte salt
|
The table is created here if it doesn't yet exist, so this function is
|
||||||
salt = os.urandom(32)
|
safe to call before initialize_database() has run.
|
||||||
if "crypto" not in cfg:
|
"""
|
||||||
cfg["crypto"] = {}
|
# ── Step 1: try config.ini first (fastest, no DB needed yet) ─────────────
|
||||||
cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii")
|
# Reading config.ini for the salt is always safe — it doesn't require the
|
||||||
|
# app_settings table to exist. If found, we attempt to also persist it to
|
||||||
|
# the DB (best-effort), but we use the value regardless.
|
||||||
|
legacy_b64 = None
|
||||||
|
try:
|
||||||
|
import configparser, os as _os
|
||||||
|
_cfg_file = "config.ini"
|
||||||
|
if _os.path.exists(_cfg_file):
|
||||||
|
cfg = configparser.ConfigParser()
|
||||||
|
cfg.read(_cfg_file, encoding="utf-8")
|
||||||
|
if cfg.has_option("crypto", "salt"):
|
||||||
|
legacy_b64 = cfg.get("crypto", "salt")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Crypto: could not read config.ini for salt: {e}")
|
||||||
|
|
||||||
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh:
|
# ── Step 2: ensure app_settings table exists ──────────────────────────────
|
||||||
cfg.write(fh)
|
table_ok = _ensure_app_settings_table()
|
||||||
logger.info("Crypto: generated and persisted new credential encryption salt.")
|
|
||||||
|
# ── Step 3: try to read salt from DB ──────────────────────────────────────
|
||||||
|
if table_ok:
|
||||||
|
try:
|
||||||
|
from config import get_setting
|
||||||
|
raw = get_setting(_SETTING_KEY, "")
|
||||||
|
if raw:
|
||||||
|
return base64.b64decode(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Crypto: could not read salt from app_settings: {e}")
|
||||||
|
|
||||||
|
# ── Step 4: migrate legacy salt from config.ini → DB ─────────────────────
|
||||||
|
if legacy_b64:
|
||||||
|
if table_ok:
|
||||||
|
try:
|
||||||
|
from config import set_setting
|
||||||
|
set_setting(_SETTING_KEY, legacy_b64)
|
||||||
|
logger.info("Crypto: migrated salt from config.ini to app_settings.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Crypto: could not persist migrated salt to DB: {e}")
|
||||||
|
else:
|
||||||
|
logger.info("Crypto: using salt from config.ini (app_settings not available yet).")
|
||||||
|
return base64.b64decode(legacy_b64)
|
||||||
|
|
||||||
|
# ── Step 5: generate a fresh salt ────────────────────────────────────────
|
||||||
|
salt = os.urandom(32)
|
||||||
|
b64_salt = base64.b64encode(salt).decode("ascii")
|
||||||
|
if table_ok:
|
||||||
|
try:
|
||||||
|
from config import set_setting
|
||||||
|
set_setting(_SETTING_KEY, b64_salt)
|
||||||
|
logger.info("Crypto: generated and persisted new salt to app_settings.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Crypto: could not persist new salt to DB: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Crypto: generated a new salt but app_settings is not available — "
|
||||||
|
"salt will NOT persist across restarts until the table is created."
|
||||||
|
)
|
||||||
return salt
|
return salt
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +163,7 @@ def _get_fernet() -> Fernet:
|
|||||||
|
|
||||||
|
|
||||||
def reset_fernet():
|
def reset_fernet():
|
||||||
"""Force key reload — call after config.ini is replaced (e.g. settings save)."""
|
"""Force key reload — call if the salt is ever rotated."""
|
||||||
global _fernet
|
global _fernet
|
||||||
_fernet = None
|
_fernet = None
|
||||||
|
|
||||||
@@ -107,7 +196,6 @@ def decrypt(ciphertext: str) -> str:
|
|||||||
if not ciphertext:
|
if not ciphertext:
|
||||||
return ciphertext
|
return ciphertext
|
||||||
if not ciphertext.startswith("enc:"):
|
if not ciphertext.startswith("enc:"):
|
||||||
# Legacy plaintext — return unchanged; will be re-encrypted on next save
|
|
||||||
return ciphertext
|
return ciphertext
|
||||||
try:
|
try:
|
||||||
token = ciphertext[4:].encode("ascii")
|
token = ciphertext[4:].encode("ascii")
|
||||||
@@ -122,4 +210,4 @@ def decrypt(ciphertext: str) -> str:
|
|||||||
|
|
||||||
def is_encrypted(value: str) -> bool:
|
def is_encrypted(value: str) -> bool:
|
||||||
"""Return True if the value was produced by encrypt()."""
|
"""Return True if the value was produced by encrypt()."""
|
||||||
return isinstance(value, str) and value.startswith("enc:")
|
return isinstance(value, str) and value.startswith("enc:")
|
||||||
+74
-125
@@ -5,24 +5,26 @@ Runs a background daemon thread that wakes every minute, checks whether
|
|||||||
the configured send_time (HH:MM) has been reached today, and sends the
|
the configured send_time (HH:MM) has been reached today, and sends the
|
||||||
summary report via SMTP if it hasn't been sent yet.
|
summary report via SMTP if it hasn't been sent yet.
|
||||||
|
|
||||||
Configuration (config.ini [email] section):
|
Configuration is stored in the app_settings database table (not config.ini):
|
||||||
enabled = true/false
|
|
||||||
smtp_host = smtp.example.com
|
Key Description
|
||||||
smtp_port = 587
|
─────────────────────── ────────────────────────────────────────────
|
||||||
smtp_user = sender@example.com
|
email.enabled 'true' or 'false'
|
||||||
smtp_password= secret
|
email.smtp_host SMTP server hostname
|
||||||
use_tls = true
|
email.smtp_port SMTP port number (string)
|
||||||
recipients = admin@example.com, manager@example.com
|
email.smtp_user Sender email address / SMTP login
|
||||||
send_time = 18:00 (24-hour HH:MM, local time)
|
email.smtp_password SMTP password (Fernet-encrypted)
|
||||||
|
email.security 'starttls' | 'ssl' | 'none'
|
||||||
|
email.recipients Comma-separated recipient addresses
|
||||||
|
email.send_time HH:MM (24-hour local time)
|
||||||
|
email.last_sent_date ISO date of last successful send (YYYY-MM-DD)
|
||||||
|
|
||||||
Call start() once after login succeeds (admin only).
|
Call start() once after login succeeds (admin only).
|
||||||
Call stop() on logout/shutdown.
|
Call stop() on logout/shutdown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import configparser
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import smtplib
|
import smtplib
|
||||||
import threading
|
import threading
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
@@ -31,7 +33,6 @@ from email.utils import formatdate, make_msgid, formataddr
|
|||||||
|
|
||||||
logger = logging.getLogger("scheduler")
|
logger = logging.getLogger("scheduler")
|
||||||
|
|
||||||
CONFIG_FILE = "config.ini"
|
|
||||||
_scheduler_thread: "threading.Thread | None" = None
|
_scheduler_thread: "threading.Thread | None" = None
|
||||||
_stop_event = threading.Event()
|
_stop_event = threading.Event()
|
||||||
|
|
||||||
@@ -39,56 +40,53 @@ _stop_event = threading.Event()
|
|||||||
# ─── Config helpers ───────────────────────────────────────────────────────────
|
# ─── Config helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_email_config() -> dict:
|
def load_email_config() -> dict:
|
||||||
|
"""Load email settings from app_settings table."""
|
||||||
|
from config import get_settings_dict
|
||||||
from utils.config_crypto import decrypt_value
|
from utils.config_crypto import decrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
s = get_settings_dict("email.")
|
||||||
if not os.path.exists(CONFIG_FILE):
|
security = s.get("email.security", "")
|
||||||
return {}
|
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
|
||||||
if "email" not in cfg:
|
|
||||||
return {}
|
|
||||||
s = cfg["email"]
|
|
||||||
# security field: "starttls" | "ssl" | "none"
|
|
||||||
# Falls back from legacy use_tls boolean for existing configs.
|
|
||||||
security = s.get("security", "")
|
|
||||||
if not security:
|
if not security:
|
||||||
security = "starttls" if s.getboolean("use_tls", fallback=True) else "none"
|
# Legacy: fall back from boolean use_tls if security key absent
|
||||||
|
use_tls = s.get("email.use_tls", "true").lower() == "true"
|
||||||
|
security = "starttls" if use_tls else "none"
|
||||||
return {
|
return {
|
||||||
"enabled": s.getboolean("enabled", fallback=False),
|
"enabled": s.get("email.enabled", "false").lower() == "true",
|
||||||
"smtp_host": s.get("smtp_host", ""),
|
"smtp_host": s.get("email.smtp_host", ""),
|
||||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
"smtp_port": int(s.get("email.smtp_port", "587") or "587"),
|
||||||
"smtp_user": s.get("smtp_user", ""),
|
"smtp_user": s.get("email.smtp_user", ""),
|
||||||
"smtp_password": decrypt_value(s.get("smtp_password", "")),
|
"smtp_password": decrypt_value(s.get("email.smtp_password", "")),
|
||||||
"security": security,
|
"security": security,
|
||||||
"use_tls": security == "starttls", # kept for compatibility
|
"use_tls": security == "starttls",
|
||||||
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
"recipients": [r.strip() for r in
|
||||||
"send_time": s.get("send_time", "18:00"),
|
s.get("email.recipients", "").split(",") if r.strip()],
|
||||||
|
"send_time": s.get("email.send_time", "18:00"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||||
smtp_user: str, smtp_password: str, security: str,
|
smtp_user: str, smtp_password: str, security: str,
|
||||||
recipients: str, send_time: str):
|
recipients: str, send_time: str):
|
||||||
|
"""Persist email settings to app_settings table."""
|
||||||
|
from config import get_setting, set_setting
|
||||||
from utils.config_crypto import encrypt_value
|
from utils.config_crypto import encrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
# Preserve last_sent_date — do not overwrite it on a normal save
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
last_sent = get_setting("email.last_sent_date", "")
|
||||||
# Preserve last_sent_date if present
|
pairs = {
|
||||||
last_sent = cfg.get("email", "last_sent_date", fallback="")
|
"email.enabled": str(enabled).lower(),
|
||||||
cfg["email"] = {
|
"email.smtp_host": smtp_host,
|
||||||
"enabled": str(enabled).lower(),
|
"email.smtp_port": str(smtp_port),
|
||||||
"smtp_host": smtp_host,
|
"email.smtp_user": smtp_user,
|
||||||
"smtp_port": str(smtp_port),
|
"email.smtp_password": encrypt_value(smtp_password),
|
||||||
"smtp_user": smtp_user,
|
"email.security": security,
|
||||||
"smtp_password": encrypt_value(smtp_password),
|
"email.use_tls": str(security == "starttls").lower(),
|
||||||
"security": security,
|
"email.recipients": recipients,
|
||||||
"use_tls": str(security == "starttls").lower(), # legacy compat
|
"email.send_time": send_time,
|
||||||
"recipients": recipients,
|
|
||||||
"send_time": send_time,
|
|
||||||
}
|
}
|
||||||
|
for k, v in pairs.items():
|
||||||
|
set_setting(k, v)
|
||||||
if last_sent:
|
if last_sent:
|
||||||
cfg["email"]["last_sent_date"] = last_sent
|
set_setting("email.last_sent_date", last_sent)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
logger.info(f"Email configuration saved to app_settings (security={security}).")
|
||||||
cfg.write(fh)
|
|
||||||
logger.info(f"Email configuration saved (security={security}, encrypted).")
|
|
||||||
|
|
||||||
|
|
||||||
def _make_smtp_server(smtp_host: str, smtp_port: int,
|
def _make_smtp_server(smtp_host: str, smtp_port: int,
|
||||||
@@ -108,7 +106,7 @@ def _make_smtp_server(smtp_host: str, smtp_port: int,
|
|||||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
||||||
server.ehlo()
|
server.ehlo()
|
||||||
server.starttls()
|
server.starttls()
|
||||||
server.ehlo() # re-identify after TLS upgrade — mandatory
|
server.ehlo()
|
||||||
else:
|
else:
|
||||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
|
||||||
server.ehlo()
|
server.ehlo()
|
||||||
@@ -120,16 +118,9 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
|||||||
security: str) -> tuple:
|
security: str) -> tuple:
|
||||||
"""
|
"""
|
||||||
Step-by-step SMTP diagnostic. Returns (success: bool, message: str).
|
Step-by-step SMTP diagnostic. Returns (success: bool, message: str).
|
||||||
Each step is attempted independently so the error message tells the
|
|
||||||
admin exactly where the failure occurred:
|
|
||||||
Step 1 — DNS resolution
|
|
||||||
Step 2 — TCP connect
|
|
||||||
Step 3 — TLS handshake (if applicable)
|
|
||||||
Step 4 — Authentication
|
|
||||||
"""
|
"""
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
# Step 1: DNS resolution
|
|
||||||
try:
|
try:
|
||||||
addr = socket.getaddrinfo(smtp_host, smtp_port,
|
addr = socket.getaddrinfo(smtp_host, smtp_port,
|
||||||
socket.AF_UNSPEC, socket.SOCK_STREAM)
|
socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||||
@@ -143,7 +134,6 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
|||||||
f"Check the hostname and your network connection.\n({e})"
|
f"Check the hostname and your network connection.\n({e})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: TCP connect (raw socket, before any SMTP protocol)
|
|
||||||
try:
|
try:
|
||||||
sock = socket.create_connection((smtp_host, smtp_port), timeout=8)
|
sock = socket.create_connection((smtp_host, smtp_port), timeout=8)
|
||||||
sock.close()
|
sock.close()
|
||||||
@@ -154,43 +144,30 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
|||||||
f"The port may be blocked by a firewall or the server is down.\n({e})"
|
f"The port may be blocked by a firewall or the server is down.\n({e})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Steps 3 + 4: SMTP protocol, TLS handshake, authentication
|
|
||||||
try:
|
try:
|
||||||
server = _make_smtp_server(smtp_host, smtp_port, security)
|
server = _make_smtp_server(smtp_host, smtp_port, security)
|
||||||
logger.info(f"SMTP test: TLS/connection OK (security={security})")
|
logger.info(f"SMTP test: TLS/connection OK (security={security})")
|
||||||
except smtplib.SMTPConnectError as e:
|
except smtplib.SMTPConnectError as e:
|
||||||
return False, (
|
return False, (f"Step 3 FAILED — SMTP connect: {e}\nTry a different Security mode or port.")
|
||||||
f"Step 3 FAILED — SMTP connect: {e}\n"
|
|
||||||
f"Try a different Security mode or port."
|
|
||||||
)
|
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
return False, (
|
return False, (f"Step 3 FAILED — TLS handshake: {e}\nTry switching Security mode.")
|
||||||
f"Step 3 FAILED — TLS handshake: {e}\n"
|
|
||||||
f"Try switching Security mode (e.g. SSL/TLS on port 465)."
|
|
||||||
)
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
return False, (
|
return False, (f"Step 3 FAILED — connection dropped: {e}\nTry switching Security mode or port.")
|
||||||
f"Step 3 FAILED — connection dropped: {e}\n"
|
|
||||||
f"Try switching Security mode or port."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server.login(smtp_user, smtp_password)
|
server.login(smtp_user, smtp_password)
|
||||||
server.quit()
|
server.quit()
|
||||||
logger.info("SMTP test: authentication OK")
|
logger.info("SMTP test: authentication OK")
|
||||||
return True, (
|
return True, (
|
||||||
f"All steps passed.\n"
|
f"All steps passed.\nConnected to {smtp_host}:{smtp_port} "
|
||||||
f"Connected to {smtp_host}:{smtp_port} "
|
|
||||||
f"({security.upper()}) and authenticated successfully."
|
f"({security.upper()}) and authenticated successfully."
|
||||||
)
|
)
|
||||||
except smtplib.SMTPAuthenticationError as e:
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
try:
|
try: server.quit()
|
||||||
server.quit()
|
except Exception: pass
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False, (
|
return False, (
|
||||||
f"Step 4 FAILED — Authentication: username or password rejected.\n"
|
f"Step 4 FAILED — Authentication: username or password rejected.\n"
|
||||||
f"For Gmail/Google Workspace use an App Password, not your account password.\n({e})"
|
f"For Gmail/Google Workspace use an App Password.\n({e})"
|
||||||
)
|
)
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
return False, f"Step 4 FAILED — SMTP error during login: {e}"
|
return False, f"Step 4 FAILED — SMTP error during login: {e}"
|
||||||
@@ -201,10 +178,7 @@ def test_smtp_connection(smtp_host: str, smtp_port: int,
|
|||||||
def send_test_email(smtp_host: str, smtp_port: int,
|
def send_test_email(smtp_host: str, smtp_port: int,
|
||||||
smtp_user: str, smtp_password: str,
|
smtp_user: str, smtp_password: str,
|
||||||
security: str, recipients: list) -> tuple:
|
security: str, recipients: list) -> tuple:
|
||||||
"""
|
"""Send a real test email through the full pipeline."""
|
||||||
Send a real test email through the full pipeline.
|
|
||||||
Returns (success: bool, message: str).
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
subject = "Website Checker — SMTP Test"
|
subject = "Website Checker — SMTP Test"
|
||||||
body = (
|
body = (
|
||||||
@@ -226,10 +200,8 @@ def send_test_email(smtp_host: str, smtp_port: int,
|
|||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
|
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
|
||||||
msg["X-Mailer"] = "WebChecker"
|
msg["X-Mailer"] = "WebChecker"
|
||||||
# Plain-text part must come first; HTML second.
|
|
||||||
# Spam filters heavily penalise HTML-only messages with no text/plain alternative.
|
|
||||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||||
msg.attach(MIMEText(body, "html", "utf-8"))
|
msg.attach(MIMEText(body, "html", "utf-8"))
|
||||||
|
|
||||||
server = _make_smtp_server(smtp_host, smtp_port, security)
|
server = _make_smtp_server(smtp_host, smtp_port, security)
|
||||||
server.login(smtp_user, smtp_password)
|
server.login(smtp_user, smtp_password)
|
||||||
@@ -238,10 +210,8 @@ def send_test_email(smtp_host: str, smtp_port: int,
|
|||||||
logger.info(f"Test email sent to {recipients} via {smtp_host}:{smtp_port}")
|
logger.info(f"Test email sent to {recipients} via {smtp_host}:{smtp_port}")
|
||||||
return True, f"Test email sent successfully to: {', '.join(recipients)}"
|
return True, f"Test email sent successfully to: {', '.join(recipients)}"
|
||||||
except smtplib.SMTPAuthenticationError as e:
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
return False, (
|
return False, (f"Authentication failed — check username and password.\n"
|
||||||
f"Authentication failed — check username and password.\n"
|
f"For Gmail/Google Workspace use an App Password.\n({e})")
|
||||||
f"For Gmail/Google Workspace use an App Password.\n({e})"
|
|
||||||
)
|
|
||||||
except smtplib.SMTPConnectError as e:
|
except smtplib.SMTPConnectError as e:
|
||||||
return False, f"Could not connect to {smtp_host}:{smtp_port} — {e}"
|
return False, f"Could not connect to {smtp_host}:{smtp_port} — {e}"
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
@@ -269,8 +239,6 @@ def _build_html_report() -> str:
|
|||||||
skipped_no_shift = 0
|
skipped_no_shift = 0
|
||||||
for r in rows:
|
for r in rows:
|
||||||
total = int(r.get("total_sites") or 0)
|
total = int(r.get("total_sites") or 0)
|
||||||
# Skip users with no shifts scheduled today — they have no expected
|
|
||||||
# work for this day and showing them as "0 / 0 — 0%" is misleading.
|
|
||||||
if total == 0:
|
if total == 0:
|
||||||
skipped_no_shift += 1
|
skipped_no_shift += 1
|
||||||
continue
|
continue
|
||||||
@@ -324,11 +292,11 @@ def _build_html_report() -> str:
|
|||||||
|
|
||||||
def _send_report(cfg: dict):
|
def _send_report(cfg: dict):
|
||||||
"""Build and send the daily report email."""
|
"""Build and send the daily report email."""
|
||||||
html = _build_html_report()
|
html = _build_html_report()
|
||||||
today = datetime.date.today().strftime("%d %b %Y")
|
today = datetime.date.today().strftime("%d %b %Y")
|
||||||
subject = f"Website Checker — Daily Report {today}"
|
subject = f"Website Checker — Daily Report {today}"
|
||||||
|
|
||||||
smtp_user = cfg["smtp_user"]
|
smtp_user = cfg["smtp_user"]
|
||||||
|
|
||||||
plain = (
|
plain = (
|
||||||
f"Website Checker — Daily Report {today}\n\n"
|
f"Website Checker — Daily Report {today}\n\n"
|
||||||
"Please view this report in an HTML-capable email client for full formatting.\n"
|
"Please view this report in an HTML-capable email client for full formatting.\n"
|
||||||
@@ -341,17 +309,14 @@ def _send_report(cfg: dict):
|
|||||||
msg["Date"] = formatdate(localtime=True)
|
msg["Date"] = formatdate(localtime=True)
|
||||||
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
|
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
|
||||||
msg["X-Mailer"] = "WebChecker"
|
msg["X-Mailer"] = "WebChecker"
|
||||||
# Plain-text part must come first; HTML second.
|
|
||||||
# Spam filters heavily penalise HTML-only messages with no text/plain alternative.
|
|
||||||
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
msg.attach(MIMEText(plain, "plain", "utf-8"))
|
||||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
security = cfg.get("security", "starttls")
|
security = cfg.get("security", "starttls")
|
||||||
server = _make_smtp_server(
|
server = _make_smtp_server(cfg["smtp_host"], cfg["smtp_port"], security)
|
||||||
cfg["smtp_host"], cfg["smtp_port"], security)
|
server.login(smtp_user, cfg["smtp_password"])
|
||||||
server.login(cfg["smtp_user"], cfg["smtp_password"])
|
server.sendmail(smtp_user, cfg["recipients"], msg.as_string())
|
||||||
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
|
|
||||||
server.quit()
|
server.quit()
|
||||||
logger.info(f"Daily report emailed to: {cfg['recipients']}")
|
logger.info(f"Daily report emailed to: {cfg['recipients']}")
|
||||||
except smtplib.SMTPAuthenticationError as e:
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
@@ -367,12 +332,9 @@ def _send_report(cfg: dict):
|
|||||||
|
|
||||||
|
|
||||||
def _get_last_sent_date() -> "datetime.date | None":
|
def _get_last_sent_date() -> "datetime.date | None":
|
||||||
"""Read the last-sent date from config.ini [email] last_sent_date key."""
|
"""Read the last-sent date from app_settings."""
|
||||||
cfg = configparser.ConfigParser()
|
from config import get_setting
|
||||||
if not os.path.exists(CONFIG_FILE):
|
raw = get_setting("email.last_sent_date", "")
|
||||||
return None
|
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
|
||||||
raw = cfg.get("email", "last_sent_date", fallback="")
|
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -382,25 +344,18 @@ def _get_last_sent_date() -> "datetime.date | None":
|
|||||||
|
|
||||||
|
|
||||||
def _set_last_sent_date(d: "datetime.date"):
|
def _set_last_sent_date(d: "datetime.date"):
|
||||||
"""Persist the last-sent date to config.ini [email] last_sent_date key."""
|
"""Persist the last-sent date to app_settings."""
|
||||||
cfg = configparser.ConfigParser()
|
from config import set_setting
|
||||||
if os.path.exists(CONFIG_FILE):
|
set_setting("email.last_sent_date", d.isoformat())
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
|
||||||
if "email" not in cfg:
|
|
||||||
cfg["email"] = {}
|
|
||||||
cfg["email"]["last_sent_date"] = d.isoformat()
|
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
|
||||||
cfg.write(fh)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Scheduler loop ───────────────────────────────────────────────────────────
|
# ─── Scheduler loop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _scheduler_loop():
|
def _scheduler_loop():
|
||||||
# Seed from persisted value so a restart after send_time does not re-send.
|
|
||||||
last_sent_date = _get_last_sent_date()
|
last_sent_date = _get_last_sent_date()
|
||||||
|
|
||||||
while not _stop_event.is_set():
|
while not _stop_event.is_set():
|
||||||
_stop_event.wait(60) # sleep 60 seconds between checks
|
_stop_event.wait(60)
|
||||||
if _stop_event.is_set():
|
if _stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -427,16 +382,10 @@ def _scheduler_loop():
|
|||||||
def start():
|
def start():
|
||||||
"""Start the background scheduler thread. Call once after successful login."""
|
"""Start the background scheduler thread. Call once after successful login."""
|
||||||
global _scheduler_thread, _stop_event
|
global _scheduler_thread, _stop_event
|
||||||
# Guard against double-start: if a thread is already alive (e.g. admin
|
|
||||||
# logs out and back in), stop it cleanly before spawning a new one.
|
|
||||||
# Without this guard, _stop_event.clear() would unblock the sleeping
|
|
||||||
# thread while a second thread also starts, resulting in two scheduler
|
|
||||||
# threads firing simultaneously and potentially sending duplicate emails.
|
|
||||||
if _scheduler_thread is not None and _scheduler_thread.is_alive():
|
if _scheduler_thread is not None and _scheduler_thread.is_alive():
|
||||||
logger.info("Email scheduler already running - stopping before restart.")
|
logger.info("Email scheduler already running - stopping before restart.")
|
||||||
_stop_event.set()
|
_stop_event.set()
|
||||||
_scheduler_thread.join(timeout=5)
|
_scheduler_thread.join(timeout=5)
|
||||||
# Create a fresh Event so there is no residual set-state from a prior stop()
|
|
||||||
_stop_event = threading.Event()
|
_stop_event = threading.Event()
|
||||||
_scheduler_thread = threading.Thread(target=_scheduler_loop,
|
_scheduler_thread = threading.Thread(target=_scheduler_loop,
|
||||||
name="EmailScheduler", daemon=True)
|
name="EmailScheduler", daemon=True)
|
||||||
|
|||||||
@@ -167,35 +167,23 @@ class AiSummaryView(ttk.Frame):
|
|||||||
|
|
||||||
def _load_config(self):
|
def _load_config(self):
|
||||||
try:
|
try:
|
||||||
import configparser
|
from config import get_setting
|
||||||
from config import CONFIG_FILE
|
|
||||||
from utils.config_crypto import decrypt_value
|
from utils.config_crypto import decrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
raw_key = get_setting("groq.api_key", "")
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
if raw_key:
|
||||||
if cfg.has_section(_CFG_SECTION):
|
|
||||||
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
|
|
||||||
self._api_key_var.set(decrypt_value(raw_key))
|
self._api_key_var.set(decrypt_value(raw_key))
|
||||||
model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL,
|
model = get_setting("groq.model", GROQ_MODELS[0])
|
||||||
fallback=GROQ_MODELS[0])
|
if model in GROQ_MODELS:
|
||||||
if model in GROQ_MODELS:
|
self._model_var.set(model)
|
||||||
self._model_var.set(model)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not load Groq config: {e}")
|
logger.warning(f"Could not load Groq config: {e}")
|
||||||
|
|
||||||
def _save_config(self):
|
def _save_config(self):
|
||||||
try:
|
try:
|
||||||
import configparser
|
from config import set_setting
|
||||||
from config import CONFIG_FILE
|
|
||||||
from utils.config_crypto import encrypt_value
|
from utils.config_crypto import encrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
set_setting("groq.api_key", encrypt_value(self._api_key_var.get().strip()))
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
set_setting("groq.model", self._model_var.get())
|
||||||
if not cfg.has_section(_CFG_SECTION):
|
|
||||||
cfg.add_section(_CFG_SECTION)
|
|
||||||
cfg.set(_CFG_SECTION, _CFG_KEY_KEY,
|
|
||||||
encrypt_value(self._api_key_var.get().strip()))
|
|
||||||
cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get())
|
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
|
||||||
cfg.write(fh)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not save Groq config: {e}")
|
logger.warning(f"Could not save Groq config: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
views/email_settings_view.py — SMTP / scheduled report configuration dialog.
|
views/email_settings_view.py — SMTP / scheduled report configuration dialog.
|
||||||
Admin-only. Persists to config.ini [email] section via utils/scheduler.py.
|
Admin-only. Persists to the app_settings database table via utils/scheduler.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -326,4 +326,4 @@ class EmailSettingsView(tk.Toplevel):
|
|||||||
f"security={security}.")
|
f"security={security}.")
|
||||||
show_info("Email settings saved successfully.")
|
show_info("Email settings saved successfully.")
|
||||||
logger.info(f"Email settings saved by {self.current_user['username']}.")
|
logger.info(f"Email settings saved by {self.current_user['username']}.")
|
||||||
self.destroy()
|
self.destroy()
|
||||||
+24
-15
@@ -1,13 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
views/settings_view.py — Database connection settings dialog.
|
views/settings_view.py — Database connection settings dialog.
|
||||||
|
|
||||||
Shown automatically on first run (no config.ini) and accessible
|
Shown automatically on first run (no stored credentials) and accessible
|
||||||
via the admin sidebar Settings nav item at any time.
|
via the admin sidebar Settings nav item at any time.
|
||||||
|
|
||||||
Writes connection details to config.ini via config.save_config().
|
Credentials are saved to the OS native keychain (Windows Credential Manager,
|
||||||
Does NOT store the password in plaintext beyond what config.ini holds
|
macOS Keychain, or Linux Secret Service) via the keyring library.
|
||||||
(which is acceptable for a locally-run desktop tool; operators should
|
No config.ini or local file is written — the app is fully portable.
|
||||||
restrict file-system access to config.ini in production).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -42,7 +41,7 @@ class SettingsView(tk.Toplevel):
|
|||||||
|
|
||||||
self.title("Database Setup" if first_run else "Connection Settings")
|
self.title("Database Setup" if first_run else "Connection Settings")
|
||||||
self.configure(bg=COLOURS["bg"])
|
self.configure(bg=COLOURS["bg"])
|
||||||
self.resizable(False, False)
|
self.resizable(False, True) # allow vertical resize for high-DPI
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
|
|
||||||
if first_run:
|
if first_run:
|
||||||
@@ -58,7 +57,9 @@ class SettingsView(tk.Toplevel):
|
|||||||
|
|
||||||
def _centre(self):
|
def _centre(self):
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
w, h = 480, 480
|
# Height increased to 560 to ensure button row is always visible.
|
||||||
|
# Vertical resize allowed for high-DPI / large-font environments.
|
||||||
|
w, h = 480, 560
|
||||||
x = (self.winfo_screenwidth() - w) // 2
|
x = (self.winfo_screenwidth() - w) // 2
|
||||||
y = (self.winfo_screenheight() - h) // 2
|
y = (self.winfo_screenheight() - h) // 2
|
||||||
self.geometry(f"{w}x{h}+{x}+{y}")
|
self.geometry(f"{w}x{h}+{x}+{y}")
|
||||||
@@ -77,6 +78,12 @@ class SettingsView(tk.Toplevel):
|
|||||||
font=FONT_SMALL, bg=COLOURS["accent"],
|
font=FONT_SMALL, bg=COLOURS["accent"],
|
||||||
fg=COLOURS["white"]).pack(pady=(2, 0))
|
fg=COLOURS["white"]).pack(pady=(2, 0))
|
||||||
|
|
||||||
|
# ── Buttons (packed before form so they anchor to bottom) ─────────────
|
||||||
|
# Packing the button row before the expanding form guarantees it is
|
||||||
|
# always visible even when the form content exceeds the window height.
|
||||||
|
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16)
|
||||||
|
btn_row.pack(side="bottom", fill="x")
|
||||||
|
|
||||||
# ── Form ──────────────────────────────────────────────────────────────
|
# ── Form ──────────────────────────────────────────────────────────────
|
||||||
form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20)
|
form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20)
|
||||||
form.pack(fill="both", expand=True)
|
form.pack(fill="both", expand=True)
|
||||||
@@ -116,7 +123,8 @@ class SettingsView(tk.Toplevel):
|
|||||||
|
|
||||||
# ── Hint ──────────────────────────────────────────────────────────────
|
# ── Hint ──────────────────────────────────────────────────────────────
|
||||||
hint = (
|
hint = (
|
||||||
"Settings are saved to config.ini in the application folder.\n"
|
"Credentials are saved to your OS keychain (Windows Credential Manager, "
|
||||||
|
"macOS Keychain, or Linux Secret Service) — no config.ini required.\n"
|
||||||
"Ensure the database user has CREATE, INSERT, UPDATE, DELETE privileges."
|
"Ensure the database user has CREATE, INSERT, UPDATE, DELETE privileges."
|
||||||
)
|
)
|
||||||
tk.Label(form, text=hint, bg=COLOURS["bg"],
|
tk.Label(form, text=hint, bg=COLOURS["bg"],
|
||||||
@@ -124,10 +132,7 @@ class SettingsView(tk.Toplevel):
|
|||||||
justify="left", wraplength=380).grid(
|
justify="left", wraplength=380).grid(
|
||||||
row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
|
||||||
|
|
||||||
# ── Buttons ───────────────────────────────────────────────────────────
|
# ── Buttons (btn_row already packed at top of _build_ui) ─────────────
|
||||||
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16)
|
|
||||||
btn_row.pack(fill="x")
|
|
||||||
|
|
||||||
self._save_btn = tk.Button(
|
self._save_btn = tk.Button(
|
||||||
btn_row, text="Save & Connect",
|
btn_row, text="Save & Connect",
|
||||||
command=self._save,
|
command=self._save,
|
||||||
@@ -263,9 +268,13 @@ class SettingsView(tk.Toplevel):
|
|||||||
)
|
)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
from config import save_config
|
from config import save_config, reload_db_config
|
||||||
save_config(host, port, database, user, password)
|
save_config(host, port, database, user, password)
|
||||||
logger.info(f"Settings saved: {user}@{host}:{port}/{database}")
|
# Immediately update the in-memory DB_CONFIG and reset the
|
||||||
|
# connection pool so the app connects with the new credentials
|
||||||
|
# without requiring a restart.
|
||||||
|
reload_db_config()
|
||||||
|
logger.info(f"Settings saved and applied: {user}@{host}:{port}/{database}")
|
||||||
|
|
||||||
self.after(0, self._on_save_success)
|
self.after(0, self._on_save_success)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -284,4 +293,4 @@ class SettingsView(tk.Toplevel):
|
|||||||
|
|
||||||
def _finish(self):
|
def _finish(self):
|
||||||
self.destroy()
|
self.destroy()
|
||||||
self.on_save_callback()
|
self.on_save_callback()
|
||||||
Reference in New Issue
Block a user