Files
WebChecker/views/settings_view.py
T

296 lines
13 KiB
Python

"""
views/settings_view.py — Database connection settings dialog.
Shown automatically on first run (no stored credentials) and accessible
via the admin sidebar Settings nav item at any time.
Credentials are saved to the OS native keychain (Windows Credential Manager,
macOS Keychain, or Linux Secret Service) via the keyring library.
No config.ini or local file is written — the app is fully portable.
"""
import tkinter as tk
from tkinter import ttk
import logging
import threading
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info,
)
logger = logging.getLogger("settings_view")
class SettingsView(tk.Toplevel):
"""
Modal dialog for DB connection settings.
Parameters
----------
master : tk.Tk or tk.Toplevel parent
on_save_callback: called (no args) after settings are saved and verified
first_run : True => locked modal, no Cancel, title says "Setup"
False => dismissible, title says "Settings"
"""
def __init__(self, master, on_save_callback, first_run: bool = False):
super().__init__(master)
self.on_save_callback = on_save_callback
self.first_run = first_run
self.title("Database Setup" if first_run else "Connection Settings")
self.configure(bg=COLOURS["bg"])
self.resizable(False, True) # allow vertical resize for high-DPI
self.grab_set()
if first_run:
self.protocol("WM_DELETE_WINDOW", lambda: None) # prevent close
else:
self.protocol("WM_DELETE_WINDOW", self.destroy)
self._build_ui()
self._centre()
self._load_existing()
# ─── Layout ───────────────────────────────────────────────────────────────
def _centre(self):
self.update_idletasks()
# 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
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
# ── Header ────────────────────────────────────────────────────────────
hdr = tk.Frame(self, bg=COLOURS["accent"], pady=16)
hdr.pack(fill="x")
tk.Label(hdr,
text="Database Setup" if self.first_run else "Connection Settings",
font=FONT_BOLD, bg=COLOURS["accent"],
fg=COLOURS["white"]).pack()
if self.first_run:
tk.Label(hdr,
text="Enter your MySQL connection details to get started.",
font=FONT_SMALL, bg=COLOURS["accent"],
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 = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20)
form.pack(fill="both", expand=True)
form.columnconfigure(1, weight=1)
def field(label, row, show=None, width=30):
tk.Label(form, text=label, bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL,
anchor="w").grid(row=row, column=0,
sticky="w", padx=(0, 12), pady=6)
var = tk.StringVar()
ent = tk.Entry(form, textvariable=var, width=width,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT,
show=show or "")
ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=6)
return var, ent
self.host_var, self._host_ent = field("Host *", 0)
self.port_var, _ = field("Port *", 1, width=8)
self.database_var, _ = field("Database Name *", 2)
self.user_var, _ = field("Username *", 3)
self.password_var, _ = field("Password", 4, show="•")
self.port_var.set("3306") # sensible default
# ── Status line ───────────────────────────────────────────────────────
self._status_var = tk.StringVar(value="")
self._status_lbl = tk.Label(
form, textvariable=self._status_var,
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
font=FONT_SMALL, anchor="w", wraplength=380
)
self._status_lbl.grid(row=5, column=0, columnspan=2,
sticky="ew", pady=(4, 0))
# ── Hint ──────────────────────────────────────────────────────────────
hint = (
"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."
)
tk.Label(form, text=hint, bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL,
justify="left", wraplength=380).grid(
row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
# ── Buttons (btn_row already packed at top of _build_ui) ─────────────
self._save_btn = tk.Button(
btn_row, text="Save & Connect",
command=self._save,
bg=COLOURS["accent"], fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD,
cursor="hand2", padx=14, pady=8,
)
self._save_btn.pack(side="right", padx=(8, 0))
self._test_btn = tk.Button(
btn_row, text="Test Connection",
command=self._test_connection,
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["surface2"],
activeforeground=COLOURS["accent"],
relief="flat", font=FONT_SMALL,
cursor="hand2", padx=10, pady=8,
)
self._test_btn.pack(side="right")
if not self.first_run:
tk.Button(
btn_row, text="Cancel",
command=self.destroy,
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
activebackground=COLOURS["surface2"],
relief="flat", font=FONT_SMALL,
cursor="hand2", padx=10, pady=8,
).pack(side="left")
# ─── Prefill from existing config ─────────────────────────────────────────
def _load_existing(self):
from config import load_config
ini = load_config()
if ini:
self.host_var.set(ini.get("host", ""))
self.port_var.set(str(ini.get("port", 3306)))
self.database_var.set(ini.get("database", ""))
self.user_var.set(ini.get("user", ""))
self.password_var.set(ini.get("password", ""))
# ─── Validation ───────────────────────────────────────────────────────────
def _get_fields(self):
"""Return (host, port, database, user, password) or None on error."""
host = self.host_var.get().strip()
port_str = self.port_var.get().strip()
database = self.database_var.get().strip()
user = self.user_var.get().strip()
password = self.password_var.get()
if not host or not database or not user:
self._set_status("Host, Database Name, and Username are required.", "danger")
return None
try:
port = int(port_str)
if not (1 <= port <= 65535):
raise ValueError
except ValueError:
self._set_status("Port must be a number between 1 and 65535.", "danger")
return None
return host, port, database, user, password
def _set_status(self, message: str, level: str = "dim"):
colour_map = {
"danger": COLOURS["danger"],
"success": COLOURS["success"],
"warning": COLOURS["warning"],
"dim": COLOURS["text_dim"],
}
self._status_lbl.config(fg=colour_map.get(level, COLOURS["text_dim"]))
self._status_var.set(message)
# ─── Test Connection ──────────────────────────────────────────────────────
def _test_connection(self):
fields = self._get_fields()
if not fields:
return
host, port, database, user, password = fields
self._set_status("Testing connection...", "dim")
self._test_btn.config(state="disabled")
self._save_btn.config(state="disabled")
# Run in a background thread so the UI stays responsive
def _run():
try:
import mysql.connector
conn = mysql.connector.connect(
host=host, port=port, database=database,
user=user, password=password,
connection_timeout=8,
)
conn.close()
self.after(0, lambda: self._set_status(
"Connection successful!", "success"))
logger.info(f"Test connection to {host}:{port}/{database} succeeded.")
except Exception as e:
self.after(0, lambda err=e: self._set_status(
f"Connection failed: {err}", "danger"))
logger.warning(f"Test connection failed: {e}")
finally:
self.after(0, lambda: self._test_btn.config(state="normal"))
self.after(0, lambda: self._save_btn.config(state="normal"))
threading.Thread(target=_run, daemon=True).start()
# ─── Save ─────────────────────────────────────────────────────────────────
def _save(self):
fields = self._get_fields()
if not fields:
return
host, port, database, user, password = fields
self._set_status("Saving and connecting...", "dim")
self._save_btn.config(state="disabled")
self._test_btn.config(state="disabled")
def _run():
try:
# Verify the connection before persisting
import mysql.connector
conn = mysql.connector.connect(
host=host, port=port, database=database,
user=user, password=password,
connection_timeout=8,
)
conn.close()
from config import save_config, reload_db_config
save_config(host, port, database, user, password)
# 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)
except Exception as e:
self.after(0, lambda err=e: self._set_status(
f"Could not connect: {err}", "danger"))
logger.error(f"Settings save failed: {e}")
finally:
self.after(0, lambda: self._save_btn.config(state="normal"))
self.after(0, lambda: self._test_btn.config(state="normal"))
threading.Thread(target=_run, daemon=True).start()
def _on_save_success(self):
self._set_status("Saved successfully.", "success")
self.after(400, self._finish)
def _finish(self):
self.destroy()
self.on_save_callback()