04/23 Enhance app functionalities
This commit is contained in:
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user