288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""
|
|
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()
|