Files
WebChecker/views/admin_shifts_view.py
T
2026-04-21 17:16:37 -04:00

588 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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