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
+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