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
|
### activity_log
|
||||||
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
|
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
|
||||||
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL,
|
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD, CREATE/UPDATE/DELETE_WEBSITE,
|
||||||
CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL,
|
ADD/REMOVE_CREDENTIAL, CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
|
||||||
EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS.
|
EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS,
|
||||||
|
CREATE/UPDATE/DELETE_AI_CRITERION.
|
||||||
|
|
||||||
### shifts
|
### 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.
|
`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)
|
### login_attempts: username, attempted_at, ip_address (indexed)
|
||||||
### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
|
### 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
|
## 5. Module Details
|
||||||
@@ -224,6 +249,8 @@ Key internals:
|
|||||||
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
|
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
|
||||||
- Auto-expands when editing a site that already has saved credentials
|
- Auto-expands when editing a site that already has saved credentials
|
||||||
- "+ Add Credential" auto-expands the section if collapsed
|
- "+ 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
|
### views/user_dashboard_view.py
|
||||||
Key internals:
|
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).
|
6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
|
||||||
|
|
||||||
7. The email scheduler last_sent_date is in-memory — resets on restart.
|
7. The email scheduler `last_sent_date` is now persisted to `config.ini`
|
||||||
A production deployment should persist it to the DB.
|
`[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
|
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
|
||||||
cp1252 UnicodeEncodeError on Windows consoles.
|
cp1252 UnicodeEncodeError on Windows consoles.
|
||||||
|
|||||||
@@ -39,8 +39,12 @@ class App(tk.Tk):
|
|||||||
self.current_user = None
|
self.current_user = None
|
||||||
self._idle_timer = None # after() handle for timeout
|
self._idle_timer = None # after() handle for timeout
|
||||||
self._warning_timer = None # after() handle for 1-min warning
|
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>"):
|
for event in ("<Motion>", "<KeyPress>", "<ButtonPress>", "<MouseWheel>"):
|
||||||
self.bind_all(event, self._reset_idle_timer, add="+")
|
self.bind_all(event, self._reset_idle_timer, add="+")
|
||||||
|
|
||||||
@@ -333,11 +337,35 @@ class App(tk.Tk):
|
|||||||
|
|
||||||
# ─── Session Timeout ──────────────────────────────────────────────────────
|
# ─── 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):
|
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:
|
if not self.current_user:
|
||||||
return # not logged in — nothing to time out
|
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:
|
if self._idle_timer:
|
||||||
self.after_cancel(self._idle_timer)
|
self.after_cancel(self._idle_timer)
|
||||||
if self._warning_timer:
|
if self._warning_timer:
|
||||||
|
|||||||
@@ -310,6 +310,19 @@ def initialize_database():
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS ai_criteria (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
title VARCHAR(200) NOT NULL,
|
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):
|
def get_unchecked_report(target_date=None, user_id=None):
|
||||||
"""
|
"""
|
||||||
Return websites that were NOT checked on target_date (default: today)
|
Return websites that a user was EXPECTED to check on target_date but did not.
|
||||||
for the given user (or all users if omitted).
|
|
||||||
|
"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
|
Columns: check_date, username, full_name, website_name, url, status
|
||||||
"""
|
"""
|
||||||
conn = None
|
conn = None
|
||||||
@@ -902,36 +908,70 @@ def get_unchecked_report(target_date=None, user_id=None):
|
|||||||
|
|
||||||
user_filter = "AND u.id = %s" if user_id else ""
|
user_filter = "AND u.id = %s" if user_id else ""
|
||||||
|
|
||||||
# Use a bind param only when a specific date is provided.
|
# Embed CURDATE() directly when no date given — passing it as a %s
|
||||||
# When no date is given, embed CURDATE() directly in SQL so MySQL
|
# bind param would treat it as a literal string, not a SQL function.
|
||||||
# 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).
|
|
||||||
if target_date:
|
if target_date:
|
||||||
date_val = str(target_date)
|
date_val = str(target_date)
|
||||||
date_expr = "%s"
|
date_expr = "%s"
|
||||||
date_params = [date_val]
|
date_params = [date_val]
|
||||||
|
# DAYOFWEEK for a specific date
|
||||||
|
dow_expr = "DAYOFWEEK(%s)"
|
||||||
|
dow_params = [date_val]
|
||||||
else:
|
else:
|
||||||
date_expr = "CURDATE()"
|
date_expr = "CURDATE()"
|
||||||
date_params = []
|
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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
{date_expr} AS check_date,
|
{date_expr} AS check_date,
|
||||||
u.username,
|
u.username,
|
||||||
COALESCE(u.full_name, u.username) AS full_name,
|
COALESCE(u.full_name, u.username) AS full_name,
|
||||||
w.name AS website_name,
|
w.name AS website_name,
|
||||||
w.url,
|
w.url,
|
||||||
'Not Checked' AS status
|
'Not Checked' AS status
|
||||||
FROM websites w
|
FROM users u
|
||||||
CROSS JOIN users u
|
-- Only websites the user was expected to check on this date
|
||||||
WHERE w.is_active = 1
|
JOIN (
|
||||||
AND u.is_active = 1
|
SELECT DISTINCT sw.website_id
|
||||||
AND u.role = 'user'
|
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}
|
{user_filter}
|
||||||
|
-- Exclude sites the user DID check on the target date
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM shift_checks sc
|
SELECT 1 FROM shift_checks sc
|
||||||
WHERE sc.website_id = w.id
|
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,
|
def update_criterion(admin_id: int, criterion_id: int, title: str,
|
||||||
description: str, is_active: bool, sort_order: int):
|
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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -1476,8 +1521,13 @@ def update_criterion(admin_id: int, criterion_id: int, title: str,
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
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,
|
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}.")
|
logger.info(f"AI criterion id={criterion_id} updated by admin_id={admin_id}.")
|
||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
@@ -1502,3 +1552,107 @@ def delete_criterion(admin_id: int, criterion_id: int):
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
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):
|
def __init__(self, parent, current_user: dict):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.current_user = current_user
|
self.current_user = current_user
|
||||||
|
self._show_inactive = tk.BooleanVar(value=False)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._load_shifts()
|
self._load_shifts()
|
||||||
|
|
||||||
@@ -56,6 +57,15 @@ class AdminShiftsView(ttk.Frame):
|
|||||||
style="Ghost.TButton",
|
style="Ghost.TButton",
|
||||||
command=self._export_pdf).pack(side="right", padx=(0, 12))
|
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",
|
cols = ("ID", "Shift Name", "Days", "Start", "End",
|
||||||
"Users", "Websites", "Active", "Note")
|
"Users", "Websites", "Active", "Note")
|
||||||
self.tree = ttk.Treeview(self, columns=cols,
|
self.tree = ttk.Treeview(self, columns=cols,
|
||||||
@@ -80,8 +90,13 @@ class AdminShiftsView(ttk.Frame):
|
|||||||
def _load_shifts(self):
|
def _load_shifts(self):
|
||||||
from models import get_all_shifts
|
from models import get_all_shifts
|
||||||
self.tree.delete(*self.tree.get_children())
|
self.tree.delete(*self.tree.get_children())
|
||||||
|
show_inactive = self._show_inactive.get()
|
||||||
try:
|
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"])
|
days_str = _days_label(s["days_of_week"])
|
||||||
active = "✔" if s["is_active"] else "✘"
|
active = "✔" if s["is_active"] else "✘"
|
||||||
tag = "active" if s["is_active"] else "inactive"
|
tag = "active" if s["is_active"] else "inactive"
|
||||||
@@ -99,6 +114,10 @@ class AdminShiftsView(ttk.Frame):
|
|||||||
(s["note"] or "")[:60],
|
(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:
|
except Exception as e:
|
||||||
show_error(f"Failed to load shifts:\n{e}")
|
show_error(f"Failed to load shifts:\n{e}")
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class AdminUsersView(ttk.Frame):
|
|||||||
ttk.Button(toolbar, text="✕ Delete",
|
ttk.Button(toolbar, text="✕ Delete",
|
||||||
style="Danger.TButton",
|
style="Danger.TButton",
|
||||||
command=self._delete_selected).pack(side="right")
|
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
|
# Treeview
|
||||||
cols = ("ID", "Username", "Full Name", "Role", "Active", "Created")
|
cols = ("ID", "Username", "Full Name", "Role", "Active", "Created")
|
||||||
@@ -76,6 +79,82 @@ class AdminUsersView(ttk.Frame):
|
|||||||
|
|
||||||
# ─── Dialogs ──────────────────────────────────────────────────────────────
|
# ─── 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):
|
def _open_add_dialog(self):
|
||||||
UserDialog(self, self.current_user, user_data=None,
|
UserDialog(self, self.current_user, user_data=None,
|
||||||
on_save=self._load_users)
|
on_save=self._load_users)
|
||||||
|
|||||||
@@ -939,6 +939,10 @@ class CriterionDialog(tk.Toplevel):
|
|||||||
y = (self.winfo_screenheight() - h) // 2
|
y = (self.winfo_screenheight() - h) // 2
|
||||||
self.geometry(f"{w}x{h}+{x}+{y}")
|
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):
|
def _build_ui(self):
|
||||||
C = COLOURS
|
C = COLOURS
|
||||||
|
|
||||||
@@ -950,14 +954,14 @@ class CriterionDialog(tk.Toplevel):
|
|||||||
form.pack(fill="x", padx=24, pady=8)
|
form.pack(fill="x", padx=24, pady=8)
|
||||||
form.columnconfigure(1, weight=1)
|
form.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
# Title
|
# Row 0 — Title
|
||||||
ttk.Label(form, text="Title *").grid(
|
ttk.Label(form, text="Title *").grid(
|
||||||
row=0, column=0, sticky="w", padx=(0, 10), pady=6)
|
row=0, column=0, sticky="w", padx=(0, 10), pady=6)
|
||||||
self._title_var = tk.StringVar()
|
self._title_var = tk.StringVar()
|
||||||
ttk.Entry(form, textvariable=self._title_var).grid(
|
ttk.Entry(form, textvariable=self._title_var).grid(
|
||||||
row=0, column=1, sticky="ew", pady=6)
|
row=0, column=1, sticky="ew", pady=6)
|
||||||
|
|
||||||
# Description
|
# Row 1 — Description text area
|
||||||
ttk.Label(form, text="Description *").grid(
|
ttk.Label(form, text="Description *").grid(
|
||||||
row=1, column=0, sticky="nw", padx=(0, 10), pady=6)
|
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)
|
self._desc_txt.pack(fill="both", expand=True, padx=4, pady=4)
|
||||||
desc_vsb.config(command=self._desc_txt.yview)
|
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(
|
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")
|
self._order_var = tk.StringVar(value="0")
|
||||||
ttk.Spinbox(form, from_=0, to=999,
|
ttk.Spinbox(form, from_=0, to=999,
|
||||||
textvariable=self._order_var, width=8).grid(
|
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)
|
self._active_var = tk.BooleanVar(value=True)
|
||||||
tk.Checkbutton(
|
tk.Checkbutton(
|
||||||
form, text="Active (included in AI evaluation)",
|
form, text="Active (included in AI evaluation)",
|
||||||
@@ -991,7 +1019,7 @@ class CriterionDialog(tk.Toplevel):
|
|||||||
bg=C["bg"], fg=C["text"],
|
bg=C["bg"], fg=C["text"],
|
||||||
activebackground=C["bg"], activeforeground=C["accent"],
|
activebackground=C["bg"], activeforeground=C["accent"],
|
||||||
selectcolor=C["surface2"], font=FONT, cursor="hand2",
|
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(
|
tk.Label(
|
||||||
self,
|
self,
|
||||||
@@ -1018,6 +1046,8 @@ class CriterionDialog(tk.Toplevel):
|
|||||||
self._desc_txt.insert("1.0", d.get("description") or "")
|
self._desc_txt.insert("1.0", d.get("description") or "")
|
||||||
self._order_var.set(str(d.get("sort_order", 0)))
|
self._order_var.set(str(d.get("sort_order", 0)))
|
||||||
self._active_var.set(bool(d.get("is_active", True)))
|
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):
|
def _save(self):
|
||||||
title = self._title_var.get().strip()
|
title = self._title_var.get().strip()
|
||||||
|
|||||||
@@ -265,12 +265,18 @@ class UserDashboardView(ttk.Frame):
|
|||||||
cached = self._health_cache.get(wid)
|
cached = self._health_cache.get(wid)
|
||||||
if cached:
|
if cached:
|
||||||
status_str, ms = cached
|
status_str, ms = cached
|
||||||
dot_col = {"ok": COLOURS["success"],
|
dot_col = {
|
||||||
"slow": COLOURS["warning"],
|
"ok": COLOURS["success"],
|
||||||
"down": COLOURS["danger"]}.get(status_str, COLOURS["text_dim"])
|
"slow": COLOURS["warning"],
|
||||||
dot_tip = {"ok": f"Reachable ({ms}ms)",
|
"restricted": COLOURS["warning"],
|
||||||
"slow": f"Slow ({ms}ms)",
|
"down": COLOURS["danger"],
|
||||||
"down": "Unreachable"}.get(status_str, "Unknown")
|
}.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:
|
else:
|
||||||
dot_col = COLOURS["text_dim"]
|
dot_col = COLOURS["text_dim"]
|
||||||
dot_tip = "Checking..."
|
dot_tip = "Checking..."
|
||||||
@@ -424,8 +430,21 @@ class UserDashboardView(ttk.Frame):
|
|||||||
# ─── Health Pre-check ─────────────────────────────────────────────────────
|
# ─── Health Pre-check ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _check_site_health(self, wid: int, url: str, dot_label: tk.Label):
|
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.request
|
||||||
|
import urllib.error
|
||||||
import time
|
import time
|
||||||
|
|
||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
@@ -436,16 +455,28 @@ class UserDashboardView(ttk.Frame):
|
|||||||
req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe")
|
req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe")
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
urllib.request.urlopen(req, timeout=6)
|
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"
|
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:
|
except Exception:
|
||||||
ms = 0
|
ms = 0
|
||||||
status = "down"
|
status = "down"
|
||||||
|
|
||||||
self._health_cache[wid] = (status, ms)
|
self._health_cache[wid] = (status, ms)
|
||||||
col = {"ok": COLOURS["success"],
|
col = {
|
||||||
"slow": COLOURS["warning"],
|
"ok": COLOURS["success"],
|
||||||
"down": COLOURS["danger"]}.get(status, COLOURS["text_dim"])
|
"slow": COLOURS["warning"],
|
||||||
|
"restricted": COLOURS["warning"],
|
||||||
|
"down": COLOURS["danger"],
|
||||||
|
}.get(status, COLOURS["text_dim"])
|
||||||
|
|
||||||
# Update the dot on the main thread
|
# Update the dot on the main thread
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user