821 lines
35 KiB
Python
821 lines
35 KiB
Python
"""
|
|
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"],
|
|
"restricted": COLOURS["warning"],
|
|
"down": COLOURS["danger"],
|
|
}.get(status_str, COLOURS["text_dim"])
|
|
dot_tip = {
|
|
"ok": f"Reachable ({ms}ms)",
|
|
"slow": f"Slow ({ms}ms)",
|
|
"restricted": f"Reachable — access restricted ({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(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.
|
|
|
|
Status semantics:
|
|
ok — 2xx / 3xx response, <= 3 000 ms
|
|
slow — 2xx / 3xx response, > 3 000 ms
|
|
restricted — 4xx response (server reachable but denying HEAD access)
|
|
down — network error, timeout, or 5xx (server not responding)
|
|
|
|
4xx responses are intentionally treated as "restricted" (amber) rather
|
|
than "down" (red) because the server is clearly reachable — many sites
|
|
block unauthenticated HEAD requests with 401/403 even when fully
|
|
operational.
|
|
"""
|
|
import urllib.request
|
|
import urllib.error
|
|
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 urllib.error.HTTPError as e:
|
|
ms = int((time.monotonic() - t0) * 1000)
|
|
if 400 <= e.code < 500:
|
|
# Server responded — it is reachable but blocking this probe
|
|
status = "restricted"
|
|
else:
|
|
# 5xx: server error — treat as down
|
|
ms = 0
|
|
status = "down"
|
|
except Exception:
|
|
ms = 0
|
|
status = "down"
|
|
|
|
self._health_cache[wid] = (status, ms)
|
|
col = {
|
|
"ok": COLOURS["success"],
|
|
"slow": COLOURS["warning"],
|
|
"restricted": 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}")
|
|
|
|
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}")
|