04/23 Enhance app functionalities

This commit is contained in:
2026-04-23 16:54:24 -04:00
parent 64419a445a
commit f4eea48e4d
8 changed files with 427 additions and 45 deletions
+20 -1
View File
@@ -33,6 +33,7 @@ class AdminShiftsView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._show_inactive = tk.BooleanVar(value=False)
self._build_ui()
self._load_shifts()
@@ -56,6 +57,15 @@ class AdminShiftsView(ttk.Frame):
style="Ghost.TButton",
command=self._export_pdf).pack(side="right", padx=(0, 12))
# Show / hide inactive shifts toggle
ttk.Checkbutton(
toolbar,
text="Show inactive",
variable=self._show_inactive,
command=self._load_shifts,
style="TCheckbutton",
).pack(side="left", padx=(16, 0))
cols = ("ID", "Shift Name", "Days", "Start", "End",
"Users", "Websites", "Active", "Note")
self.tree = ttk.Treeview(self, columns=cols,
@@ -80,8 +90,13 @@ class AdminShiftsView(ttk.Frame):
def _load_shifts(self):
from models import get_all_shifts
self.tree.delete(*self.tree.get_children())
show_inactive = self._show_inactive.get()
try:
for s in get_all_shifts():
all_shifts = get_all_shifts()
shown = 0
for s in all_shifts:
if not s["is_active"] and not show_inactive:
continue
days_str = _days_label(s["days_of_week"])
active = "" if s["is_active"] else ""
tag = "active" if s["is_active"] else "inactive"
@@ -99,6 +114,10 @@ class AdminShiftsView(ttk.Frame):
(s["note"] or "")[:60],
)
)
shown += 1
hidden = len(all_shifts) - shown
if hidden and not show_inactive:
logger.debug(f"Shifts view: {hidden} inactive shift(s) hidden.")
except Exception as e:
show_error(f"Failed to load shifts:\n{e}")
+79
View File
@@ -39,6 +39,9 @@ class AdminUsersView(ttk.Frame):
ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton",
command=self._delete_selected).pack(side="right")
ttk.Button(toolbar, text="🔑 Reset Password",
style="Ghost.TButton",
command=self._reset_password).pack(side="right", padx=(0, 8))
# Treeview
cols = ("ID", "Username", "Full Name", "Role", "Active", "Created")
@@ -76,6 +79,82 @@ class AdminUsersView(ttk.Frame):
# ─── Dialogs ──────────────────────────────────────────────────────────────
def _reset_password(self):
"""
Generate a random temporary password for the selected user, set it in
the database, copy it to the clipboard, and log the action.
The admin must communicate the temporary password to the user out-of-band;
the user should change it immediately via Change Password.
"""
import random
import string
uid = self._get_selected_id()
if not uid:
show_error("Please select a user to reset.")
return
if uid == self.current_user["id"]:
show_error("You cannot reset your own password here.\nUse Change Password instead.")
return
vals = self.tree.item(uid, "values")
username = vals[1] if vals else str(uid)
# Confirm before proceeding
from tkinter import messagebox
if not messagebox.askyesno(
"Reset Password",
f"Generate a new temporary password for '{username}'?\n\n"
"The temporary password will be copied to your clipboard.",
icon="warning",
):
return
# Build a strong random password that meets the existing policy:
# 8+ chars, uppercase, digit, special character
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
while True:
pwd = "".join(random.SystemRandom().choices(alphabet, k=16))
if (any(c.isupper() for c in pwd)
and any(c.isdigit() for c in pwd)
and any(c in "!@#$%^&*" for c in pwd)):
break
try:
from models import update_user, get_user_by_id, log_action
user_data = get_user_by_id(uid)
if not user_data:
show_error("User not found.")
return
update_user(
self.current_user["id"],
uid,
user_data["username"],
user_data["role"],
user_data["full_name"],
user_data["is_active"],
password=pwd,
)
log_action(
self.current_user["id"], "RESET_PASSWORD", "users", uid,
f"Admin reset password for user '{username}' (id={uid})."
)
logger.info(
f"Password reset for user id={uid} '{username}' "
f"by admin '{self.current_user['username']}'."
)
# Copy to clipboard
self.clipboard_clear()
self.clipboard_append(pwd)
show_info(
f"Temporary password for '{username}' has been set and "
f"copied to your clipboard:\n\n{pwd}\n\n"
"Please share it with the user securely.\n"
"The user should change it immediately after logging in."
)
except Exception as e:
show_error(f"Password reset failed:\n{e}")
def _open_add_dialog(self):
UserDialog(self, self.current_user, user_data=None,
on_save=self._load_users)
+37 -7
View File
@@ -939,6 +939,10 @@ class CriterionDialog(tk.Toplevel):
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
# Recommended character limit for criterion descriptions.
# Beyond this the AI prompt grows large and eats into document token budget.
_DESC_SOFT_LIMIT = 500
def _build_ui(self):
C = COLOURS
@@ -950,14 +954,14 @@ class CriterionDialog(tk.Toplevel):
form.pack(fill="x", padx=24, pady=8)
form.columnconfigure(1, weight=1)
# Title
# Row 0 — Title
ttk.Label(form, text="Title *").grid(
row=0, column=0, sticky="w", padx=(0, 10), pady=6)
self._title_var = tk.StringVar()
ttk.Entry(form, textvariable=self._title_var).grid(
row=0, column=1, sticky="ew", pady=6)
# Description
# Row 1 — Description text area
ttk.Label(form, text="Description *").grid(
row=1, column=0, sticky="nw", padx=(0, 10), pady=6)
@@ -975,15 +979,39 @@ class CriterionDialog(tk.Toplevel):
self._desc_txt.pack(fill="both", expand=True, padx=4, pady=4)
desc_vsb.config(command=self._desc_txt.yview)
# Sort order
# Row 2 — Live character counter (guidance, not a hard block)
self._char_lbl = tk.Label(
form,
text=f"0 / {self._DESC_SOFT_LIMIT} chars",
bg=C["bg"], fg=C["text_dim"],
font=FONT_SMALL, anchor="e",
)
self._char_lbl.grid(row=2, column=1, sticky="e", pady=(0, 4))
def _update_char_count(event=None):
n = len(self._desc_txt.get("1.0", "end-1c"))
over = n > self._DESC_SOFT_LIMIT
colour = C["danger"] if over else C["text_dim"]
label = (
f"{n} / {self._DESC_SOFT_LIMIT} chars"
f" ⚠ exceeds recommended limit" if over else
f"{n} / {self._DESC_SOFT_LIMIT} chars"
)
self._char_lbl.config(text=label, fg=colour)
self._desc_txt.bind("<KeyRelease>", _update_char_count)
# Also update when content is inserted programmatically (edit pre-fill)
self._desc_txt.bind("<<Modified>>", _update_char_count)
# Row 3 — Sort order
ttk.Label(form, text="Sort Order").grid(
row=2, column=0, sticky="w", padx=(0, 10), pady=6)
row=3, column=0, sticky="w", padx=(0, 10), pady=6)
self._order_var = tk.StringVar(value="0")
ttk.Spinbox(form, from_=0, to=999,
textvariable=self._order_var, width=8).grid(
row=2, column=1, sticky="w", pady=6)
row=3, column=1, sticky="w", pady=6)
# Active flag
# Row 4 — Active flag
self._active_var = tk.BooleanVar(value=True)
tk.Checkbutton(
form, text="Active (included in AI evaluation)",
@@ -991,7 +1019,7 @@ class CriterionDialog(tk.Toplevel):
bg=C["bg"], fg=C["text"],
activebackground=C["bg"], activeforeground=C["accent"],
selectcolor=C["surface2"], font=FONT, cursor="hand2",
).grid(row=3, column=1, sticky="w", pady=6)
).grid(row=4, column=1, sticky="w", pady=6)
tk.Label(
self,
@@ -1018,6 +1046,8 @@ class CriterionDialog(tk.Toplevel):
self._desc_txt.insert("1.0", d.get("description") or "")
self._order_var.set(str(d.get("sort_order", 0)))
self._active_var.set(bool(d.get("is_active", True)))
# Trigger counter update now that content has been inserted
_update_char_count()
def _save(self):
title = self._title_var.get().strip()
+42 -11
View File
@@ -265,12 +265,18 @@ class UserDashboardView(ttk.Frame):
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")
dot_col = {
"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"restricted": COLOURS["warning"],
"down": COLOURS["danger"],
}.get(status_str, COLOURS["text_dim"])
dot_tip = {
"ok": f"Reachable ({ms}ms)",
"slow": f"Slow ({ms}ms)",
"restricted": f"Reachable — access restricted ({ms}ms)",
"down": "Unreachable",
}.get(status_str, "Unknown")
else:
dot_col = COLOURS["text_dim"]
dot_tip = "Checking..."
@@ -424,8 +430,21 @@ class UserDashboardView(ttk.Frame):
# ─── 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."""
"""Background thread: HEAD request to url; updates health_cache + dot colour.
Status semantics:
ok 2xx / 3xx response, <= 3 000 ms
slow 2xx / 3xx response, > 3 000 ms
restricted 4xx response (server reachable but denying HEAD access)
down network error, timeout, or 5xx (server not responding)
4xx responses are intentionally treated as "restricted" (amber) rather
than "down" (red) because the server is clearly reachable many sites
block unauthenticated HEAD requests with 401/403 even when fully
operational.
"""
import urllib.request
import urllib.error
import time
if not url.startswith(("http://", "https://")):
@@ -436,16 +455,28 @@ class UserDashboardView(ttk.Frame):
req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe")
t0 = time.monotonic()
urllib.request.urlopen(req, timeout=6)
ms = int((time.monotonic() - t0) * 1000)
ms = int((time.monotonic() - t0) * 1000)
status = "slow" if ms > 3000 else "ok"
except urllib.error.HTTPError as e:
ms = int((time.monotonic() - t0) * 1000)
if 400 <= e.code < 500:
# Server responded — it is reachable but blocking this probe
status = "restricted"
else:
# 5xx: server error — treat as down
ms = 0
status = "down"
except Exception:
ms = 0
status = "down"
self._health_cache[wid] = (status, ms)
col = {"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"down": COLOURS["danger"]}.get(status, COLOURS["text_dim"])
col = {
"ok": COLOURS["success"],
"slow": COLOURS["warning"],
"restricted": COLOURS["warning"],
"down": COLOURS["danger"],
}.get(status, COLOURS["text_dim"])
# Update the dot on the main thread
try: