""" 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()