04/23 Enhance app functionalities
This commit is contained in:
@@ -131,9 +131,10 @@ One row per user per site per day (upserted). `user_note` optional.
|
||||
|
||||
### activity_log
|
||||
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
|
||||
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL,
|
||||
CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL,
|
||||
EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS.
|
||||
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE,
|
||||
ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
|
||||
EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS,
|
||||
CREATE/UPDATE/DELETE_AI_CRITERION.
|
||||
|
||||
### shifts
|
||||
`days_of_week` is a digit string using MySQL DAYOFWEEK: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat.
|
||||
@@ -144,6 +145,30 @@ E.g. "23456" = Mon–Fri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
|
||||
### login_attempts: username, attempted_at, ip_address (indexed)
|
||||
### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
|
||||
|
||||
### ai_criteria
|
||||
Stores evaluation criteria used by the AI to assess opportunity alignment.
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO | |
|
||||
| title | VARCHAR(200) | Short label |
|
||||
| description | TEXT | Full criterion text sent to the AI |
|
||||
| is_active | TINYINT(1) | Inactive criteria excluded from AI prompt |
|
||||
| sort_order | INT | Controls display and prompt ordering |
|
||||
| created_by | INT FK users SET NULL | |
|
||||
|
||||
### ai_analysis_log
|
||||
Persists every AI analysis result for history and audit purposes.
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO | |
|
||||
| user_id | INT FK users SET NULL | |
|
||||
| file_names | TEXT | Comma-separated filenames analyzed |
|
||||
| model | VARCHAR(100) | Groq model used |
|
||||
| verdict | ENUM('PURSUE','PASS','UNCLEAR') NULL | NULL when no criteria active |
|
||||
| criteria_snapshot | TEXT NULL | Active criteria text at time of analysis |
|
||||
| summary_text | MEDIUMTEXT | Full AI response |
|
||||
| analyzed_at | DATETIME | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Module Details
|
||||
@@ -224,6 +249,8 @@ Key internals:
|
||||
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
|
||||
- Auto-expands when editing a site that already has saved credentials
|
||||
- "+ Add Credential" auto-expands the section if collapsed
|
||||
- **Bug fixed:** `_on_visibility_change` previously used `before=self.creds_container`
|
||||
which crosses a widget parent boundary causing a TclError; corrected to `before=self._cred_body`
|
||||
|
||||
### views/user_dashboard_view.py
|
||||
Key internals:
|
||||
@@ -330,8 +357,9 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
||||
|
||||
6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
|
||||
|
||||
7. The email scheduler last_sent_date is in-memory — resets on restart.
|
||||
A production deployment should persist it to the DB.
|
||||
7. The email scheduler `last_sent_date` is now persisted to `config.ini`
|
||||
`[email] last_sent_date` (ISO format). A restart after the configured
|
||||
`send_time` will not re-send the report on the same day.
|
||||
|
||||
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
|
||||
cp1252 UnicodeEncodeError on Windows consoles.
|
||||
|
||||
@@ -39,8 +39,12 @@ class App(tk.Tk):
|
||||
self.current_user = None
|
||||
self._idle_timer = None # after() handle for timeout
|
||||
self._warning_timer = None # after() handle for 1-min warning
|
||||
self._idle_last_reset = 0.0 # monotonic timestamp of last actual reset
|
||||
|
||||
# Bind all user activity events to reset the idle timer
|
||||
# Bind all user activity events to reset the idle timer.
|
||||
# <Motion> fires continuously — the handler throttles itself to at most
|
||||
# once every 5 seconds so we don't schedule/cancel hundreds of after()
|
||||
# handles per second during normal mouse movement.
|
||||
for event in ("<Motion>", "<KeyPress>", "<ButtonPress>", "<MouseWheel>"):
|
||||
self.bind_all(event, self._reset_idle_timer, add="+")
|
||||
|
||||
@@ -333,11 +337,35 @@ class App(tk.Tk):
|
||||
|
||||
# ─── Session Timeout ──────────────────────────────────────────────────────
|
||||
|
||||
# Minimum interval (seconds) between idle-timer resets triggered by
|
||||
# continuous events like <Motion>. KeyPress / ButtonPress always reset
|
||||
# immediately (they are infrequent by nature).
|
||||
_IDLE_THROTTLE_S = 5.0
|
||||
|
||||
def _reset_idle_timer(self, event=None):
|
||||
"""Cancel any pending timeout/warning timers and restart them."""
|
||||
"""Cancel any pending timeout/warning timers and restart them.
|
||||
|
||||
Throttled for <Motion> events: the timer is only rescheduled if at
|
||||
least _IDLE_THROTTLE_S seconds have elapsed since the last reset.
|
||||
This prevents hundreds of after_cancel/after() calls per second
|
||||
during normal mouse movement without affecting correctness.
|
||||
"""
|
||||
if not self.current_user:
|
||||
return # not logged in — nothing to time out
|
||||
|
||||
import time
|
||||
# Throttle continuous motion events; discrete actions always go through
|
||||
if event and getattr(event, "type", None) is not None:
|
||||
try:
|
||||
# EventType.Motion == 6 in tkinter
|
||||
if int(event.type) == 6:
|
||||
now = time.monotonic()
|
||||
if now - self._idle_last_reset < self._IDLE_THROTTLE_S:
|
||||
return
|
||||
self._idle_last_reset = now
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self._idle_timer:
|
||||
self.after_cancel(self._idle_timer)
|
||||
if self._warning_timer:
|
||||
|
||||
@@ -310,6 +310,19 @@ def initialize_database():
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ai_analysis_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT,
|
||||
file_names TEXT NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
verdict ENUM('PURSUE','PASS','UNCLEAR') NULL,
|
||||
criteria_snapshot TEXT NULL,
|
||||
summary_text MEDIUMTEXT NOT NULL,
|
||||
analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ai_criteria (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
|
||||
@@ -891,8 +891,14 @@ def get_shift_report(date_from=None, date_to=None, user_id=None, website_id=None
|
||||
|
||||
def get_unchecked_report(target_date=None, user_id=None):
|
||||
"""
|
||||
Return websites that were NOT checked on target_date (default: today)
|
||||
for the given user (or all users if omitted).
|
||||
Return websites that a user was EXPECTED to check on target_date but did not.
|
||||
|
||||
"Expected" is defined by shift membership on that day-of-week:
|
||||
- Only websites assigned to a shift the user belongs to are included.
|
||||
- Visibility rules (all / assigned) are respected.
|
||||
- When a user has no shifts, falls back to all active visible websites.
|
||||
When no target_date is supplied, defaults to today via CURDATE().
|
||||
|
||||
Columns: check_date, username, full_name, website_name, url, status
|
||||
"""
|
||||
conn = None
|
||||
@@ -902,20 +908,35 @@ def get_unchecked_report(target_date=None, user_id=None):
|
||||
|
||||
user_filter = "AND u.id = %s" if user_id else ""
|
||||
|
||||
# Use a bind param only when a specific date is provided.
|
||||
# When no date is given, embed CURDATE() directly in SQL so MySQL
|
||||
# evaluates it as a function — passing "CURDATE()" as a %s bind
|
||||
# parameter treats it as a literal string, not a SQL function, and
|
||||
# causes the NOT EXISTS filter to match nothing (returns 0 rows).
|
||||
# Embed CURDATE() directly when no date given — passing it as a %s
|
||||
# bind param would treat it as a literal string, not a SQL function.
|
||||
if target_date:
|
||||
date_val = str(target_date)
|
||||
date_expr = "%s"
|
||||
date_params = [date_val]
|
||||
# DAYOFWEEK for a specific date
|
||||
dow_expr = "DAYOFWEEK(%s)"
|
||||
dow_params = [date_val]
|
||||
else:
|
||||
date_expr = "CURDATE()"
|
||||
date_params = []
|
||||
dow_expr = "DAYOFWEEK(CURDATE())"
|
||||
dow_params = []
|
||||
|
||||
params = date_params + ([user_id] if user_id else []) + date_params
|
||||
# One user_id param slot for the user_filter inside the main query
|
||||
user_filter_params = [user_id] if user_id else []
|
||||
|
||||
# The subquery needs: dow_params, (optional user_id for shift_users join)
|
||||
# Outer query needs: date_params, user_filter_params, date_params
|
||||
# EXISTS(shift) subquery: dow_params + (user_id if filtering by user)
|
||||
# We build params carefully to match the f-string placeholders below.
|
||||
params = (
|
||||
date_params # {date_expr} in SELECT
|
||||
+ user_filter_params # {user_filter} AND u.id = %s
|
||||
+ dow_params # DAYOFWEEK(%s) in shift EXISTS
|
||||
+ (user_id and [user_id] or []) # su.user_id=%s in shift EXISTS
|
||||
+ date_params # DATE(sc.checked_at) = {date_expr}
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
@@ -926,12 +947,31 @@ def get_unchecked_report(target_date=None, user_id=None):
|
||||
w.name AS website_name,
|
||||
w.url,
|
||||
'Not Checked' AS status
|
||||
FROM websites w
|
||||
CROSS JOIN users u
|
||||
WHERE w.is_active = 1
|
||||
AND u.is_active = 1
|
||||
FROM users u
|
||||
-- Only websites the user was expected to check on this date
|
||||
JOIN (
|
||||
SELECT DISTINCT sw.website_id
|
||||
FROM shift_websites sw
|
||||
JOIN shifts s ON s.id = sw.shift_id
|
||||
JOIN shift_users su ON su.shift_id = s.id
|
||||
JOIN websites w2 ON w2.id = sw.website_id
|
||||
WHERE s.is_active = 1
|
||||
AND w2.is_active = 1
|
||||
AND LOCATE(CAST({dow_expr} AS CHAR), s.days_of_week) > 0
|
||||
{"AND su.user_id = %s" if user_id else "AND su.user_id = u.id"}
|
||||
AND (
|
||||
w2.visibility = 'all'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM website_users wu
|
||||
WHERE wu.website_id = w2.id AND wu.user_id = su.user_id
|
||||
)
|
||||
)
|
||||
) expected ON 1=1
|
||||
JOIN websites w ON w.id = expected.website_id
|
||||
WHERE u.is_active = 1
|
||||
AND u.role = 'user'
|
||||
{user_filter}
|
||||
-- Exclude sites the user DID check on the target date
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM shift_checks sc
|
||||
WHERE sc.website_id = w.id
|
||||
@@ -1461,7 +1501,12 @@ def create_criterion(admin_id: int, title: str, description: str,
|
||||
|
||||
def update_criterion(admin_id: int, criterion_id: int, title: str,
|
||||
description: str, is_active: bool, sort_order: int):
|
||||
"""Update an existing AI evaluation criterion."""
|
||||
"""Update an existing AI evaluation criterion.
|
||||
|
||||
The full description text is included in the activity_log detail field so
|
||||
there is a complete audit trail of exactly what criteria wording the AI was
|
||||
evaluating against at any point in time.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_connection()
|
||||
@@ -1476,8 +1521,13 @@ def update_criterion(admin_id: int, criterion_id: int, title: str,
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
# Include full description in detail so audit log captures wording at
|
||||
# time of change — essential for reconstructing what criteria were
|
||||
# active during any historical AI analysis.
|
||||
log_action(admin_id, "UPDATE_AI_CRITERION", "ai_criteria", criterion_id,
|
||||
f"Updated criterion id={criterion_id} '{title}' active={is_active}.")
|
||||
f"Updated criterion id={criterion_id} '{title}' "
|
||||
f"active={is_active} order={sort_order}. "
|
||||
f"Description: {description[:500]}")
|
||||
logger.info(f"AI criterion id={criterion_id} updated by admin_id={admin_id}.")
|
||||
finally:
|
||||
if conn:
|
||||
@@ -1502,3 +1552,107 @@ def delete_criterion(admin_id: int, criterion_id: int):
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── AI Analysis History ──────────────────────────────────────────────────────
|
||||
|
||||
def save_ai_analysis(user_id: int, file_names: str, model: str,
|
||||
verdict: str | None, criteria_snapshot: str | None,
|
||||
summary_text: str) -> int:
|
||||
"""
|
||||
Persist an AI analysis result to ai_analysis_log.
|
||||
verdict : 'PURSUE' | 'PASS' | 'UNCLEAR' | None
|
||||
criteria_snapshot: JSON/text of active criteria at time of analysis
|
||||
Returns the new row id.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ai_analysis_log
|
||||
(user_id, file_names, model, verdict, criteria_snapshot, summary_text)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(user_id, file_names, model, verdict, criteria_snapshot, summary_text)
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close()
|
||||
logger.info(
|
||||
f"AI analysis saved: id={new_id} user_id={user_id} "
|
||||
f"verdict={verdict} files='{file_names[:80]}'."
|
||||
)
|
||||
return new_id
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_ai_analysis_history(user_id: int | None = None, limit: int = 100):
|
||||
"""
|
||||
Return recent AI analysis log entries.
|
||||
When user_id is provided, filters to that user's own analyses.
|
||||
Admins pass user_id=None to see all users' analyses.
|
||||
Columns: id, username, file_names, model, verdict, analyzed_at
|
||||
(summary_text excluded for list view — fetch by id for detail).
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
if user_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT al.id, u.username, al.file_names, al.model,
|
||||
al.verdict, al.analyzed_at
|
||||
FROM ai_analysis_log al
|
||||
LEFT JOIN users u ON u.id = al.user_id
|
||||
WHERE al.user_id = %s
|
||||
ORDER BY al.analyzed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(user_id, limit)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT al.id, u.username, al.file_names, al.model,
|
||||
al.verdict, al.analyzed_at
|
||||
FROM ai_analysis_log al
|
||||
LEFT JOIN users u ON u.id = al.user_id
|
||||
ORDER BY al.analyzed_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return rows
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_ai_analysis_detail(analysis_id: int):
|
||||
"""Return a single ai_analysis_log row including summary_text and criteria_snapshot."""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT al.*, u.username
|
||||
FROM ai_analysis_log al
|
||||
LEFT JOIN users u ON u.id = al.user_id
|
||||
WHERE al.id = %s
|
||||
""",
|
||||
(analysis_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
@@ -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"],
|
||||
dot_col = {
|
||||
"ok": COLOURS["success"],
|
||||
"slow": COLOURS["warning"],
|
||||
"down": COLOURS["danger"]}.get(status_str, COLOURS["text_dim"])
|
||||
dot_tip = {"ok": f"Reachable ({ms}ms)",
|
||||
"restricted": 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")
|
||||
"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://")):
|
||||
@@ -438,14 +457,26 @@ class UserDashboardView(ttk.Frame):
|
||||
urllib.request.urlopen(req, timeout=6)
|
||||
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"],
|
||||
col = {
|
||||
"ok": COLOURS["success"],
|
||||
"slow": COLOURS["warning"],
|
||||
"down": COLOURS["danger"]}.get(status, COLOURS["text_dim"])
|
||||
"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