04/21 Fisrt commit

This commit is contained in:
2026-04-21 17:16:37 -04:00
parent f94d86f04b
commit 7548dfc9bf
23 changed files with 6860 additions and 1 deletions
+287
View File
@@ -0,0 +1,287 @@
"""
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()