""" views/user_dashboard_view.py — Regular user: Shift Dashboard. New in this version: - Live search/filter bar - Bulk check-off (select all visible unchecked, then mark in one action) - Shift-end notification via plyer (falls back to Tkinter toast) """ import tkinter as tk from tkinter import ttk import webbrowser import logging import threading import datetime from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_SMALL, scrolled_text, show_error, show_info, ) logger = logging.getLogger("user_dashboard_view") # How many minutes before shift end to fire a reminder notification NOTIFY_MINUTES_BEFORE = 15 class UserDashboardView(ttk.Frame): def __init__(self, parent, current_user: dict): super().__init__(parent) self.current_user = current_user self._all_sites = [] # full unfiltered list self._site_check_vars = {} # website_id -> BooleanVar (bulk select) self._notified_sites = set() # ids already notified this session self._notify_job = None # after() handle self._health_cache = {} # website_id -> ("ok"|"slow"|"down", ms) self._build_ui() self._load() def destroy(self): # Cancel notification timer if self._notify_job: try: self.after_cancel(self._notify_job) except Exception: pass # Remove mousewheel binding so it can't fire on a dead canvas try: self.canvas.unbind_all("") except Exception: pass # Remove keyboard shortcut bindings self._unbind_shortcuts() # Destroy any open tooltip self._hide_tooltip() super().destroy() # ─── Layout ─────────────────────────────────────────────────────────────── def _build_ui(self): # ── Top bar ─────────────────────────────────────────────────────────── top = ttk.Frame(self) top.pack(fill="x", pady=(0, 8)) ttk.Label(top, text="My Shift — Website Checklist", style="Heading.TLabel").pack(side="left") ttk.Label( top, text=f"{self.current_user['full_name'] or self.current_user['username']}", style="Dim.TLabel" ).pack(side="right") ttk.Button(top, text="↻ Refresh", style="Ghost.TButton", command=self._load).pack(side="right", padx=(0, 8)) # ── Search + bulk actions bar ────────────────────────────────────────── action_bar = tk.Frame(self, bg=COLOURS["surface"], pady=8) action_bar.pack(fill="x", pady=(0, 8)) # Search box tk.Label(action_bar, text="🔍", bg=COLOURS["surface"], fg=COLOURS["text_dim"], font=FONT).pack(side="left", padx=(12, 4)) self._search_var = tk.StringVar() self._search_var.trace_add("write", lambda *_: self._apply_filter()) search_ent = tk.Entry( action_bar, textvariable=self._search_var, width=28, bg=COLOURS["surface2"], fg=COLOURS["text"], insertbackground=COLOURS["text"], relief="flat", font=FONT ) search_ent.pack(side="left", ipady=5, padx=(0, 16)) # Bulk action buttons tk.Button( action_bar, text="☑ Select All Unchecked", command=self._select_all_unchecked, bg=COLOURS["surface2"], fg=COLOURS["text"], activebackground=COLOURS["accent"], activeforeground=COLOURS["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).pack(side="left", padx=(0, 6)) tk.Button( action_bar, text="✔ Mark Selected Checked", command=self._bulk_check, bg=COLOURS["success"], fg=COLOURS["white"], activebackground="#3d9140", activeforeground=COLOURS["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).pack(side="left") # ── Progress bar ────────────────────────────────────────────────────── prog_frame = ttk.Frame(self) prog_frame.pack(fill="x", pady=(0, 6)) self.progress_label = ttk.Label(prog_frame, text="", style="Dim.TLabel") self.progress_label.pack(side="left") self.progress_bar = ttk.Progressbar(prog_frame, length=200, mode="determinate") self.progress_bar.pack(side="right") ttk.Separator(self, orient="horizontal").pack(fill="x", pady=(0, 10)) # ── Scrollable sites list ───────────────────────────────────────────── list_frame = ttk.Frame(self) list_frame.pack(fill="both", expand=True) self.canvas = tk.Canvas(list_frame, bg=COLOURS["bg"], highlightthickness=0) vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.canvas.yview) self.canvas.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") self.canvas.pack(side="left", fill="both", expand=True) self.sites_frame = ttk.Frame(self.canvas) self.sites_frame.bind( "", lambda e: self.canvas.configure( scrollregion=self.canvas.bbox("all")) ) self._canvas_window = self.canvas.create_window( (0, 0), window=self.sites_frame, anchor="nw" ) self.canvas.bind("", self._on_canvas_resize) # Scope mousewheel to the canvas and its children via Enter/Leave self.canvas.bind("", self._on_canvas_enter) self.canvas.bind("", self._on_canvas_leave) self._mw_binding = None # track active bind_all handle self._bind_shortcuts() def _on_canvas_resize(self, event): self.canvas.itemconfig(self._canvas_window, width=event.width) def _on_canvas_enter(self, event): """Mouse entered canvas area — activate mousewheel scrolling.""" self._mw_binding = self.canvas.bind_all( "", self._on_mousewheel ) def _on_canvas_leave(self, event): """Mouse left canvas area — deactivate mousewheel scrolling.""" try: self.canvas.unbind_all("") except Exception: pass self._mw_binding = None def _on_mousewheel(self, event): try: self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") except Exception: pass # ─── Data ───────────────────────────────────────────────────────────────── def _load(self): from models import get_today_checks try: self._all_sites = get_today_checks(self.current_user["id"]) except Exception as e: show_error(f"Failed to load websites:\n{e}") return self._site_check_vars.clear() self._apply_filter() self._schedule_notifications() def _apply_filter(self): query = self._search_var.get().strip().lower() if query: filtered = [s for s in self._all_sites if query in s["name"].lower() or query in (s["url"] or "").lower()] else: filtered = self._all_sites self._render_sites(filtered) def _render_sites(self, sites: list): for widget in self.sites_frame.winfo_children(): widget.destroy() checked_count = sum(1 for s in self._all_sites if s["check_id"]) total = len(self._all_sites) self.progress_bar["maximum"] = total self.progress_bar["value"] = checked_count self.progress_label.config( text=f"Progress: {checked_count} / {total} checked" + (f" | Showing {len(sites)} of {total}" if len(sites) != total else "") ) if not sites: msg = ("No websites match your search." if self._search_var.get().strip() else "No active websites found. Please contact your administrator.") ttk.Label(self.sites_frame, text=msg, style="Dim.TLabel").pack(pady=40) return for site in sites: self._render_site_card(site) # ─── Site Card ──────────────────────────────────────────────────────────── def _render_site_card(self, site: dict): wid = site["id"] is_checked = bool(site["check_id"]) border_col = COLOURS["success"] if is_checked else COLOURS["border"] card = tk.Frame( self.sites_frame, bg=COLOURS["surface"], bd=2, relief="flat", highlightbackground=border_col, highlightthickness=2, ) card.pack(fill="x", padx=8, pady=5, ipady=4) card.columnconfigure(2, weight=1) # ── Bulk-select checkbox ─────────────────────────────────────────────── sel_var = tk.BooleanVar(value=False) self._site_check_vars[wid] = sel_var cb = tk.Checkbutton( card, variable=sel_var, bg=COLOURS["surface"], activebackground=COLOURS["surface"], selectcolor=COLOURS["surface2"], cursor="hand2", ) cb.grid(row=0, column=0, rowspan=3, padx=(10, 4), sticky="ns") # ── Status indicator ────────────────────────────────────────────────── status_char = "✔" if is_checked else "○" status_col = COLOURS["success"] if is_checked else COLOURS["text_dim"] tk.Label(card, text=status_char, font=(FONT[0], 16, "bold"), bg=COLOURS["surface"], fg=status_col, width=2).grid(row=0, column=1, rowspan=2, padx=(4, 8), pady=8, sticky="ns") # ── Health indicator dot (updated asynchronously) ───────────────────── cached = self._health_cache.get(wid) if cached: status_str, ms = cached dot_col = {"ok": COLOURS["success"], "slow": COLOURS["warning"], "down": COLOURS["danger"]}.get(status_str, COLOURS["text_dim"]) dot_tip = {"ok": f"Reachable ({ms}ms)", "slow": f"Slow ({ms}ms)", "down": "Unreachable"}.get(status_str, "Unknown") else: dot_col = COLOURS["text_dim"] dot_tip = "Checking..." health_dot = tk.Label(card, text="●", font=(FONT[0], 9), bg=COLOURS["surface"], fg=dot_col, cursor="hand2") health_dot.grid(row=0, column=5, padx=(0, 6), sticky="e") health_dot.bind("", lambda e, t=dot_tip: self._show_tooltip(e, t)) health_dot.bind("", self._hide_tooltip) # Trigger background health check if not cached if wid not in self._health_cache: self._health_cache[wid] = ("checking", 0) threading.Thread( target=self._check_site_health, args=(wid, site["url"], health_dot), daemon=True ).start() # ── Site name + URL (clickable) ──────────────────────────────────────── name_lbl = tk.Label(card, text=site["name"], font=FONT_BOLD, bg=COLOURS["surface"], fg=COLOURS["accent"], cursor="hand2", anchor="w") name_lbl.grid(row=0, column=2, sticky="ew", padx=4, pady=(8, 1)) name_lbl.bind("", lambda e, s=site: self._open_site(s)) url_lbl = tk.Label(card, text=site["url"], font=FONT_SMALL, bg=COLOURS["surface"], fg=COLOURS["text_dim"], cursor="hand2", anchor="w") url_lbl.grid(row=1, column=2, sticky="ew", padx=4, pady=(0, 4)) url_lbl.bind("", lambda e, s=site: self._open_site(s)) # ── Metadata badges ──────────────────────────────────────────────────── badge_row = 2 if site.get("site_note"): tk.Label(card, text=f"📝 {site['site_note']}", font=FONT_SMALL, bg=COLOURS["surface"], fg=COLOURS["text_dim"], anchor="w", wraplength=400).grid( row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 2)) badge_row += 1 if site.get("shift_names"): tk.Label(card, text=f"🕐 {site['shift_names']}", font=FONT_SMALL, bg=COLOURS["surface"], fg=COLOURS["accent"], anchor="w").grid( row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 2)) badge_row += 1 ct = site.get("check_type") or "daily" ct_text = "📅 Daily" if ct == "daily" else "🗓 Weekly" ct_colour = COLOURS["text_dim"] if ct == "daily" else COLOURS["warning"] tk.Label(card, text=ct_text, font=FONT_SMALL, bg=COLOURS["surface"], fg=ct_colour, anchor="w").grid( row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 4)) # User note if any if site.get("user_note"): tk.Label(card, text=f"Your note: {site['user_note']}", font=FONT_SMALL, bg=COLOURS["surface"], fg=COLOURS["warning"], anchor="w", wraplength=350).grid( row=badge_row + 1, column=2, columnspan=2, sticky="ew", padx=4, pady=(0, 6)) # ── Action buttons ───────────────────────────────────────────────────── btn_frame = tk.Frame(card, bg=COLOURS["surface"]) btn_frame.grid(row=0, column=3, rowspan=4, padx=(8, 12), pady=8, sticky="ns") check_text = "✔ Checked" if is_checked else "✔ Mark Checked" check_bg = COLOURS["surface2"] if is_checked else COLOURS["success"] tk.Button( btn_frame, text=check_text, command=lambda s=site: self._mark_checked(s), bg=check_bg, fg=COLOURS["white"], activebackground=COLOURS["success"], activeforeground=COLOURS["white"], relief="flat", font=FONT_BOLD, cursor="hand2", padx=10, pady=6, ).pack(pady=(0, 6)) tk.Button( btn_frame, text="📝 Add Note", command=lambda s=site: self._open_note_dialog(s), bg=COLOURS["surface2"], fg=COLOURS["text"], activebackground=COLOURS["surface2"], activeforeground=COLOURS["accent"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).pack(pady=(0, 6)) # 🔑 Credentials button — only shown if this site has saved credentials try: from models import get_website_credentials creds = get_website_credentials(wid) except Exception: creds = [] if creds: tk.Button( btn_frame, text="🔑 Credentials", command=lambda s=site, c=creds: CredentialsPopup(self, s["name"], c), bg=COLOURS["surface2"], fg=COLOURS["text"], activebackground=COLOURS["accent"], activeforeground=COLOURS["white"], relief="flat", font=FONT_SMALL, cursor="hand2", padx=10, pady=4, ).pack() # ─── Bulk Actions ───────────────────────────────────────────────────────── def _select_all_unchecked(self): """Tick checkboxes for all currently visible unchecked sites.""" visible_ids = { s["id"] for s in self._all_sites if not s["check_id"] and (not self._search_var.get().strip() or self._search_var.get().strip().lower() in s["name"].lower() or self._search_var.get().strip().lower() in (s["url"] or "").lower()) } for wid, var in self._site_check_vars.items(): if wid in visible_ids: var.set(True) def _bulk_check(self): """Mark all ticked sites as checked in a single operation.""" selected = [wid for wid, var in self._site_check_vars.items() if var.get()] if not selected: show_error("No sites selected. Tick the checkboxes first.") return from models import mark_website_checked errors = [] for wid in selected: try: mark_website_checked(self.current_user["id"], wid) except Exception as e: errors.append(str(e)) if errors: show_error(f"Some sites could not be marked:\n" + "\n".join(errors)) else: show_info(f"{len(selected)} site(s) marked as checked.") logger.info(f"Bulk check: user {self.current_user['username']} " f"marked {len(selected)} sites.") self._load() # ─── Health Pre-check ───────────────────────────────────────────────────── def _check_site_health(self, wid: int, url: str, dot_label: tk.Label): """Background thread: HEAD request to url; updates health_cache + dot colour.""" import urllib.request import time if not url.startswith(("http://", "https://")): url = "https://" + url try: req = urllib.request.Request(url, method="HEAD") req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe") t0 = time.monotonic() urllib.request.urlopen(req, timeout=6) ms = int((time.monotonic() - t0) * 1000) status = "slow" if ms > 3000 else "ok" except Exception: ms = 0 status = "down" self._health_cache[wid] = (status, ms) col = {"ok": COLOURS["success"], "slow": COLOURS["warning"], "down": COLOURS["danger"]}.get(status, COLOURS["text_dim"]) # Update the dot on the main thread try: dot_label.after(0, lambda: dot_label.config(fg=col)) except Exception: pass def _show_tooltip(self, event, text: str): x = event.widget.winfo_rootx() + 20 y = event.widget.winfo_rooty() + 20 self._tooltip = tk.Toplevel() self._tooltip.overrideredirect(True) self._tooltip.geometry(f"+{x}+{y}") tk.Label(self._tooltip, text=text, bg=COLOURS["surface2"], fg=COLOURS["text"], relief="flat", padx=8, pady=4, font=FONT_SMALL).pack() def _hide_tooltip(self, event=None): if hasattr(self, "_tooltip"): try: self._tooltip.destroy() except Exception: pass # ─── Keyboard Shortcuts ──────────────────────────────────────────────────── def _bind_shortcuts(self): """Bind keyboard shortcuts to this frame only (not globally).""" self._shortcut_ids = [] bindings = [ ("", lambda e: self._load()), ("", lambda e: self._focus_search()), ("", lambda e: self._clear_search()), ("", lambda e: self._select_all_unchecked()), ("", lambda e: self._bulk_check()), ] for seq, cmd in bindings: bid = self.bind(seq, cmd) self._shortcut_ids.append((seq, bid)) def _unbind_shortcuts(self): """Remove all shortcut bindings — called from destroy().""" for seq, bid in getattr(self, "_shortcut_ids", []): try: self.unbind(seq, bid) except Exception: pass self._shortcut_ids = [] def _focus_search(self): """Ctrl+F — focus the search entry.""" try: # Walk widget tree to find the search entry for w in self.winfo_children(): for child in w.winfo_children(): if isinstance(child, tk.Entry): child.focus_set() return except Exception: pass def _clear_search(self): """Escape — clear the search box.""" self._search_var.set("") # ─── Individual Actions ──────────────────────────────────────────────────── def _open_site(self, site: dict): url = site["url"] if not url.startswith(("http://", "https://")): url = "https://" + url try: webbrowser.open(url) logger.info(f"User {self.current_user['username']} opened URL: {url}") except Exception as e: show_error(f"Could not open URL:\n{e}") def _mark_checked(self, site: dict): try: from models import mark_website_checked mark_website_checked(self.current_user["id"], site["id"]) logger.info( f"User {self.current_user['username']} checked '{site['name']}'.") self._load() except Exception as e: show_error(f"Could not mark as checked:\n{e}") def _open_note_dialog(self, site: dict): NoteDialog(self, self.current_user, site, on_save=self._load) # ─── Shift-end Notifications ────────────────────────────────────────────── def _schedule_notifications(self): """Check every 60 s whether any shift end-time triggers a reminder.""" if self._notify_job: try: self.after_cancel(self._notify_job) except Exception: pass self._check_notifications() self._notify_job = self.after(60_000, self._schedule_notifications) def _check_notifications(self): """Fire a desktop notification if a shift ends within NOTIFY_MINUTES_BEFORE.""" from models import get_unchecked_sites_for_user import datetime as dt try: unchecked = get_unchecked_sites_for_user(self.current_user["id"]) except Exception: return now = dt.datetime.now().time() for site in unchecked: end_time = site.get("end_time") if not end_time: continue # MySQL returns timedelta for TIME columns if hasattr(end_time, "total_seconds"): total_secs = int(end_time.total_seconds()) end_h, rem = divmod(total_secs, 3600) end_m = rem // 60 end_t = dt.time(end_h % 24, end_m) else: try: end_t = dt.time.fromisoformat(str(end_time)[:5]) except Exception: continue # Compute minutes until shift end now_mins = now.hour * 60 + now.minute end_mins = end_t.hour * 60 + end_t.minute diff = end_mins - now_mins key = (site["id"], end_t) if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites: self._notified_sites.add(key) self._fire_notification(site["name"], diff, end_t) def _fire_notification(self, site_name: str, minutes_left: int, end_time): title = "Shift Reminder" message = (f"'{site_name}' is unchecked — " f"shift ends at {end_time.strftime('%H:%M')} " f"({minutes_left} min remaining).") logger.info(f"Notification: {message}") # Try plyer desktop notification; fall back to Tkinter toast notified = False try: from plyer import notification notification.notify( title=title, message=message, app_name="Website Checker", timeout=10, ) notified = True except Exception: pass if not notified: self._show_toast(title, message) def _show_toast(self, title: str, message: str): """Fallback in-app toast notification.""" try: toast = tk.Toplevel(self) toast.overrideredirect(True) toast.attributes("-topmost", True) toast.configure(bg=COLOURS["warning"]) tk.Label(toast, text=title, font=FONT_BOLD, bg=COLOURS["warning"], fg=COLOURS["white"]).pack( padx=16, pady=(10, 2)) tk.Label(toast, text=message, font=FONT_SMALL, bg=COLOURS["warning"], fg=COLOURS["white"], wraplength=300, justify="left").pack( padx=16, pady=(0, 10)) # Position bottom-right toast.update_idletasks() sw = toast.winfo_screenwidth() sh = toast.winfo_screenheight() tw = toast.winfo_reqwidth() th = toast.winfo_reqheight() toast.geometry(f"+{sw - tw - 20}+{sh - th - 60}") # Auto-dismiss after 8 seconds toast.after(8000, toast.destroy) except Exception as e: logger.warning(f"Toast notification failed: {e}") # ─── Credentials Popup ──────────────────────────────────────────────────────── class CredentialsPopup(tk.Toplevel): def __init__(self, parent, site_name, credentials: list): super().__init__(parent) self.title(f"Credentials — {site_name}") self.configure(bg=COLOURS["bg"]) self.resizable(False, False) self.grab_set() ttk.Label(self, text=f"Login credentials for {site_name}", style="Heading.TLabel").pack(padx=20, pady=(16, 8)) for cred in credentials: frame = ttk.Frame(self, style="Surface.TFrame") frame.pack(fill="x", padx=20, pady=4, ipady=6) label = cred.get("label") or "Default" tk.Label(frame, text=label, font=FONT_BOLD, bg=COLOURS["surface"], fg=COLOURS["accent"]).grid( row=0, column=0, columnspan=6, sticky="w", padx=10, pady=(4, 2)) # ── Username row ────────────────────────────────────────────────── tk.Label(frame, text="Username:", bg=COLOURS["surface"], fg=COLOURS["text_dim"]).grid( row=1, column=0, sticky="w", padx=(10, 4)) tk.Label(frame, text=cred["username"], bg=COLOURS["surface"], fg=COLOURS["text"], font=FONT_BOLD).grid( row=1, column=1, sticky="w", padx=(0, 8)) user_copy_btn = tk.Button( frame, text="📋", width=3, bg=COLOURS["surface2"], fg=COLOURS["text_dim"], activebackground=COLOURS["accent"], activeforeground=COLOURS["white"], relief="flat", cursor="hand2", font=FONT_SMALL, ) user_copy_btn.grid(row=1, column=2, padx=(0, 16)) def _copy_user(c=cred, b=user_copy_btn): self.clipboard_clear() self.clipboard_append(c["username"]) b.config(text="✔", fg=COLOURS["success"]) self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"])) user_copy_btn.config(command=_copy_user) # ── Password row ────────────────────────────────────────────────── tk.Label(frame, text="Password:", bg=COLOURS["surface"], fg=COLOURS["text_dim"]).grid( row=1, column=3, sticky="w", padx=(0, 4)) pw_var = tk.StringVar(value="••••••••") tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"], fg=COLOURS["text"], font=FONT_BOLD).grid( row=1, column=4, sticky="w", padx=(0, 4)) revealed = [False] def toggle(c=cred, v=pw_var, r=revealed): r[0] = not r[0] v.set(c["password"] if r[0] else "••••••••") tk.Button(frame, text="👁", command=toggle, bg=COLOURS["surface"], fg=COLOURS["text_dim"], relief="flat", cursor="hand2").grid( row=1, column=5, padx=(0, 4)) pw_copy_btn = tk.Button( frame, text="📋", width=3, bg=COLOURS["surface2"], fg=COLOURS["text_dim"], activebackground=COLOURS["accent"], activeforeground=COLOURS["white"], relief="flat", cursor="hand2", font=FONT_SMALL, ) pw_copy_btn.grid(row=1, column=6, padx=(0, 10)) def _copy_pw(c=cred, b=pw_copy_btn): self.clipboard_clear() self.clipboard_append(c["password"]) b.config(text="✔", fg=COLOURS["success"]) # Auto-clear clipboard after 15 s for security self.after(15_000, self.clipboard_clear) self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"])) pw_copy_btn.config(command=_copy_pw) ttk.Button(self, text="Close", command=self.destroy).pack(pady=16) self._centre() def _centre(self): self.update_idletasks() w = self.winfo_reqwidth() + 40 h = self.winfo_reqheight() + 20 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") # ─── Note Dialog ────────────────────────────────────────────────────────────── class NoteDialog(tk.Toplevel): def __init__(self, parent, current_user, site, on_save): super().__init__(parent) self.current_user = current_user self.site = site self.on_save = on_save self.title(f"Note — {site['name']}") self.configure(bg=COLOURS["bg"]) self.resizable(False, False) self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() w, h = 460, 280 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") def _build_ui(self): ttk.Label(self, text=f"Note for: {self.site['name']}", style="Heading.TLabel").pack( padx=20, pady=(16, 8), anchor="w") frame, self.note_txt = scrolled_text(self, height=6, width=50) frame.pack(padx=20, pady=4, fill="x") if self.site.get("user_note"): self.note_txt.insert("1.0", self.site["user_note"]) btn_frame = ttk.Frame(self) btn_frame.pack(fill="x", padx=20, pady=(12, 16)) ttk.Button(btn_frame, text="Save Note", command=self._save).pack(side="right", padx=(6, 0)) ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", command=self.destroy).pack(side="right") def _save(self): note = self.note_txt.get("1.0", "end-1c").strip() try: if self.site.get("check_id"): from models import update_check_note update_check_note(self.current_user["id"], self.site["id"], note) else: from models import mark_website_checked mark_website_checked(self.current_user["id"], self.site["id"], note) logger.info(f"Note saved for '{self.site['name']}' " f"by {self.current_user['username']}.") show_info("Note saved successfully.") self.on_save() self.destroy() except Exception as e: show_error(f"Failed to save note:\n{e}")