04/21 Fisrt commit
This commit is contained in:
@@ -0,0 +1,743 @@
|
||||
"""
|
||||
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("<MouseWheel>")
|
||||
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(
|
||||
"<Configure>",
|
||||
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("<Configure>", self._on_canvas_resize)
|
||||
|
||||
# Scope mousewheel to the canvas and its children via Enter/Leave
|
||||
self.canvas.bind("<Enter>", self._on_canvas_enter)
|
||||
self.canvas.bind("<Leave>", 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(
|
||||
"<MouseWheel>", self._on_mousewheel
|
||||
)
|
||||
|
||||
def _on_canvas_leave(self, event):
|
||||
"""Mouse left canvas area — deactivate mousewheel scrolling."""
|
||||
try:
|
||||
self.canvas.unbind_all("<MouseWheel>")
|
||||
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("<Enter>", lambda e, t=dot_tip: self._show_tooltip(e, t))
|
||||
health_dot.bind("<Leave>", 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("<Button-1>", 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("<Button-1>", 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()
|
||||
|
||||
# ─── 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 = [
|
||||
("<F5>", lambda e: self._load()),
|
||||
("<Control-f>", lambda e: self._focus_search()),
|
||||
("<Escape>", lambda e: self._clear_search()),
|
||||
("<Control-a>", lambda e: self._select_all_unchecked()),
|
||||
("<Control-Return>", 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}")
|
||||
|
||||
from models import get_website_credentials
|
||||
creds = get_website_credentials(site["id"])
|
||||
if creds:
|
||||
CredentialsPopup(self, site["name"], creds)
|
||||
|
||||
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=4, sticky="w", padx=10, pady=(4, 2))
|
||||
|
||||
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, 20))
|
||||
|
||||
tk.Label(frame, text="Password:", bg=COLOURS["surface"],
|
||||
fg=COLOURS["text_dim"]).grid(
|
||||
row=1, column=2, 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=3, sticky="w")
|
||||
|
||||
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=4, padx=(8, 10))
|
||||
|
||||
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}")
|
||||
Reference in New Issue
Block a user