""" views/settings_view.py — Database connection settings dialog. Shown automatically on first run (no config.ini) and accessible via the admin sidebar Settings nav item at any time. Writes connection details to config.ini via config.save_config(). Does NOT store the password in plaintext beyond what config.ini holds (which is acceptable for a locally-run desktop tool; operators should restrict file-system access to config.ini in production). """ 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, False) 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() w, h = 480, 480 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)) # ── 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 = ( "Settings are saved to config.ini in the application folder.\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 = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16) btn_row.pack(fill="x") 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 save_config(host, port, database, user, password) logger.info(f"Settings saved: {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()