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
+1
View File
@@ -0,0 +1 @@
# views package
+267
View File
@@ -0,0 +1,267 @@
"""
views/admin_dashboard_view.py — Admin home screen.
Shows today's completion stats at-a-glance:
- Headline KPI cards (total users, active today, total sites)
- Per-user completion table with inline progress bar
- Auto-refreshes every 60 seconds
"""
import tkinter as tk
from tkinter import ttk
import logging
from datetime import date
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, FONT_TITLE,
show_error,
)
logger = logging.getLogger("admin_dashboard_view")
REFRESH_INTERVAL_MS = 60_000 # auto-refresh every 60 seconds
class AdminDashboardView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._refresh_job = None
self._build_ui()
self._load()
def destroy(self):
"""Cancel the auto-refresh job and mousewheel binding when destroyed."""
if self._refresh_job:
try:
self.after_cancel(self._refresh_job)
except Exception:
pass
try:
self._canvas.unbind_all("<MouseWheel>")
except Exception:
pass
super().destroy()
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
# ── Header ────────────────────────────────────────────────────────────
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 16))
ttk.Label(hdr, text="Dashboard",
style="Heading.TLabel").pack(side="left")
self._date_lbl = ttk.Label(
hdr,
text=date.today().strftime("%A, %d %B %Y"),
style="Dim.TLabel"
)
self._date_lbl.pack(side="right", padx=(0, 4))
ttk.Button(hdr, text="↻ Refresh", style="Ghost.TButton",
command=self._load).pack(side="right", padx=(0, 8))
# ── KPI cards row ─────────────────────────────────────────────────────
self._kpi_frame = tk.Frame(self, bg=COLOURS["bg"])
self._kpi_frame.pack(fill="x", pady=(0, 20))
# ── User completion table ─────────────────────────────────────────────
ttk.Label(self, text="Today's Progress by User",
style="Heading.TLabel").pack(anchor="w", pady=(0, 8))
# Scrollable table frame
table_outer = ttk.Frame(self)
table_outer.pack(fill="both", expand=True)
self._canvas = tk.Canvas(table_outer, bg=COLOURS["bg"],
highlightthickness=0)
vsb = ttk.Scrollbar(table_outer, 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._table_frame = ttk.Frame(self._canvas)
self._table_frame.bind(
"<Configure>",
lambda e: self._canvas.configure(
scrollregion=self._canvas.bbox("all"))
)
self._canvas_win = self._canvas.create_window(
(0, 0), window=self._table_frame, anchor="nw"
)
self._canvas.bind(
"<Configure>",
lambda e: self._canvas.itemconfig(self._canvas_win, width=e.width)
)
self._canvas.bind("<Enter>", self._on_canvas_enter)
self._canvas.bind("<Leave>", self._on_canvas_leave)
def _on_canvas_enter(self, event):
self._canvas.bind_all(
"<MouseWheel>",
lambda e: self._safe_scroll(e)
)
def _on_canvas_leave(self, event):
try:
self._canvas.unbind_all("<MouseWheel>")
except Exception:
pass
def _safe_scroll(self, event):
try:
self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
except Exception:
pass
# ─── Data ─────────────────────────────────────────────────────────────────
def _load(self):
# Cancel any pending auto-refresh before issuing a new one
if self._refresh_job:
try:
self.after_cancel(self._refresh_job)
except Exception:
pass
try:
from models import get_admin_dashboard_stats
stats = get_admin_dashboard_stats()
except Exception as e:
show_error(f"Failed to load dashboard:\n{e}")
return
self._render_kpis(stats)
self._render_table(stats["user_stats"])
# Schedule next auto-refresh
self._refresh_job = self.after(REFRESH_INTERVAL_MS, self._load)
logger.info("Admin dashboard refreshed.")
# ─── KPI Cards ────────────────────────────────────────────────────────────
def _render_kpis(self, stats: dict):
for w in self._kpi_frame.winfo_children():
w.destroy()
total_users = stats["total_users"]
active_today = stats["active_today"]
total_sites = stats["total_sites"]
# Compute overall completion %
user_stats = stats["user_stats"]
if user_stats:
avg_pct = sum(
(r["pct_complete"] or 0) for r in user_stats
) / len(user_stats)
else:
avg_pct = 0
cards = [
("Total Users", str(total_users), COLOURS["accent"]),
("Active Today", str(active_today), COLOURS["success"]),
("Monitored Sites", str(total_sites), COLOURS["warning"]),
("Avg Completion", f"{avg_pct:.0f}%", COLOURS["accent"]),
]
self._kpi_frame.columnconfigure(list(range(len(cards))), weight=1)
for col, (label, value, colour) in enumerate(cards):
card = tk.Frame(self._kpi_frame, bg=COLOURS["surface"],
padx=20, pady=16)
card.grid(row=0, column=col, padx=(0, 12) if col < len(cards)-1 else 0,
sticky="ew")
tk.Label(card, text=value,
font=(FONT_BOLD[0], 26, "bold"),
bg=COLOURS["surface"],
fg=colour).pack()
tk.Label(card, text=label,
font=FONT_SMALL,
bg=COLOURS["surface"],
fg=COLOURS["text_dim"]).pack()
# ─── User Table ───────────────────────────────────────────────────────────
def _render_table(self, user_stats: list):
for w in self._table_frame.winfo_children():
w.destroy()
# Column headers
headers = ["User", "Full Name", "Checked", "Total", "Completion"]
col_weights = [1, 2, 0, 0, 3]
header_frame = tk.Frame(self._table_frame, bg=COLOURS["surface2"])
header_frame.pack(fill="x", pady=(0, 2))
for col, (hdr, wt) in enumerate(zip(headers, col_weights)):
header_frame.columnconfigure(col, weight=wt, minsize=80)
tk.Label(header_frame, text=hdr,
bg=COLOURS["surface2"], fg=COLOURS["accent"],
font=FONT_BOLD, anchor="w",
padx=12, pady=8).grid(
row=0, column=col, sticky="ew")
if not user_stats:
tk.Label(self._table_frame,
text="No active users with shifts today.",
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
font=FONT_SMALL).pack(pady=20)
return
for i, row in enumerate(user_stats):
bg = COLOURS["surface"] if i % 2 == 0 else COLOURS["surface2"]
row_frame = tk.Frame(self._table_frame, bg=bg)
row_frame.pack(fill="x")
pct = float(row["pct_complete"] or 0)
checked = int(row["checked_count"] or 0)
total = int(row["total_sites"] or 0)
# Colour: green if 100%, amber if >0%, red if 0%
if pct >= 100:
pct_colour = COLOURS["success"]
elif pct > 0:
pct_colour = COLOURS["warning"]
else:
pct_colour = COLOURS["danger"]
for col, (wt, content) in enumerate(zip(col_weights, [
row["username"],
row["full_name"] or "",
str(checked),
str(total),
None, # progress bar column
])):
row_frame.columnconfigure(col, weight=wt, minsize=80)
if content is not None:
tk.Label(row_frame, text=content,
bg=bg, fg=COLOURS["text"],
font=FONT, anchor="w",
padx=12, pady=10).grid(
row=0, column=col, sticky="ew")
else:
# Progress bar + percentage label
bar_cell = tk.Frame(row_frame, bg=bg, padx=12, pady=8)
bar_cell.grid(row=0, column=col, sticky="ew")
bar_cell.columnconfigure(0, weight=1)
# Outer track
track = tk.Frame(bar_cell, bg=COLOURS["border"],
height=14)
track.pack(fill="x", side="left", expand=True)
track.pack_propagate(False)
# Fill — drawn after geometry update
fill = tk.Frame(track, bg=pct_colour, height=14)
fill.place(relx=0, rely=0,
relwidth=min(pct / 100, 1.0), relheight=1.0)
tk.Label(bar_cell,
text=f"{pct:.0f}%",
bg=bg, fg=pct_colour,
font=FONT_BOLD,
width=5, anchor="e").pack(side="left", padx=(6, 0))
+50
View File
@@ -0,0 +1,50 @@
"""
views/admin_log_view.py — Admin panel: Activity Log tab.
"""
import tkinter as tk
from tkinter import ttk
from utils.ui_helpers import COLOURS, FONT_HEADING, show_error
class AdminLogView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._build_ui()
self._load()
def _build_ui(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", pady=(0, 10))
ttk.Label(toolbar, text="Activity Log", style="Heading.TLabel").pack(side="left")
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
command=self._load).pack(side="right")
cols = ("Time", "User", "Action", "Entity", "Entity ID", "Detail")
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse")
widths = [140, 100, 130, 100, 70, 320]
for col, w in zip(cols, widths):
self.tree.heading(col, text=col)
self.tree.column(col, width=w, anchor="w")
self.tree.pack(fill="both", expand=True)
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
vsb.place(relx=1, rely=0, relheight=1, anchor="ne")
def _load(self):
from models import get_activity_log
self.tree.delete(*self.tree.get_children())
try:
for entry in get_activity_log():
self.tree.insert("", "end", values=(
str(entry["logged_at"])[:16],
entry.get("username") or "",
entry["action"],
entry.get("entity") or "",
entry.get("entity_id") or "",
entry.get("detail") or "",
))
except Exception as e:
show_error(f"Failed to load activity log:\n{e}")
+587
View File
@@ -0,0 +1,587 @@
"""
views/admin_shifts_view.py — Admin panel: Shift Management.
Allows admins to:
• Create / edit / soft-delete shifts
• Name each shift and add an optional note
• Set the days of the week the shift runs
• Set a start time and end time
• Assign one or more users to the shift
• Assign one or more websites to the shift (with drag-reorder)
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
scrolled_text, show_error, show_info, confirm_delete,
)
logger = logging.getLogger("admin_shifts_view")
# Day labels: index 0 = Monday (ISO), stored as MySQL DAYOFWEEK digits
# MySQL: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat
DAY_MAP = [
("Mon", "2"), ("Tue", "3"), ("Wed", "4"),
("Thu", "5"), ("Fri", "6"), ("Sat", "7"), ("Sun", "1"),
]
class AdminShiftsView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._build_ui()
self._load_shifts()
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", pady=(0, 10))
ttk.Label(toolbar, text="Shift Management",
style="Heading.TLabel").pack(side="left")
ttk.Button(toolbar, text=" New Shift",
command=self._open_add).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✎ Edit",
style="Ghost.TButton",
command=self._open_edit).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton",
command=self._delete_selected).pack(side="right")
cols = ("ID", "Shift Name", "Days", "Start", "End",
"Users", "Websites", "Active", "Note")
self.tree = ttk.Treeview(self, columns=cols,
show="headings", selectmode="browse")
widths = [40, 160, 130, 70, 70, 60, 70, 60, 200]
for col, w in zip(cols, widths):
self.tree.heading(col, text=col)
self.tree.column(col, width=w,
anchor="center" if w <= 70 else "w")
self.tree.pack(fill="both", expand=True)
self.tree.bind("<Double-1>", lambda _: self._open_edit())
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
vsb.place(relx=1, rely=0, relheight=1, anchor="ne")
# Tag active vs inactive rows
self.tree.tag_configure("inactive", foreground=COLOURS["text_dim"])
# ─── Data ─────────────────────────────────────────────────────────────────
def _load_shifts(self):
from models import get_all_shifts
self.tree.delete(*self.tree.get_children())
try:
for s in get_all_shifts():
days_str = _days_label(s["days_of_week"])
active = "" if s["is_active"] else ""
tag = "active" if s["is_active"] else "inactive"
self.tree.insert(
"", "end", iid=str(s["id"]), tags=(tag,),
values=(
s["id"],
s["name"],
days_str,
str(s["start_time"]),
str(s["end_time"]),
s["user_count"],
s["website_count"],
active,
(s["note"] or "")[:60],
)
)
except Exception as e:
show_error(f"Failed to load shifts:\n{e}")
def _get_selected_id(self):
sel = self.tree.selection()
return int(sel[0]) if sel else None
# ─── Actions ──────────────────────────────────────────────────────────────
def _open_add(self):
ShiftDialog(self, self.current_user, shift_data=None,
on_save=self._load_shifts)
def _open_edit(self):
sid = self._get_selected_id()
if not sid:
show_error("Please select a shift to edit.")
return
from models import (get_shift_by_id, get_shift_assigned_users,
get_shift_assigned_websites)
data = get_shift_by_id(sid)
data["users"] = get_shift_assigned_users(sid)
data["websites"] = get_shift_assigned_websites(sid)
ShiftDialog(self, self.current_user, shift_data=data,
on_save=self._load_shifts)
def _delete_selected(self):
sid = self._get_selected_id()
if not sid:
show_error("Please select a shift to delete.")
return
vals = self.tree.item(sid, "values")
name = vals[1] if vals else str(sid)
if confirm_delete(name):
try:
from models import delete_shift
delete_shift(self.current_user["id"], sid)
logger.info(f"Shift id={sid} deleted by admin "
f"{self.current_user['username']}.")
show_info(f"Shift '{name}' deactivated successfully.")
self._load_shifts()
except Exception as e:
show_error(f"Delete failed:\n{e}")
# ─── Shift Dialog (Add / Edit) ────────────────────────────────────────────────
class ShiftDialog(tk.Toplevel):
def __init__(self, parent, current_user, shift_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.shift_data = shift_data
self.on_save = on_save
self.is_edit = shift_data is not None
self.title("Edit Shift" if self.is_edit else "New Shift")
self.configure(bg=COLOURS["bg"])
self.grab_set()
self.resizable(True, True)
self._all_users = []
self._all_websites = []
self._load_options()
self._build_ui()
self._centre()
if self.is_edit:
self._populate()
def _centre(self):
self.update_idletasks()
w, h = 860, 680
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _load_options(self):
from models import get_all_users, get_all_websites
try:
self._all_users = [u for u in get_all_users()
if u["is_active"] and u["role"] == "user"]
self._all_websites = [w for w in get_all_websites()]
except Exception as e:
show_error(f"Failed to load options:\n{e}")
# ─── UI ───────────────────────────────────────────────────────────────────
def _build_ui(self):
# ── Header ────────────────────────────────────────────────────────────
ttk.Label(self,
text="Edit Shift" if self.is_edit else "New Shift",
style="Heading.TLabel").pack(anchor="w", padx=20, pady=(16, 4))
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=20, pady=(0, 10))
# ── Main body (left details | right assignment panels) ────────────────
body = ttk.Frame(self)
body.pack(fill="both", expand=True, padx=20)
body.columnconfigure(0, weight=0, minsize=280)
body.columnconfigure(1, weight=1)
body.rowconfigure(0, weight=1)
self._build_details_panel(body)
self._build_assignment_panel(body)
# ── Bottom buttons ────────────────────────────────────────────────────
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=20, pady=(10, 6))
btn_row = ttk.Frame(self)
btn_row.pack(fill="x", padx=20, pady=(0, 16))
ttk.Button(btn_row, text="Save Shift",
command=self._save).pack(side="right", padx=(8, 0))
ttk.Button(btn_row, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
def _build_details_panel(self, parent):
"""Left column: name, time, days, active, note."""
pane = tk.Frame(parent, bg=COLOURS["surface"], padx=16, pady=16)
pane.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
pane.columnconfigure(1, weight=1)
ttk.Label(pane, text="Shift Details",
style="Heading.TLabel",
background=COLOURS["surface"]).grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 12))
def field(label, row):
tk.Label(pane, text=label, bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=row, column=0, sticky="w", padx=(0, 8), pady=4)
var = tk.StringVar()
ent = ttk.Entry(pane, textvariable=var)
ent.grid(row=row, column=1, sticky="ew", pady=4)
return var
self.name_var = field("Shift Name *", 1)
self.start_time_var = field("Start Time (HH:MM)", 2)
self.end_time_var = field("End Time (HH:MM)", 3)
# Days of week checkboxes
tk.Label(pane, text="Days of Week", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=4, column=0, sticky="nw", pady=(8, 0))
days_frame = tk.Frame(pane, bg=COLOURS["surface"])
days_frame.grid(row=4, column=1, sticky="w", pady=(8, 0))
self._day_vars = {}
for day_lbl, digit in DAY_MAP:
var = tk.BooleanVar(value=True)
self._day_vars[digit] = var
cb = tk.Checkbutton(
days_frame, text=day_lbl, variable=var,
bg=COLOURS["surface"], fg=COLOURS["text"],
activebackground=COLOURS["surface"],
activeforeground=COLOURS["accent"],
selectcolor=COLOURS["surface2"],
font=FONT_SMALL, cursor="hand2",
)
cb.pack(side="left", padx=2)
# Active toggle
tk.Label(pane, text="Active", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=5, column=0, sticky="w", pady=8)
self.active_var = tk.BooleanVar(value=True)
tk.Checkbutton(
pane, variable=self.active_var,
bg=COLOURS["surface"], activebackground=COLOURS["surface"],
selectcolor=COLOURS["surface2"],
).grid(row=5, column=1, sticky="w", pady=8)
# Note
tk.Label(pane, text="Note", bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=6, column=0, sticky="nw", pady=4)
note_frame, self.note_txt = scrolled_text(pane, height=5, width=28)
note_frame.grid(row=6, column=1, sticky="ew", pady=4)
def _build_assignment_panel(self, parent):
"""Right column: dual-list pickers for users and websites."""
pane = ttk.Frame(parent)
pane.grid(row=0, column=1, sticky="nsew")
pane.rowconfigure(0, weight=1)
pane.rowconfigure(1, weight=1)
pane.columnconfigure(0, weight=1)
# Users picker
self._user_picker = _DualListPicker(
pane,
title="Assigned Users",
all_items=[(u["id"], f"{u['username']}{u['full_name'] or ''}",)
for u in self._all_users],
)
self._user_picker.grid(row=0, column=0, sticky="nsew", pady=(0, 8))
# Websites picker
self._site_picker = _DualListPicker(
pane,
title="Assigned Websites",
all_items=[(w["id"], w["name"]) for w in self._all_websites],
allow_reorder=True,
)
self._site_picker.grid(row=1, column=0, sticky="nsew")
# ─── Pre-populate ─────────────────────────────────────────────────────────
def _populate(self):
d = self.shift_data
self.name_var.set(d.get("name") or "")
# Normalise timedelta → HH:MM string (MySQL returns timedelta for TIME)
self.start_time_var.set(_time_to_str(d.get("start_time")))
self.end_time_var.set(_time_to_str(d.get("end_time")))
dow = d.get("days_of_week") or "1234567"
for digit, var in self._day_vars.items():
var.set(digit in dow)
self.active_var.set(bool(d.get("is_active", 1)))
self.note_txt.insert("1.0", d.get("note") or "")
assigned_user_ids = [u["id"] for u in d.get("users", [])]
assigned_website_ids = [w["id"] for w in d.get("websites", [])]
self._user_picker.set_selected(assigned_user_ids)
self._site_picker.set_selected(assigned_website_ids)
# ─── Save ─────────────────────────────────────────────────────────────────
def _save(self):
name = self.name_var.get().strip()
start_time = self.start_time_var.get().strip() or "00:00"
end_time = self.end_time_var.get().strip() or "23:59"
note = self.note_txt.get("1.0", "end-1c").strip()
is_active = int(self.active_var.get())
if not name:
show_error("Shift name is required.")
return
if not _valid_time(start_time) or not _valid_time(end_time):
show_error("Times must be in HH:MM format (e.g. 08:00).")
return
days_of_week = "".join(d for d, v in self._day_vars.items() if v.get())
if not days_of_week:
show_error("Please select at least one day of the week.")
return
user_ids = self._user_picker.get_selected_ids()
website_ids = self._site_picker.get_selected_ids()
try:
if self.is_edit:
from models import update_shift
update_shift(
self.current_user["id"],
self.shift_data["id"],
name, days_of_week, start_time, end_time,
note, is_active, user_ids, website_ids,
)
logger.info(f"Shift id={self.shift_data['id']} updated "
f"by {self.current_user['username']}.")
show_info("Shift updated successfully.")
else:
from models import create_shift
create_shift(
self.current_user["id"],
name, days_of_week, start_time, end_time,
note, user_ids, website_ids,
)
logger.info(f"New shift '{name}' created "
f"by {self.current_user['username']}.")
show_info("Shift created successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")
# ─── Dual-List Picker Widget ──────────────────────────────────────────────────
class _DualListPicker(ttk.Frame):
"""
A reusable dual-listbox: Available (left) ↔ Assigned (right).
Supports optional drag-to-reorder on the assigned list.
"""
def __init__(self, parent, title: str, all_items: list,
allow_reorder=False):
super().__init__(parent)
self._all_items = all_items # [(id, label), ...]
self._allow_reorder = allow_reorder
self._drag_start = None
self._build(title)
def _build(self, title: str):
self.columnconfigure(0, weight=1)
self.columnconfigure(2, weight=1)
self.rowconfigure(1, weight=1)
ttk.Label(self, text=title,
style="Heading.TLabel").grid(
row=0, column=0, columnspan=3, sticky="w", pady=(4, 6))
# ── Available list ────────────────────────────────────────────────────
avail_frame = tk.Frame(self, bg=COLOURS["surface"])
avail_frame.grid(row=1, column=0, sticky="nsew")
tk.Label(avail_frame, text="Available",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).pack(anchor="w", padx=4)
av_lb_frame = tk.Frame(avail_frame, bg=COLOURS["surface"])
av_lb_frame.pack(fill="both", expand=True)
av_sb = tk.Scrollbar(av_lb_frame, bg=COLOURS["surface2"])
av_sb.pack(side="right", fill="y")
self._avail_lb = tk.Listbox(
av_lb_frame, selectmode="extended", height=8,
bg=COLOURS["surface2"], fg=COLOURS["text"],
selectbackground=COLOURS["accent"], selectforeground=COLOURS["white"],
relief="flat", font=FONT, activestyle="none",
yscrollcommand=av_sb.set,
)
self._avail_lb.pack(side="left", fill="both", expand=True)
av_sb.config(command=self._avail_lb.yview)
self._avail_lb.bind("<Double-Button-1>", lambda _: self._add())
# ── Arrow buttons ─────────────────────────────────────────────────────
btn_col = tk.Frame(self, bg=COLOURS["bg"])
btn_col.grid(row=1, column=1, padx=6)
def arrow_btn(text, cmd):
return tk.Button(
btn_col, text=text, command=cmd,
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD,
cursor="hand2", width=4, pady=4,
)
arrow_btn("", self._add).pack(pady=4)
arrow_btn("", self._remove).pack(pady=4)
arrow_btn("→→", self._add_all).pack(pady=(12, 4))
arrow_btn("←←", self._remove_all).pack(pady=4)
if self._allow_reorder:
arrow_btn("", self._move_up).pack(pady=(12, 4))
arrow_btn("", self._move_down).pack(pady=4)
# ── Assigned list ─────────────────────────────────────────────────────
assign_frame = tk.Frame(self, bg=COLOURS["surface"])
assign_frame.grid(row=1, column=2, sticky="nsew")
tk.Label(assign_frame, text="Assigned",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).pack(anchor="w", padx=4)
as_lb_frame = tk.Frame(assign_frame, bg=COLOURS["surface"])
as_lb_frame.pack(fill="both", expand=True)
as_sb = tk.Scrollbar(as_lb_frame, bg=COLOURS["surface2"])
as_sb.pack(side="right", fill="y")
self._assign_lb = tk.Listbox(
as_lb_frame, selectmode="extended", height=8,
bg=COLOURS["surface2"], fg=COLOURS["text"],
selectbackground=COLOURS["accent"], selectforeground=COLOURS["white"],
relief="flat", font=FONT, activestyle="none",
yscrollcommand=as_sb.set,
)
self._assign_lb.pack(side="left", fill="both", expand=True)
as_sb.config(command=self._assign_lb.yview)
self._assign_lb.bind("<Double-Button-1>", lambda _: self._remove())
# Internal data: parallel lists of (id, label)
self._avail_data = list(self._all_items)
self._assign_data = []
self._refresh_listboxes()
# ─── Operations ───────────────────────────────────────────────────────────
def _refresh_listboxes(self):
self._avail_lb.delete(0, "end")
for _, lbl in self._avail_data:
self._avail_lb.insert("end", lbl)
self._assign_lb.delete(0, "end")
for _, lbl in self._assign_data:
self._assign_lb.insert("end", lbl)
def _add(self):
sel = list(self._avail_lb.curselection())
if not sel:
return
items = [self._avail_data[i] for i in sel]
for i in reversed(sel):
del self._avail_data[i]
self._assign_data.extend(items)
self._refresh_listboxes()
def _remove(self):
sel = list(self._assign_lb.curselection())
if not sel:
return
items = [self._assign_data[i] for i in sel]
for i in reversed(sel):
del self._assign_data[i]
self._avail_data.extend(items)
self._avail_data.sort(key=lambda x: x[1])
self._refresh_listboxes()
def _add_all(self):
self._assign_data.extend(self._avail_data)
self._avail_data.clear()
self._refresh_listboxes()
def _remove_all(self):
self._avail_data.extend(self._assign_data)
self._assign_data.clear()
self._avail_data.sort(key=lambda x: x[1])
self._refresh_listboxes()
def _move_up(self):
sel = self._assign_lb.curselection()
if not sel or sel[0] == 0:
return
i = sel[0]
self._assign_data[i - 1], self._assign_data[i] = \
self._assign_data[i], self._assign_data[i - 1]
self._refresh_listboxes()
self._assign_lb.selection_set(i - 1)
def _move_down(self):
sel = self._assign_lb.curselection()
if not sel or sel[0] >= len(self._assign_data) - 1:
return
i = sel[0]
self._assign_data[i + 1], self._assign_data[i] = \
self._assign_data[i], self._assign_data[i + 1]
self._refresh_listboxes()
self._assign_lb.selection_set(i + 1)
# ─── Public API ───────────────────────────────────────────────────────────
def set_selected(self, ids: list):
"""Pre-select items by id (called when editing an existing shift)."""
id_set = set(ids)
id_order = {id_: idx for idx, id_ in enumerate(ids)}
remaining = []
selected = []
for item in self._all_items:
if item[0] in id_set:
selected.append(item)
else:
remaining.append(item)
selected.sort(key=lambda x: id_order.get(x[0], 9999))
self._avail_data = remaining
self._assign_data = selected
self._refresh_listboxes()
def get_selected_ids(self) -> list:
return [id_ for id_, _ in self._assign_data]
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _days_label(dow_str: str) -> str:
"""Convert '23456''Mon Tue Wed Thu Fri'."""
digit_to_lbl = {digit: lbl for lbl, digit in DAY_MAP}
# Preserve order Mon-Sun
order = [d for _, d in DAY_MAP]
return " ".join(digit_to_lbl[d] for d in order if d in (dow_str or ""))
def _time_to_str(val) -> str:
"""Normalise MySQL TIME (timedelta or str) to HH:MM string."""
if val is None:
return "00:00"
import datetime
if isinstance(val, datetime.timedelta):
total = int(val.total_seconds())
h, m = divmod(total // 60, 60)
return f"{h:02d}:{m:02d}"
# Already a string or time object
return str(val)[:5]
def _valid_time(s: str) -> bool:
"""Return True if s matches HH:MM."""
parts = s.split(":")
if len(parts) != 2:
return False
try:
h, m = int(parts[0]), int(parts[1])
return 0 <= h <= 23 and 0 <= m <= 59
except ValueError:
return False
+219
View File
@@ -0,0 +1,219 @@
"""
views/admin_users_view.py — Admin panel: User Management tab.
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING,
show_error, show_info, confirm_delete
)
logger = logging.getLogger("admin_users_view")
class AdminUsersView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self.configure(style="TFrame")
self._selected_user_id = None
self._build_ui()
self._load_users()
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
# Top toolbar
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", pady=(0, 10))
ttk.Label(toolbar, text="User Management", style="Heading.TLabel").pack(side="left")
ttk.Button(toolbar, text=" Add User",
command=self._open_add_dialog).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✎ Edit",
style="Ghost.TButton",
command=self._open_edit_dialog).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton",
command=self._delete_selected).pack(side="right")
# Treeview
cols = ("ID", "Username", "Full Name", "Role", "Active", "Created")
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse")
widths = [40, 140, 180, 80, 60, 160]
for col, w in zip(cols, widths):
self.tree.heading(col, text=col)
self.tree.column(col, width=w, anchor="center" if w < 120 else "w")
self.tree.pack(fill="both", expand=True)
self.tree.bind("<Double-1>", lambda _: self._open_edit_dialog())
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
vsb.place(relx=1, rely=0, relheight=1, anchor="ne")
# ─── Data ─────────────────────────────────────────────────────────────────
def _load_users(self):
from models import get_all_users
self.tree.delete(*self.tree.get_children())
try:
for u in get_all_users():
active = "" if u["is_active"] else ""
created = str(u["created_at"])[:16] if u["created_at"] else ""
self.tree.insert("", "end", iid=str(u["id"]),
values=(u["id"], u["username"],
u["full_name"] or "", u["role"],
active, created))
except Exception as e:
show_error(f"Failed to load users:\n{e}")
def _get_selected_id(self):
sel = self.tree.selection()
return int(sel[0]) if sel else None
# ─── Dialogs ──────────────────────────────────────────────────────────────
def _open_add_dialog(self):
UserDialog(self, self.current_user, user_data=None,
on_save=self._load_users)
def _open_edit_dialog(self):
uid = self._get_selected_id()
if not uid:
show_error("Please select a user to edit.")
return
from models import get_user_by_id
user_data = get_user_by_id(uid)
UserDialog(self, self.current_user, user_data=user_data,
on_save=self._load_users)
def _delete_selected(self):
uid = self._get_selected_id()
if not uid:
show_error("Please select a user to delete.")
return
if uid == self.current_user["id"]:
show_error("You cannot delete your own account.")
return
vals = self.tree.item(uid, "values")
username = vals[1] if vals else str(uid)
if confirm_delete(username):
try:
from models import delete_user
delete_user(self.current_user["id"], uid)
logger.info(f"Admin {self.current_user['username']} deleted user id={uid}.")
show_info(f"User '{username}' deleted successfully.")
self._load_users()
except Exception as e:
show_error(f"Delete failed:\n{e}")
# ─── User Dialog (Add / Edit) ─────────────────────────────────────────────────
class UserDialog(tk.Toplevel):
def __init__(self, parent, current_user, user_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.user_data = user_data
self.on_save = on_save
self.is_edit = user_data is not None
self.title("Edit User" if self.is_edit else "Add User")
self.resizable(False, False)
self.configure(bg=COLOURS["bg"])
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 420, 420
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
pad = {"padx": 24, "pady": 8}
ttk.Label(self,
text="Edit User" if self.is_edit else "New User",
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
form = ttk.Frame(self)
form.pack(fill="x", padx=24, pady=8)
form.columnconfigure(1, weight=1)
def row(label, row_n, show=None):
ttk.Label(form, text=label).grid(row=row_n, column=0,
sticky="w", padx=(0, 10), pady=6)
var = tk.StringVar()
ent = ttk.Entry(form, textvariable=var, show=show or "")
ent.grid(row=row_n, column=1, sticky="ew", pady=6)
return var, ent
self.full_name_var, _ = row("Full Name", 0)
self.username_var, _ = row("Username", 1)
self.password_var, _ = row("Password", 2, show="")
pw_hint = "(leave blank to keep unchanged)" if self.is_edit else ""
ttk.Label(form, text=pw_hint, style="Dim.TLabel").grid(
row=3, column=1, sticky="w")
ttk.Label(form, text="Role").grid(row=4, column=0, sticky="w",
padx=(0, 10), pady=6)
self.role_var = tk.StringVar(value="user")
role_cb = ttk.Combobox(form, textvariable=self.role_var,
values=["admin", "user"], state="readonly")
role_cb.grid(row=4, column=1, sticky="ew", pady=6)
ttk.Label(form, text="Active").grid(row=5, column=0, sticky="w",
padx=(0, 10), pady=6)
self.active_var = tk.BooleanVar(value=True)
ttk.Checkbutton(form, variable=self.active_var).grid(row=5, column=1,
sticky="w", pady=6)
if self.is_edit:
d = self.user_data
self.full_name_var.set(d.get("full_name") or "")
self.username_var.set(d.get("username") or "")
self.role_var.set(d.get("role") or "user")
self.active_var.set(bool(d.get("is_active", 1)))
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=24, pady=(16, 20))
ttk.Button(btn_frame, text="Save", 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):
full_name = self.full_name_var.get().strip()
username = self.username_var.get().strip()
password = self.password_var.get()
role = self.role_var.get()
is_active = int(self.active_var.get())
if not username:
show_error("Username is required.")
return
if not self.is_edit and not password:
show_error("Password is required for new users.")
return
try:
if self.is_edit:
from models import update_user
update_user(self.current_user["id"],
self.user_data["id"],
username, role, full_name, is_active,
password if password else None)
logger.info(f"User id={self.user_data['id']} updated by admin.")
show_info("User updated successfully.")
else:
from models import create_user
create_user(self.current_user["id"], username, password, role, full_name)
logger.info(f"New user '{username}' created by admin.")
show_info("User created successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")
+337
View File
@@ -0,0 +1,337 @@
"""
views/admin_websites_view.py — Admin panel: Website Link Management tab.
Changes: added Check Type (Daily / Weekly) field and treeview column.
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
scrolled_text, show_error, show_info, confirm_delete
)
logger = logging.getLogger("admin_websites_view")
CHECK_TYPE_OPTIONS = ["daily", "weekly"]
CHECK_TYPE_LABELS = {"daily": "📅 Daily", "weekly": "🗓 Weekly"}
class AdminWebsitesView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._build_ui()
self._load_websites()
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", pady=(0, 10))
ttk.Label(toolbar, text="Website Link Management",
style="Heading.TLabel").pack(side="left")
ttk.Button(toolbar, text=" Add Website",
command=self._open_add).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✎ Edit",
style="Ghost.TButton",
command=self._open_edit).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton",
command=self._delete_selected).pack(side="right")
cols = ("ID", "Name", "Type", "URL", "Note", "Created By")
widths = [40, 170, 80, 230, 150, 100]
self.tree = ttk.Treeview(self, columns=cols, show="headings",
selectmode="browse")
for col, w in zip(cols, widths):
self.tree.heading(col, text=col)
self.tree.column(col, width=w,
anchor="center" if w <= 80 else "w")
self.tree.pack(fill="both", expand=True)
self.tree.bind("<Double-1>", lambda _: self._open_edit())
# Tag weekly rows with a subtle colour
self.tree.tag_configure("weekly", foreground=COLOURS["warning"])
vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=vsb.set)
vsb.place(relx=1, rely=0, relheight=1, anchor="ne")
# ─── Data ─────────────────────────────────────────────────────────────────
def _load_websites(self):
from models import get_all_websites
self.tree.delete(*self.tree.get_children())
try:
for w in get_all_websites():
ct = w.get("check_type") or "daily"
tag = "weekly" if ct == "weekly" else ""
self.tree.insert(
"", "end", iid=str(w["id"]), tags=(tag,),
values=(
w["id"],
w["name"],
CHECK_TYPE_LABELS.get(ct, ct),
w["url"],
(w["note"] or "")[:60],
w["creator"] or "",
)
)
except Exception as e:
show_error(f"Failed to load websites:\n{e}")
def _get_selected_id(self):
sel = self.tree.selection()
return int(sel[0]) if sel else None
# ─── Actions ──────────────────────────────────────────────────────────────
def _open_add(self):
WebsiteDialog(self, self.current_user, website_data=None,
on_save=self._load_websites)
def _open_edit(self):
wid = self._get_selected_id()
if not wid:
show_error("Please select a website to edit.")
return
from models import get_website_by_id, get_website_credentials
data = get_website_by_id(wid)
creds = get_website_credentials(wid)
data["credentials"] = creds
WebsiteDialog(self, self.current_user, website_data=data,
on_save=self._load_websites)
def _delete_selected(self):
wid = self._get_selected_id()
if not wid:
show_error("Please select a website to delete.")
return
vals = self.tree.item(wid, "values")
name = vals[1] if vals else str(wid)
if confirm_delete(name):
try:
from models import delete_website
delete_website(self.current_user["id"], wid)
logger.info(f"Website id={wid} deleted by admin.")
show_info(f"Website '{name}' deleted.")
self._load_websites()
except Exception as e:
show_error(f"Delete failed:\n{e}")
# ─── Website Dialog (Add / Edit) ──────────────────────────────────────────────
class WebsiteDialog(tk.Toplevel):
def __init__(self, parent, current_user, website_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.website_data = website_data
self.on_save = on_save
self.is_edit = website_data is not None
self.cred_rows = []
self.title("Edit Website" if self.is_edit else "Add Website")
self.configure(bg=COLOURS["bg"])
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 600, 680
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
# Scrollable container
canvas = tk.Canvas(self, bg=COLOURS["bg"], highlightthickness=0)
vsb = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
canvas.pack(fill="both", expand=True)
self.inner = ttk.Frame(canvas)
self.inner.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=self.inner, anchor="nw")
# Scope mousewheel to canvas hover — avoids stale-widget errors on close
def _enter(e):
canvas.bind_all("<MouseWheel>",
lambda ev: _safe_scroll(ev))
def _leave(e):
try:
canvas.unbind_all("<MouseWheel>")
except Exception:
pass
def _safe_scroll(ev):
try:
canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units")
except Exception:
pass
canvas.bind("<Enter>", _enter)
canvas.bind("<Leave>", _leave)
self.protocol("WM_DELETE_WINDOW", lambda: (_leave(None), self.destroy()))
ttk.Label(self.inner,
text="Edit Website" if self.is_edit else "New Website",
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
form = ttk.Frame(self.inner)
form.pack(fill="x", padx=24, pady=4)
form.columnconfigure(1, weight=1)
def text_row(label, r):
ttk.Label(form, text=label).grid(row=r, column=0,
sticky="nw", padx=(0, 10), pady=6)
var = tk.StringVar()
ent = ttk.Entry(form, textvariable=var)
ent.grid(row=r, column=1, sticky="ew", pady=6)
return var
self.name_var = text_row("Name *", 0)
self.url_var = text_row("URL *", 1)
# ── Check Type ────────────────────────────────────────────────────────
ttk.Label(form, text="Check Type").grid(
row=2, column=0, sticky="w", padx=(0, 10), pady=6)
type_frame = tk.Frame(form, bg=COLOURS["bg"])
type_frame.grid(row=2, column=1, sticky="w", pady=6)
self.check_type_var = tk.StringVar(value="daily")
for val, lbl in [("daily", "📅 Daily"), ("weekly", "🗓 Weekly")]:
rb = tk.Radiobutton(
type_frame,
text=lbl,
variable=self.check_type_var,
value=val,
bg=COLOURS["bg"],
fg=COLOURS["text"],
activebackground=COLOURS["bg"],
activeforeground=COLOURS["accent"],
selectcolor=COLOURS["surface2"],
font=FONT,
cursor="hand2",
)
rb.pack(side="left", padx=(0, 16))
# ── Note ──────────────────────────────────────────────────────────────
ttk.Label(form, text="Note").grid(row=3, column=0, sticky="nw",
padx=(0, 10), pady=6)
note_frame, self.note_txt = scrolled_text(form, height=4)
note_frame.grid(row=3, column=1, sticky="ew", pady=6)
# ── Credentials section ───────────────────────────────────────────────
ttk.Separator(self.inner, orient="horizontal").pack(
fill="x", padx=24, pady=12)
cred_header = ttk.Frame(self.inner)
cred_header.pack(fill="x", padx=24)
ttk.Label(cred_header, text="Login Credentials",
style="Heading.TLabel").pack(side="left")
ttk.Button(cred_header, text=" Add Credential",
command=self._add_cred_row).pack(side="right")
self.creds_container = ttk.Frame(self.inner)
self.creds_container.pack(fill="x", padx=24, pady=8)
# ── Buttons ───────────────────────────────────────────────────────────
ttk.Separator(self.inner, orient="horizontal").pack(
fill="x", padx=24, pady=12)
btn_frame = ttk.Frame(self.inner)
btn_frame.pack(fill="x", padx=24, pady=(0, 20))
ttk.Button(btn_frame, text="Save",
command=self._save).pack(side="right", padx=(6, 0))
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
# Pre-populate if editing
if self.is_edit:
d = self.website_data
self.name_var.set(d.get("name") or "")
self.url_var.set(d.get("url") or "")
self.check_type_var.set(d.get("check_type") or "daily")
self.note_txt.insert("1.0", d.get("note") or "")
for cred in d.get("credentials", []):
self._add_cred_row(cred)
else:
self._add_cred_row()
def _add_cred_row(self, cred=None):
frame = ttk.Frame(self.creds_container, style="Surface.TFrame")
frame.pack(fill="x", pady=4, ipady=4)
label_var = tk.StringVar(value=cred.get("label", "") if cred else "")
user_var = tk.StringVar(value=cred.get("username", "") if cred else "")
pass_var = tk.StringVar(value=cred.get("password", "") if cred else "")
for col_n, (lbl, var, show) in enumerate([
("Label", label_var, None),
("Username", user_var, None),
("Password", pass_var, ""),
]):
tk.Label(frame, text=lbl, bg=COLOURS["surface"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=0, column=col_n*2, sticky="w", padx=(8, 2))
ent = ttk.Entry(frame, textvariable=var, show=show or "", width=14)
ent.grid(row=0, column=col_n*2+1, sticky="ew", padx=(0, 8), pady=4)
ttk.Button(frame, text="", style="Danger.TButton", width=3,
command=lambda f=frame: self._remove_cred_row(f)).grid(
row=0, column=6, padx=(0, 8))
frame.columnconfigure(1, weight=1)
frame.columnconfigure(3, weight=1)
frame.columnconfigure(5, weight=1)
self.cred_rows.append((label_var, user_var, pass_var, frame))
def _remove_cred_row(self, frame):
self.cred_rows = [(l, u, p, f) for l, u, p, f in self.cred_rows
if f is not frame]
frame.destroy()
def _save(self):
name = self.name_var.get().strip()
url = self.url_var.get().strip()
check_type = self.check_type_var.get()
note = self.note_txt.get("1.0", "end-1c").strip()
if not name or not url:
show_error("Name and URL are required.")
return
credentials = []
for label_var, user_var, pass_var, _ in self.cred_rows:
u = user_var.get().strip()
if u:
credentials.append({
"label": label_var.get().strip(),
"username": u,
"password": pass_var.get().strip(),
})
try:
if self.is_edit:
from models import update_website
update_website(self.current_user["id"],
self.website_data["id"],
name, url, check_type, note, credentials)
logger.info(f"Website id={self.website_data['id']} updated "
f"check_type='{check_type}'.")
show_info("Website updated successfully.")
else:
from models import create_website
create_website(self.current_user["id"],
name, url, check_type, note, credentials)
logger.info(f"New website '{name}' created "
f"check_type='{check_type}'.")
show_info("Website created successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")
+249
View File
@@ -0,0 +1,249 @@
"""
views/change_password_view.py — Self-service password change dialog.
Available to all authenticated users via the sidebar.
Enforces strength rules and verifies the current password before accepting.
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info,
)
logger = logging.getLogger("change_password_view")
class ChangePasswordView(tk.Toplevel):
"""
Modal dialog for changing the logged-in user's password.
Parameters
----------
master : parent widget
current_user : dict with at least 'id' and 'username'
"""
def __init__(self, master, current_user: dict):
super().__init__(master)
self.current_user = current_user
self.title("Change Password")
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 = 440, 460
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
# Header banner
hdr = tk.Frame(self, bg=COLOURS["accent"], pady=16)
hdr.pack(fill="x")
tk.Label(hdr, text="Change Password",
font=FONT_BOLD, bg=COLOURS["accent"],
fg=COLOURS["white"]).pack()
tk.Label(hdr,
text=f"Logged in as: {self.current_user.get('username', '')}",
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=""):
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, show=show,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT)
ent.grid(row=row, column=1, sticky="ew", ipady=7, pady=6)
return var, ent
self.old_var, self.old_ent = field("Current Password", 0)
self.new_var, _ = field("New Password", 1)
self.cfm_var, _ = field("Confirm Password", 2)
self.old_ent.focus_set()
# Bind new-password field to live strength meter
self.new_var.trace_add("write", lambda *_: self._update_strength())
# ── Password strength meter ───────────────────────────────────────────
tk.Label(form, text="Strength", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=3, column=0, sticky="nw", padx=(0, 12), pady=(8, 2))
meter_frame = tk.Frame(form, bg=COLOURS["bg"])
meter_frame.grid(row=3, column=1, sticky="ew", pady=(8, 2))
# Five segment bars
self._segments = []
for i in range(5):
seg = tk.Frame(meter_frame, bg=COLOURS["surface2"],
width=36, height=8)
seg.pack(side="left", padx=2)
seg.pack_propagate(False)
self._segments.append(seg)
self._strength_lbl = tk.Label(meter_frame, text="",
bg=COLOURS["bg"],
fg=COLOURS["text_dim"],
font=FONT_SMALL)
self._strength_lbl.pack(side="left", padx=(10, 0))
# Requirements checklist
tk.Label(form, text="Requirements", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=4, column=0, sticky="nw", padx=(0, 12), pady=(12, 4))
req_frame = tk.Frame(form, bg=COLOURS["bg"])
req_frame.grid(row=4, column=1, sticky="ew", pady=(12, 4))
from models import (PW_MIN_LENGTH, PW_REQUIRE_UPPER,
PW_REQUIRE_DIGIT, PW_REQUIRE_SPECIAL)
self._req_labels = {}
req_defs = [
("length", f"At least {PW_MIN_LENGTH} characters"),
("upper", "Uppercase letter"),
("digit", "Number"),
("special", "Special character"),
]
for key, text in req_defs:
lbl = tk.Label(req_frame, text=f" {text}",
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
font=FONT_SMALL, anchor="w")
lbl.pack(fill="x")
self._req_labels[key] = lbl
# Status / error line
self._status_var = tk.StringVar()
tk.Label(form, textvariable=self._status_var,
bg=COLOURS["bg"], fg=COLOURS["danger"],
font=FONT_SMALL, wraplength=340,
justify="left").grid(
row=5, 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")
tk.Button(
btn_row, text="Change Password",
command=self._submit,
bg=COLOURS["accent"], fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD,
cursor="hand2", padx=14, pady=8,
).pack(side="right", padx=(8, 0))
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="right")
# Enter key submits
self.bind("<Return>", lambda _: self._submit())
# ─── Strength Meter ───────────────────────────────────────────────────────
def _update_strength(self):
from models import (validate_password_strength, PW_MIN_LENGTH,
PW_REQUIRE_UPPER, PW_REQUIRE_DIGIT,
PW_REQUIRE_SPECIAL, _SPECIAL_CHARS)
pw = self.new_var.get()
# Evaluate each requirement
met = {
"length": len(pw) >= PW_MIN_LENGTH,
"upper": any(c.isupper() for c in pw),
"digit": any(c.isdigit() for c in pw),
"special": any(c in _SPECIAL_CHARS for c in pw),
}
score = sum(met.values()) # 04
# Update requirement labels
for key, lbl in self._req_labels.items():
if met[key]:
lbl.config(fg=COLOURS["success"], text=f"{lbl.cget('text')[2:]}")
else:
lbl.config(fg=COLOURS["text_dim"], text=f" {lbl.cget('text')[2:]}")
# Re-set correct prefix each time
req_texts = {
"length": f"At least {PW_MIN_LENGTH} characters",
"upper": "Uppercase letter",
"digit": "Number",
"special": "Special character",
}
for key, lbl in self._req_labels.items():
prefix = "" if met[key] else " "
lbl.config(text=prefix + req_texts[key])
# Colour segments
colours = ["#e05c5c", "#f0a500", "#f0a500", "#7c6af7", "#4caf50"]
labels = ["Very Weak", "Weak", "Fair", "Strong", "Very Strong"]
for i, seg in enumerate(self._segments):
seg.config(bg=colours[score - 1] if i < score and score > 0
else COLOURS["surface2"])
if pw:
self._strength_lbl.config(
text=labels[score - 1] if score > 0 else "",
fg=colours[score - 1] if score > 0 else COLOURS["text_dim"]
)
else:
self._strength_lbl.config(text="")
# ─── Submit ───────────────────────────────────────────────────────────────
def _submit(self):
old_pw = self.old_var.get()
new_pw = self.new_var.get()
cfm_pw = self.cfm_var.get()
if not old_pw or not new_pw or not cfm_pw:
self._status_var.set("All fields are required.")
return
if new_pw != cfm_pw:
self._status_var.set("New password and confirmation do not match.")
return
from models import change_password
try:
success, message = change_password(
self.current_user["id"], old_pw, new_pw
)
except Exception as e:
self._status_var.set(f"Error: {e}")
return
if success:
show_info(message)
logger.info(
f"Password changed by user '{self.current_user['username']}'.")
self.destroy()
else:
self._status_var.set(message)
+239
View File
@@ -0,0 +1,239 @@
"""
views/email_settings_view.py — SMTP / scheduled report configuration dialog.
Admin-only. Persists to config.ini [email] section via utils/scheduler.py.
"""
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("email_settings_view")
class EmailSettingsView(tk.Toplevel):
def __init__(self, master, current_user: dict):
super().__init__(master)
self.current_user = current_user
self.title("Email Report Settings")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
self.protocol("WM_DELETE_WINDOW", self.destroy)
self._build_ui()
self._load_existing()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 500, 560
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=14)
hdr.pack(fill="x")
tk.Label(hdr, text="Daily Report Email Settings",
font=FONT_BOLD, bg=COLOURS["accent"],
fg=COLOURS["white"]).pack()
tk.Label(hdr,
text="Send an HTML completion report to recipients on a daily schedule.",
font=FONT_SMALL, bg=COLOURS["accent"],
fg=COLOURS["white"]).pack(pady=(2, 0))
# Form
form = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=16)
form.pack(fill="both", expand=True)
form.columnconfigure(1, weight=1)
# Enable toggle
tk.Label(form, text="Enable daily emails", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=0, column=0, sticky="w", padx=(0, 12), pady=6)
self.enabled_var = tk.BooleanVar(value=False)
tk.Checkbutton(form, variable=self.enabled_var,
bg=COLOURS["bg"],
activebackground=COLOURS["bg"],
selectcolor=COLOURS["surface2"],
command=self._toggle_fields).grid(
row=0, column=1, sticky="w", pady=6)
def field(label, row, show=None, width=28):
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=5)
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=5)
self._fields.append(ent)
return var
self._fields = []
self.smtp_host_var = field("SMTP Host", 1)
self.smtp_port_var = field("SMTP Port", 2, width=8)
self.smtp_user_var = field("SMTP Username", 3)
self.smtp_pass_var = field("SMTP Password", 4, show="")
# TLS toggle
tk.Label(form, text="Use STARTTLS", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_SMALL).grid(
row=5, column=0, sticky="w", padx=(0, 12), pady=5)
self.tls_var = tk.BooleanVar(value=True)
tls_cb = tk.Checkbutton(form, variable=self.tls_var,
bg=COLOURS["bg"],
activebackground=COLOURS["bg"],
selectcolor=COLOURS["surface2"])
tls_cb.grid(row=5, column=1, sticky="w", pady=5)
self._fields.append(tls_cb)
self.recipients_var = field("Recipients (comma-sep)", 6)
self.send_time_var = field("Send Time (HH:MM)", 7, width=8)
self.smtp_port_var.set("587")
self.send_time_var.set("18:00")
# Status line
self._status_var = tk.StringVar()
self._status_lbl = tk.Label(form, textvariable=self._status_var,
bg=COLOURS["bg"], fg=COLOURS["text_dim"],
font=FONT_SMALL, wraplength=380, anchor="w")
self._status_lbl.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0))
# Buttons
btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=14)
btn_row.pack(fill="x")
self._save_btn = tk.Button(
btn_row, text="Save Settings",
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,
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")
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")
self._toggle_fields()
def _toggle_fields(self):
"""Enable/disable SMTP fields based on the enabled checkbox."""
state = "normal" if self.enabled_var.get() else "disabled"
for w in self._fields:
try:
w.config(state=state)
except Exception:
pass
def _load_existing(self):
from utils.scheduler import load_email_config
cfg = load_email_config()
if not cfg:
return
self.enabled_var.set(cfg.get("enabled", False))
self.smtp_host_var.set(cfg.get("smtp_host", ""))
self.smtp_port_var.set(str(cfg.get("smtp_port", 587)))
self.smtp_user_var.set(cfg.get("smtp_user", ""))
self.smtp_pass_var.set(cfg.get("smtp_password", ""))
self.tls_var.set(cfg.get("use_tls", True))
self.recipients_var.set(", ".join(cfg.get("recipients", [])))
self.send_time_var.set(cfg.get("send_time", "18:00"))
self._toggle_fields()
def _get_fields(self):
host = self.smtp_host_var.get().strip()
port_str = self.smtp_port_var.get().strip()
user = self.smtp_user_var.get().strip()
password = self.smtp_pass_var.get()
use_tls = self.tls_var.get()
recipients = self.recipients_var.get().strip()
send_time = self.send_time_var.get().strip()
enabled = self.enabled_var.get()
if enabled and (not host or not user or not recipients):
self._set_status("Host, username, and recipients are required when enabled.", "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
# Validate send_time
try:
h, m = map(int, send_time.split(":"))
assert 0 <= h <= 23 and 0 <= m <= 59
except Exception:
self._set_status("Send time must be in HH:MM format (e.g. 18:00).", "danger")
return None
return enabled, host, port, user, password, use_tls, recipients, send_time
def _set_status(self, msg, level="dim"):
colours = {"danger": COLOURS["danger"], "success": COLOURS["success"],
"dim": COLOURS["text_dim"], "warning": COLOURS["warning"]}
self._status_lbl.config(fg=colours.get(level, COLOURS["text_dim"]))
self._status_var.set(msg)
def _test(self):
result = self._get_fields()
if result is None:
return
enabled, host, port, user, password, use_tls, recipients, send_time = result
self._set_status("Testing SMTP connection...", "dim")
self._test_btn.config(state="disabled")
self._save_btn.config(state="disabled")
def _run():
from utils.scheduler import test_smtp_connection
ok, msg = test_smtp_connection(host, port, user, password, use_tls)
level = "success" if ok else "danger"
self.after(0, lambda: self._set_status(msg, level))
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()
def _save(self):
result = self._get_fields()
if result is None:
return
enabled, host, port, user, password, use_tls, recipients, send_time = result
from utils.scheduler import save_email_config
save_email_config(enabled, host, port, user, password, use_tls,
recipients, send_time)
from models import log_action
log_action(self.current_user["id"], "UPDATE_EMAIL_SETTINGS", "config",
None, f"Email reports {'enabled' if enabled else 'disabled'}.")
show_info("Email settings saved successfully.")
logger.info(f"Email settings saved by {self.current_user['username']}.")
self.destroy()
+221
View File
@@ -0,0 +1,221 @@
"""
views/login_view.py — Login screen with rate limiting.
Calls check_login_allowed() before authenticate().
On lockout, shows remaining wait time (updated every second).
On successful login, the failed attempt counter is cleared by authenticate().
"""
import tkinter as tk
from tkinter import ttk
import logging
from utils.ui_helpers import COLOURS, FONT, FONT_BOLD, FONT_TITLE, show_error
logger = logging.getLogger("login_view")
class LoginView(tk.Toplevel):
def __init__(self, master, on_success_callback):
super().__init__(master)
self.on_success = on_success_callback
self._countdown_job = None # after() handle for lockout countdown
self.title("Website Checker - Login")
self.resizable(False, False)
self.configure(bg=COLOURS["bg"])
self._centre()
self._build_ui()
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.grab_set()
def _centre(self):
self.update_idletasks()
w, h = 420, 380
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
outer = tk.Frame(self, bg=COLOURS["bg"], padx=40, pady=30)
outer.pack(fill="both", expand=True)
# Logo / Title
tk.Label(outer, text="🌐 Website Checker",
font=FONT_TITLE,
bg=COLOURS["bg"],
fg=COLOURS["accent"]).pack(pady=(0, 4))
tk.Label(outer, text="Shift Monitoring Tool",
font=(FONT[0], 9),
bg=COLOURS["bg"],
fg=COLOURS["text_dim"]).pack(pady=(0, 24))
# Form
form = tk.Frame(outer, bg=COLOURS["bg"])
form.pack(fill="x")
form.columnconfigure(0, weight=1)
tk.Label(form, text="Username", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_BOLD).grid(
row=0, column=0, sticky="w", pady=(0, 2))
self.username_var = tk.StringVar()
self.username_ent = tk.Entry(
form, textvariable=self.username_var, width=32,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT)
self.username_ent.grid(row=1, column=0, sticky="ew",
ipady=8, pady=(0, 14))
self.username_ent.focus_set()
tk.Label(form, text="Password", bg=COLOURS["bg"],
fg=COLOURS["text_dim"], font=FONT_BOLD).grid(
row=2, column=0, sticky="w", pady=(0, 2))
self.password_var = tk.StringVar()
self.password_ent = tk.Entry(
form, textvariable=self.password_var, show="", width=32,
bg=COLOURS["surface2"], fg=COLOURS["text"],
insertbackground=COLOURS["text"],
relief="flat", font=FONT)
self.password_ent.grid(row=3, column=0, sticky="ew",
ipady=8, pady=(0, 20))
self.password_ent.bind("<Return>", lambda _: self._login())
# Error / lockout label
self.error_label = tk.Label(outer, text="",
fg=COLOURS["danger"],
bg=COLOURS["bg"], font=FONT,
wraplength=320, justify="center")
self.error_label.pack(pady=(0, 8))
# Sign-in button
self.sign_in_btn = tk.Button(
outer, text=" Sign In ",
command=self._login,
bg=COLOURS["accent"],
fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat",
font=FONT_BOLD,
cursor="hand2",
padx=20, pady=8)
self.sign_in_btn.pack()
# ─── Login ────────────────────────────────────────────────────────────────
def _login(self):
username = self.username_var.get().strip()
password = self.password_var.get()
if not username or not password:
self.error_label.config(text="Please enter both username and password.")
return
# ── Rate-limit check ──────────────────────────────────────────────────
try:
from models import check_login_allowed, LOCKOUT_MINUTES
allowed, seconds_left = check_login_allowed(username)
except Exception as e:
logger.error(f"Rate-limit check error: {e}")
show_error(f"Unable to connect to the database.\n\n{e}",
"Connection Error")
return
if not allowed:
self._start_lockout_countdown(seconds_left)
return
# ── Authenticate ──────────────────────────────────────────────────────
try:
from models import authenticate
user = authenticate(username, password)
except Exception as e:
logger.error(f"Authentication error: {e}")
show_error(f"Unable to connect to the database.\n\n{e}",
"Connection Error")
return
if user:
self._cancel_countdown()
logger.info(f"Login successful for '{username}' (role={user['role']}).")
self.destroy()
self.on_success(user)
else:
# Show remaining attempts warning
try:
from models import check_login_allowed, MAX_FAILED_ATTEMPTS
_, secs = check_login_allowed(username)
if secs > 0:
self._start_lockout_countdown(secs)
else:
# Fetch fresh count to show "N attempts remaining"
from config import get_connection
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT failed_attempts FROM users WHERE username=%s",
(username,)
)
row = cur.fetchone()
cur.close()
conn.close()
if row:
remaining = MAX_FAILED_ATTEMPTS - (row["failed_attempts"] or 0)
remaining = max(0, remaining)
if remaining > 0:
self.error_label.config(
text=f"Invalid credentials. "
f"{remaining} attempt(s) remaining before lockout."
)
else:
self.error_label.config(
text="Invalid credentials. Please try again.")
else:
self.error_label.config(
text="Invalid credentials. Please try again.")
except Exception:
self.error_label.config(
text="Invalid credentials. Please try again.")
# ─── Lockout countdown ────────────────────────────────────────────────────
def _start_lockout_countdown(self, seconds_left: int):
"""Disable the form and show a live countdown until the lockout expires."""
self.username_ent.config(state="disabled")
self.password_ent.config(state="disabled")
self.sign_in_btn.config(state="disabled", bg=COLOURS["border"])
self._remaining = seconds_left
self._tick_countdown()
def _tick_countdown(self):
if self._remaining <= 0:
self._cancel_countdown()
return
mins, secs = divmod(self._remaining, 60)
self.error_label.config(
text=f"Account temporarily locked.\n"
f"Try again in {mins}m {secs:02d}s."
)
self._remaining -= 1
self._countdown_job = self.after(1000, self._tick_countdown)
def _cancel_countdown(self):
if self._countdown_job:
try:
self.after_cancel(self._countdown_job)
except Exception:
pass
self._countdown_job = None
try:
self.username_ent.config(state="normal")
self.password_ent.config(state="normal")
self.sign_in_btn.config(state="normal", bg=COLOURS["accent"])
self.error_label.config(text="")
except Exception:
pass # widget may have been destroyed
def _on_close(self):
self._cancel_countdown()
self.master.destroy()
+513
View File
@@ -0,0 +1,513 @@
"""
views/reports_view.py — Reports panel (Admin only).
Tabs
────
1. Shift Detail — every check event, filterable by date range / user / website
2. Unchecked — sites not checked on a given date, per user
3. Summary — per-user per-day completion percentage
All three tabs share the same Export toolbar (CSV + Excel).
"""
import os
import tkinter as tk
from tkinter import ttk, filedialog
import logging
from datetime import date, timedelta
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info, DateEntry,
)
logger = logging.getLogger("reports_view")
# ─── Column definitions per report type ───────────────────────────────────────
SHIFT_COLS = ["check_date", "checked_at", "username", "full_name",
"website_name", "url", "user_note", "status"]
UNCHECKED_COLS = ["check_date", "username", "full_name",
"website_name", "url", "status"]
SUMMARY_COLS = ["check_date", "username", "full_name",
"checked_count", "total_sites", "pct_complete"]
class ReportsView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._users = [] # [{id, username, full_name}, ...]
self._websites = [] # [{id, name}, ...]
self._load_filter_options()
self._build_ui()
# ─── Bootstrap ────────────────────────────────────────────────────────────
def _load_filter_options(self):
try:
from models import get_report_filter_options
self._users, self._websites = get_report_filter_options()
except Exception as e:
logger.error(f"Failed to load filter options: {e}")
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
# ── Page header ───────────────────────────────────────────────────────
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 12))
ttk.Label(hdr, text="Reports", style="Heading.TLabel").pack(side="left")
# ── Notebook (tabs) ───────────────────────────────────────────────────
self.nb = ttk.Notebook(self)
self.nb.pack(fill="both", expand=True)
self._tab_shift = self._make_tab("📋 Shift Detail", SHIFT_COLS,
self._run_shift_report)
self._tab_unchecked = self._make_tab("⚠️ Unchecked", UNCHECKED_COLS,
self._run_unchecked_report)
self._tab_summary = self._make_tab("📊 Summary", SUMMARY_COLS,
self._run_summary_report)
self._build_chart_tab()
def _make_tab(self, label: str, columns: list, run_fn) -> dict:
"""
Build one tab with a filter strip, result treeview, and export toolbar.
Returns a dict of widget references used by the run / export functions.
"""
frame = ttk.Frame(self.nb)
self.nb.add(frame, text=label)
# ── Filter strip ──────────────────────────────────────────────────────
filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10)
filter_card.pack(fill="x", padx=0, pady=(0, 10))
widgets = {}
is_unchecked = (columns == UNCHECKED_COLS)
is_summary = (columns == SUMMARY_COLS)
col = 0
def _lbl(text):
nonlocal col
tk.Label(filter_card, text=text,
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).grid(row=0, column=col, padx=(14, 2), pady=6, sticky="w")
col += 1
def _cb(values, width=18):
nonlocal col
var = tk.StringVar(value=values[0])
cb = ttk.Combobox(filter_card, textvariable=var,
values=values, state="readonly", width=width)
cb.grid(row=0, column=col, padx=(0, 8), pady=6)
col += 1
return var, cb
# Date from / Date to — use DateEntry calendar pickers
today_str = date.today().isoformat()
week_ago = (date.today() - timedelta(days=6)).isoformat()
if is_unchecked:
_lbl("Date")
de = DateEntry(filter_card, initial_date=today_str, width=11)
de.grid(row=0, column=col, padx=(0, 8), pady=6)
col += 1
widgets["date_single"] = de
else:
_lbl("From")
de_from = DateEntry(filter_card, initial_date=week_ago, width=11)
de_from.grid(row=0, column=col, padx=(0, 4), pady=6); col += 1
_lbl("To")
de_to = DateEntry(filter_card, initial_date=today_str, width=11)
de_to.grid(row=0, column=col, padx=(0, 8), pady=6); col += 1
widgets["date_from"] = de_from
widgets["date_to"] = de_to
# User filter (all tabs)
_lbl("User")
user_labels = ["All Users"] + [
f"{u['username']}{u['full_name'] or ''}" for u in self._users
]
uvar, _ = _cb(user_labels, width=22)
widgets["user_var"] = uvar
# Website filter (shift detail only)
if not is_unchecked and not is_summary:
_lbl("Website")
site_labels = ["All Websites"] + [w["name"] for w in self._websites]
svar, _ = _cb(site_labels, width=22)
widgets["site_var"] = svar
# Run button
run_btn = tk.Button(
filter_card, text=" Run Report ",
command=lambda w=widgets: run_fn(w),
bg=COLOURS["accent"], fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD, cursor="hand2", padx=10, pady=4,
)
run_btn.grid(row=0, column=col, padx=(4, 14), pady=6); col += 1
filter_card.columnconfigure(col, weight=1)
# ── Status / count bar ────────────────────────────────────────────────
status_bar = ttk.Frame(frame)
status_bar.pack(fill="x", pady=(0, 4))
count_lbl = ttk.Label(status_bar, text="Run a report to see results.",
style="Dim.TLabel")
count_lbl.pack(side="left")
widgets["count_lbl"] = count_lbl
# Export buttons
tk.Button(
status_bar, text="⬇ Export Excel",
command=lambda w=widgets, c=columns: self._export(w, c, "excel"),
bg=COLOURS["success"], fg=COLOURS["white"],
activebackground="#3d9140", activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4,
).pack(side="right", padx=(4, 0))
tk.Button(
status_bar, text="⬇ Export CSV",
command=lambda w=widgets, c=columns: self._export(w, c, "csv"),
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["border"], activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4,
).pack(side="right", padx=(4, 0))
# ── Treeview ──────────────────────────────────────────────────────────
tree_frame = ttk.Frame(frame)
tree_frame.pack(fill="both", expand=True)
col_display = [c.replace("_", " ").title() for c in columns]
tree = ttk.Treeview(tree_frame, columns=col_display,
show="headings", selectmode="browse")
col_widths = {
"Check Date": 95, "Checked At": 140, "Username": 110,
"Full Name": 150, "Website Name": 170, "Url": 230,
"User Note": 200, "Status": 90,
"Checked Count": 100, "Total Sites": 90, "Pct Complete": 100,
}
for disp in col_display:
w = col_widths.get(disp, 120)
tree.heading(disp, text=disp,
command=lambda d=disp, t=tree: self._sort_tree(t, d))
tree.column(disp, width=w, anchor="center" if w < 130 else "w",
minwidth=60)
# Colour-tag rows by status
tree.tag_configure("checked", foreground=COLOURS["success"])
tree.tag_configure("unchecked", foreground=COLOURS["danger"])
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
widgets["tree"] = tree
widgets["columns"] = columns
widgets["rows"] = [] # populated after each run
return widgets
# ─── Report Runners ───────────────────────────────────────────────────────
def _resolve_user_id(self, user_var):
"""Translate the combobox display string back to a DB user id or None."""
val = user_var.get()
if val == "All Users":
return None
username = val.split("")[0].strip()
for u in self._users:
if u["username"] == username:
return u["id"]
return None
def _resolve_website_id(self, site_var):
val = site_var.get()
if val == "All Websites":
return None
for w in self._websites:
if w["name"] == val:
return w["id"]
return None
def _populate_tree(self, widgets: dict, rows: list):
"""Clear and refill the treeview; update count label."""
tree = widgets["tree"]
columns = widgets["columns"]
tree.delete(*tree.get_children())
for row in rows:
values = [str(row.get(c) or "") for c in columns]
tag = "unchecked" if row.get("status") == "Not Checked" else "checked"
tree.insert("", "end", values=values, tags=(tag,))
widgets["rows"] = rows
widgets["count_lbl"].config(
text=f"{len(rows)} record{'s' if len(rows) != 1 else ''} found."
)
def _run_shift_report(self, widgets: dict):
from models import get_shift_report
try:
date_from = widgets["date_from"].get().strip() or None
date_to = widgets["date_to"].get().strip() or None
user_id = self._resolve_user_id(widgets["user_var"])
website_id = self._resolve_website_id(widgets["site_var"])
rows = get_shift_report(date_from, date_to, user_id, website_id)
self._populate_tree(widgets, rows)
logger.info(
f"[REPORT] Shift Detail run by {self.current_user['username']} - "
f"from={date_from} to={date_to} user={user_id} site={website_id} "
f"-> {len(rows)} rows"
)
except Exception as e:
show_error(f"Report failed:\n{e}")
def _run_unchecked_report(self, widgets: dict):
from models import get_unchecked_report
try:
target_date = widgets["date_single"].get().strip() or None
user_id = self._resolve_user_id(widgets["user_var"])
rows = get_unchecked_report(target_date, user_id)
self._populate_tree(widgets, rows)
logger.info(
f"[REPORT] Unchecked run by {self.current_user['username']} - "
f"date={target_date} user={user_id} -> {len(rows)} rows"
)
except Exception as e:
show_error(f"Report failed:\n{e}")
def _run_summary_report(self, widgets: dict):
from models import get_summary_report
try:
date_from = widgets["date_from"].get().strip() or None
date_to = widgets["date_to"].get().strip() or None
rows = get_summary_report(date_from, date_to)
self._populate_tree(widgets, rows)
logger.info(
f"[REPORT] Summary run by {self.current_user['username']} - "
f"from={date_from} to={date_to} -> {len(rows)} rows"
)
except Exception as e:
show_error(f"Report failed:\n{e}")
# ─── Export ───────────────────────────────────────────────────────────────
def _export(self, widgets: dict, columns: list, fmt: str):
rows = widgets.get("rows", [])
if not rows:
show_error("No data to export. Please run a report first.")
return
save_dir = filedialog.askdirectory(title="Select folder to save report")
if not save_dir:
return
# Derive a meaningful base filename from the active tab
tab_text = self.nb.tab(self.nb.select(), "text").strip()
base = "report_" + tab_text.replace(" ", "_").replace(" ", "_") \
.replace("/", "").replace("⚠️", "unchecked") \
.replace("📋", "shift").replace("📊", "summary") \
.lower()
try:
from utils.export import export_csv, export_excel
if fmt == "csv":
path = export_csv(rows, columns, base, save_dir)
else:
sheet = tab_text.replace(" ", " ").strip()
path = export_excel(rows, columns, base, save_dir, sheet_title=sheet)
from models import log_action
log_action(
self.current_user["id"],
f"EXPORT_{fmt.upper()}",
"reports",
None,
f"Exported '{tab_text.strip()}' ({len(rows)} rows) → {path}"
)
show_info(f"Export complete!\n\nSaved to:\n{path}")
except Exception as e:
show_error(f"Export failed:\n{e}")
# ─── Column Sort ──────────────────────────────────────────────────────────
@staticmethod
def _sort_tree(tree: ttk.Treeview, col: str):
"""Toggle ascending / descending sort on a treeview column."""
items = [(tree.set(k, col), k) for k in tree.get_children("")]
reverse = getattr(tree, f"_sort_rev_{col}", False)
items.sort(reverse=reverse)
for idx, (_, k) in enumerate(items):
tree.move(k, "", idx)
setattr(tree, f"_sort_rev_{col}", not reverse)
# ─── Chart Tab ────────────────────────────────────────────────────────────
def _build_chart_tab(self):
"""Fourth tab: completion bar chart powered by matplotlib."""
frame = ttk.Frame(self.nb)
self.nb.add(frame, text="📈 Completion Chart")
# Filter strip
filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10)
filter_card.pack(fill="x", pady=(0, 10))
tk.Label(filter_card, text="From",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).grid(row=0, column=0, padx=(14, 2), pady=6)
today_str = date.today().isoformat()
week_ago = (date.today() - timedelta(days=6)).isoformat()
self._chart_from = DateEntry(filter_card, initial_date=week_ago, width=11)
self._chart_from.grid(row=0, column=1, padx=(0, 4), pady=6)
tk.Label(filter_card, text="To",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).grid(row=0, column=2, padx=(4, 2), pady=6)
self._chart_to = DateEntry(filter_card, initial_date=today_str, width=11)
self._chart_to.grid(row=0, column=3, padx=(0, 12), pady=6)
tk.Button(
filter_card, text=" Generate Chart ",
command=self._run_chart,
bg=COLOURS["accent"], fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=10, pady=4,
).grid(row=0, column=4, padx=(0, 14), pady=6)
filter_card.columnconfigure(5, weight=1)
# Chart canvas placeholder
self._chart_status = ttk.Label(
frame,
text="Select a date range and click Generate Chart.",
style="Dim.TLabel"
)
self._chart_status.pack(pady=20)
self._chart_frame = ttk.Frame(frame)
self._chart_frame.pack(fill="both", expand=True, padx=8, pady=8)
def _run_chart(self):
"""Fetch summary data and render a grouped bar chart."""
date_from = self._chart_from.get().strip() or None
date_to = self._chart_to.get().strip() or None
try:
from models import get_summary_report
rows = get_summary_report(date_from, date_to)
except Exception as e:
show_error(f"Failed to load chart data:\n{e}")
return
if not rows:
self._chart_status.config(text="No data found for the selected range.")
return
self._chart_status.config(text="")
self._render_chart(rows, date_from, date_to)
logger.info(f"[CHART] Generated completion chart "
f"from={date_from} to={date_to} rows={len(rows)}")
def _render_chart(self, rows: list, date_from, date_to):
"""Build and embed a matplotlib bar chart in the chart frame."""
# Clear previous chart
for w in self._chart_frame.winfo_children():
w.destroy()
try:
import matplotlib
matplotlib.use("Agg") # non-interactive backend — safe for Tkinter
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
except ImportError:
show_error(
"matplotlib is required for charts.\n"
"Install it with: pip install matplotlib"
)
return
C = COLOURS
# ── Aggregate per-user average completion ──────────────────────────────
from collections import defaultdict
user_pcts: dict[str, list] = defaultdict(list)
for row in rows:
user_pcts[row["username"]].append(float(row["pct_complete"] or 0))
users = list(user_pcts.keys())
avg_pct = [sum(v) / len(v) for v in user_pcts.values()]
# Colour bars by completion level
bar_colours = [
"#4caf50" if p >= 100 else
"#f0a500" if p >= 50 else
"#e05c5c"
for p in avg_pct
]
# ── Plot ──────────────────────────────────────────────────────────────
fig_bg = C["surface"]
axes_bg = C["surface2"]
fig, ax = plt.subplots(figsize=(max(6, len(users) * 0.9 + 2), 4),
facecolor=fig_bg)
ax.set_facecolor(axes_bg)
x = range(len(users))
bars = ax.bar(x, avg_pct, color=bar_colours,
width=0.55, zorder=2)
# Grid lines
ax.yaxis.set_major_locator(mticker.MultipleLocator(20))
ax.set_ylim(0, 110)
ax.grid(axis="y", color=C["border"], linewidth=0.7, zorder=1)
ax.set_axisbelow(True)
# Labels
ax.set_xticks(list(x))
ax.set_xticklabels(users, rotation=30, ha="right",
color=C["text"], fontsize=9)
ax.set_ylabel("Avg Completion (%)", color=C["text_dim"], fontsize=9)
ax.tick_params(colors=C["text_dim"], which="both")
for spine in ax.spines.values():
spine.set_edgecolor(C["border"])
title_range = ""
if date_from and date_to:
title_range = f" ({date_from} to {date_to})"
ax.set_title(f"User Completion{title_range}",
color=C["text"], fontsize=11, pad=10)
# Value labels on bars
for bar, pct in zip(bars, avg_pct):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 1.5,
f"{pct:.0f}%",
ha="center", va="bottom",
color=C["text"], fontsize=8, fontweight="bold",
)
fig.tight_layout()
# ── Embed in Tkinter ──────────────────────────────────────────────────
canvas = FigureCanvasTkAgg(fig, master=self._chart_frame)
canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True)
plt.close(fig) # free memory
+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()
+743
View File
@@ -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}")