04/23 Enhance app functionalities 2
This commit is contained in:
@@ -222,7 +222,7 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
|
|||||||
- last_sent_date in-memory (resets on restart)
|
- last_sent_date in-memory (resets on restart)
|
||||||
- start() / stop() called from app.py on login/logout (admin only)
|
- start() / stop() called from app.py on login/logout (admin only)
|
||||||
|
|
||||||
### views/ai_summary_view.py *(NEW)*
|
### views/ai_summary_view.py
|
||||||
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
|
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
|
||||||
|
|
||||||
Key internals:
|
Key internals:
|
||||||
@@ -238,6 +238,15 @@ Key internals:
|
|||||||
Key Deadlines & Action Items
|
Key Deadlines & Action Items
|
||||||
- Groq API key stored DPAPI-encrypted in config.ini [groq] section
|
- Groq API key stored DPAPI-encrypted in config.ini [groq] section
|
||||||
- max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
|
- max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
|
||||||
|
- **Evaluation Criteria panel** (collapsible): admin can add/edit/delete criteria;
|
||||||
|
users see read-only list. Active criteria are appended to the AI prompt as an
|
||||||
|
alignment evaluation section. The AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR
|
||||||
|
which is parsed and displayed as a colour-coded verdict banner.
|
||||||
|
- **History tab**: every analysis is saved to ai_analysis_log (with verdict + criteria
|
||||||
|
snapshot). History treeview is filterable by verdict. Double-click restores full
|
||||||
|
output in the Analyze tab. Admins see all users; regular users see only their own.
|
||||||
|
- CriterionDialog has a live character counter with 500-char soft limit guidance.
|
||||||
|
- URL open and health-check probe both guarded by _validate_and_normalise_url.
|
||||||
|
|
||||||
.doc reading (legacy binary Word format) — 3-tier fallback:
|
.doc reading (legacy binary Word format) — 3-tier fallback:
|
||||||
1. win32com (Word COM automation — requires MS Word installed)
|
1. win32com (Word COM automation — requires MS Word installed)
|
||||||
@@ -335,6 +344,10 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
|
|||||||
| utils/crypto.py | _ITERATIONS | 100,000 |
|
| utils/crypto.py | _ITERATIONS | 100,000 |
|
||||||
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
|
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
|
||||||
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
|
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
|
||||||
|
| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) |
|
||||||
|
| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) |
|
||||||
|
| admin_users_view.py | _PasswordResetDialog._AUTO_CLOSE_S | 120 (seconds before auto-close) |
|
||||||
|
| admin_users_view.py | _PasswordResetDialog._CLIP_CLEAR_S | 30 (seconds before clipboard wipe) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,24 @@ def initialize_database():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Migration: added visibility column to websites table.")
|
logger.info("Migration: added visibility column to websites table.")
|
||||||
|
|
||||||
|
# Confirm creation of new tables added post-initial-deployment
|
||||||
|
# These use CREATE TABLE IF NOT EXISTS so they are safe on first run.
|
||||||
|
# We log confirmation so operators can verify the upgrade applied.
|
||||||
|
for new_table in ("ai_criteria", "ai_analysis_log"):
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = %s
|
||||||
|
""",
|
||||||
|
(new_table,)
|
||||||
|
)
|
||||||
|
(table_exists,) = cursor.fetchone()
|
||||||
|
if table_exists:
|
||||||
|
logger.info(f"Table '{new_table}' confirmed present.")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Table '{new_table}' was not created — check DDL.")
|
||||||
|
|
||||||
# Seed default admin if users table is empty
|
# Seed default admin if users table is empty
|
||||||
cursor.execute("SELECT COUNT(*) FROM users")
|
cursor.execute("SELECT COUNT(*) FROM users")
|
||||||
(count,) = cursor.fetchone()
|
(count,) = cursor.fetchone()
|
||||||
|
|||||||
Binary file not shown.
@@ -993,7 +993,17 @@ def get_unchecked_report(target_date=None, user_id=None):
|
|||||||
|
|
||||||
def get_summary_report(date_from=None, date_to=None):
|
def get_summary_report(date_from=None, date_to=None):
|
||||||
"""
|
"""
|
||||||
Per-user per-day summary: total sites checked vs total active sites.
|
Per-user per-day summary: sites checked vs the sites that user was
|
||||||
|
expected to check on that specific day (shift-scoped total).
|
||||||
|
|
||||||
|
The previous implementation used a global COUNT(*) of all active websites
|
||||||
|
as the denominator, producing misleading percentages — a user in a 3-site
|
||||||
|
shift who checked all 3 would show 15% against 20 global sites.
|
||||||
|
|
||||||
|
The corrected subquery counts the distinct websites in the shifts the user
|
||||||
|
was assigned to that ran on the check_date's day-of-week. For historical
|
||||||
|
dates this still uses DAYOFWEEK(check_date) to match the shift schedule.
|
||||||
|
|
||||||
Columns: check_date, username, full_name, checked_count, total_sites, pct_complete
|
Columns: check_date, username, full_name, checked_count, total_sites, pct_complete
|
||||||
"""
|
"""
|
||||||
conn = None
|
conn = None
|
||||||
@@ -1015,16 +1025,38 @@ def get_summary_report(date_from=None, date_to=None):
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
DATE(sc.checked_at) AS check_date,
|
DATE(sc.checked_at) 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,
|
||||||
COUNT(DISTINCT sc.website_id) AS checked_count,
|
COUNT(DISTINCT sc.website_id) AS checked_count,
|
||||||
(SELECT COUNT(*) FROM websites WHERE is_active=1) AS total_sites,
|
(
|
||||||
|
SELECT COUNT(DISTINCT sw2.website_id)
|
||||||
|
FROM shift_websites sw2
|
||||||
|
JOIN shifts s2 ON s2.id = sw2.shift_id
|
||||||
|
JOIN shift_users su2 ON su2.shift_id = s2.id
|
||||||
|
AND su2.user_id = u.id
|
||||||
|
WHERE s2.is_active = 1
|
||||||
|
AND LOCATE(
|
||||||
|
CAST(DAYOFWEEK(DATE(sc.checked_at)) AS CHAR),
|
||||||
|
s2.days_of_week
|
||||||
|
) > 0
|
||||||
|
) AS total_sites,
|
||||||
ROUND(
|
ROUND(
|
||||||
COUNT(DISTINCT sc.website_id) * 100.0 /
|
COUNT(DISTINCT sc.website_id) * 100.0 /
|
||||||
NULLIF((SELECT COUNT(*) FROM websites WHERE is_active=1), 0),
|
NULLIF((
|
||||||
|
SELECT COUNT(DISTINCT sw2.website_id)
|
||||||
|
FROM shift_websites sw2
|
||||||
|
JOIN shifts s2 ON s2.id = sw2.shift_id
|
||||||
|
JOIN shift_users su2 ON su2.shift_id = s2.id
|
||||||
|
AND su2.user_id = u.id
|
||||||
|
WHERE s2.is_active = 1
|
||||||
|
AND LOCATE(
|
||||||
|
CAST(DAYOFWEEK(DATE(sc.checked_at)) AS CHAR),
|
||||||
|
s2.days_of_week
|
||||||
|
) > 0
|
||||||
|
), 0),
|
||||||
1
|
1
|
||||||
) AS pct_complete
|
) AS pct_complete
|
||||||
FROM shift_checks sc
|
FROM shift_checks sc
|
||||||
JOIN users u ON u.id = sc.user_id
|
JOIN users u ON u.id = sc.user_id
|
||||||
{where_clause}
|
{where_clause}
|
||||||
@@ -1430,17 +1462,22 @@ def get_unchecked_sites_for_user(user_id: int):
|
|||||||
# ─── AI Criteria CRUD ─────────────────────────────────────────────────────────
|
# ─── AI Criteria CRUD ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_all_criteria():
|
def get_all_criteria():
|
||||||
"""Return all AI evaluation criteria ordered by sort_order, then id."""
|
"""Return all AI evaluation criteria ordered by sort_order, then id.
|
||||||
|
|
||||||
|
The creator username is intentionally omitted — the criteria treeview does
|
||||||
|
not display it and the LEFT JOIN was adding a needless per-call cost.
|
||||||
|
If a creator column is ever added to the UI, restore the JOIN here.
|
||||||
|
"""
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor(dictionary=True)
|
cur = conn.cursor(dictionary=True)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT c.*, u.username AS creator
|
SELECT id, title, description, is_active, sort_order,
|
||||||
FROM ai_criteria c
|
created_by, created_at, updated_at
|
||||||
LEFT JOIN users u ON u.id = c.created_by
|
FROM ai_criteria
|
||||||
ORDER BY c.sort_order, c.id
|
ORDER BY sort_order, id
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|||||||
+19
-1
@@ -111,7 +111,14 @@ def _build_html_report() -> str:
|
|||||||
rows = stats.get("user_stats", [])
|
rows = stats.get("user_stats", [])
|
||||||
|
|
||||||
table_rows = ""
|
table_rows = ""
|
||||||
|
skipped_no_shift = 0
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
total = int(r.get("total_sites") or 0)
|
||||||
|
# Skip users with no shifts scheduled today — they have no expected
|
||||||
|
# work for this day and showing them as "0 / 0 — 0%" is misleading.
|
||||||
|
if total == 0:
|
||||||
|
skipped_no_shift += 1
|
||||||
|
continue
|
||||||
pct = float(r.get("pct_complete") or 0)
|
pct = float(r.get("pct_complete") or 0)
|
||||||
color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f"
|
color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f"
|
||||||
table_rows += (
|
table_rows += (
|
||||||
@@ -119,11 +126,21 @@ def _build_html_report() -> str:
|
|||||||
f"<td style='padding:8px 12px'>{r['username']}</td>"
|
f"<td style='padding:8px 12px'>{r['username']}</td>"
|
||||||
f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>"
|
f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>"
|
||||||
f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>"
|
f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>"
|
||||||
f"<td style='padding:8px 12px;text-align:center'>{int(r['total_sites'] or 0)}</td>"
|
f"<td style='padding:8px 12px;text-align:center'>{total}</td>"
|
||||||
f"<td style='padding:8px 12px;text-align:center;"
|
f"<td style='padding:8px 12px;text-align:center;"
|
||||||
f"color:{color};font-weight:bold'>{pct:.0f}%</td>"
|
f"color:{color};font-weight:bold'>{pct:.0f}%</td>"
|
||||||
f"</tr>"
|
f"</tr>"
|
||||||
)
|
)
|
||||||
|
if not table_rows:
|
||||||
|
table_rows = (
|
||||||
|
"<tr><td colspan='5' style='padding:12px;color:#6b6b80;"
|
||||||
|
"text-align:center'>No users with active shifts today.</td></tr>"
|
||||||
|
)
|
||||||
|
no_shift_note = (
|
||||||
|
f"<p style='color:#6b6b80;font-size:11px'>"
|
||||||
|
f"{skipped_no_shift} user(s) had no shifts scheduled today and are "
|
||||||
|
f"excluded from this report.</p>"
|
||||||
|
) if skipped_no_shift else ""
|
||||||
|
|
||||||
return f"""
|
return f"""
|
||||||
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
|
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
|
||||||
@@ -142,6 +159,7 @@ def _build_html_report() -> str:
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>{table_rows}</tbody>
|
<tbody>{table_rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{no_shift_note}
|
||||||
<p style="color:#6b6b80;font-size:12px;margin-top:24px">
|
<p style="color:#6b6b80;font-size:12px;margin-top:24px">
|
||||||
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
|
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+171
-10
@@ -6,7 +6,7 @@ import tkinter as tk
|
|||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
import logging
|
import logging
|
||||||
from utils.ui_helpers import (
|
from utils.ui_helpers import (
|
||||||
COLOURS, FONT, FONT_BOLD, FONT_HEADING,
|
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
|
||||||
show_error, show_info, confirm_delete
|
show_error, show_info, confirm_delete
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,15 +143,9 @@ class AdminUsersView(ttk.Frame):
|
|||||||
f"Password reset for user id={uid} '{username}' "
|
f"Password reset for user id={uid} '{username}' "
|
||||||
f"by admin '{self.current_user['username']}'."
|
f"by admin '{self.current_user['username']}'."
|
||||||
)
|
)
|
||||||
# Copy to clipboard
|
# Show the temporary password in a purpose-built secure dialog
|
||||||
self.clipboard_clear()
|
# instead of show_info (which leaves the password visible indefinitely)
|
||||||
self.clipboard_append(pwd)
|
_PasswordResetDialog(self, username=username, password=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:
|
except Exception as e:
|
||||||
show_error(f"Password reset failed:\n{e}")
|
show_error(f"Password reset failed:\n{e}")
|
||||||
|
|
||||||
@@ -296,3 +290,170 @@ class UserDialog(tk.Toplevel):
|
|||||||
self.destroy()
|
self.destroy()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
show_error(f"Save failed:\n{e}")
|
show_error(f"Save failed:\n{e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Secure Password Reset Dialog ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _PasswordResetDialog(tk.Toplevel):
|
||||||
|
"""
|
||||||
|
Purpose-built dialog for displaying a freshly-generated temporary password.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Password field is masked by default; admin can reveal with 👁 toggle.
|
||||||
|
- 📋 Copy button copies to clipboard with a 2-second "Copied!" flash.
|
||||||
|
- Clipboard is auto-cleared after 30 seconds for security.
|
||||||
|
- Dialog auto-closes after 120 seconds with a live countdown so the
|
||||||
|
password cannot sit on screen indefinitely.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_AUTO_CLOSE_S = 120 # seconds before auto-close
|
||||||
|
_CLIP_CLEAR_S = 30 # seconds before clipboard is wiped
|
||||||
|
|
||||||
|
def __init__(self, parent, username: str, password: str):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._password = password
|
||||||
|
self._username = username
|
||||||
|
self._remaining = self._AUTO_CLOSE_S
|
||||||
|
self._clip_job = None
|
||||||
|
self._tick_job = None
|
||||||
|
self._revealed = False
|
||||||
|
|
||||||
|
self.title("Temporary Password")
|
||||||
|
self.configure(bg=COLOURS["bg"])
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.grab_set()
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self._close)
|
||||||
|
self._build_ui()
|
||||||
|
self._centre()
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
def _centre(self):
|
||||||
|
self.update_idletasks()
|
||||||
|
w, h = 480, 300
|
||||||
|
x = (self.winfo_screenwidth() - w) // 2
|
||||||
|
y = (self.winfo_screenheight() - h) // 2
|
||||||
|
self.geometry(f"{w}x{h}+{x}+{y}")
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
C = COLOURS
|
||||||
|
pad = dict(padx=24, pady=8)
|
||||||
|
|
||||||
|
tk.Label(self, text="🔑 Password Reset",
|
||||||
|
bg=C["bg"], fg=C["text"],
|
||||||
|
font=FONT_BOLD).pack(anchor="w", **pad)
|
||||||
|
|
||||||
|
tk.Label(
|
||||||
|
self,
|
||||||
|
text=f"A temporary password has been generated for '{self._username}'.\n"
|
||||||
|
"Share it securely — the user must change it on first login.",
|
||||||
|
bg=C["bg"], fg=C["text_dim"],
|
||||||
|
font=FONT_SMALL, justify="left", wraplength=430,
|
||||||
|
).pack(anchor="w", padx=24, pady=(0, 8))
|
||||||
|
|
||||||
|
# Password row
|
||||||
|
pw_frame = tk.Frame(self, bg=C["surface2"], padx=8, pady=8)
|
||||||
|
pw_frame.pack(fill="x", padx=24, pady=(0, 8))
|
||||||
|
|
||||||
|
self._pw_var = tk.StringVar(value=self._password)
|
||||||
|
self._pw_entry = tk.Entry(
|
||||||
|
pw_frame, textvariable=self._pw_var,
|
||||||
|
show="•", state="readonly",
|
||||||
|
readonlybackground=C["surface2"], fg=C["text"],
|
||||||
|
relief="flat", font=(FONT_BOLD[0], 14),
|
||||||
|
)
|
||||||
|
self._pw_entry.pack(side="left", fill="x", expand=True)
|
||||||
|
|
||||||
|
tk.Button(
|
||||||
|
pw_frame, text="👁",
|
||||||
|
command=self._toggle_reveal,
|
||||||
|
bg=C["surface2"], fg=C["text_dim"],
|
||||||
|
activebackground=C["surface"], activeforeground=C["text"],
|
||||||
|
relief="flat", font=FONT_SMALL, cursor="hand2", padx=6,
|
||||||
|
).pack(side="left", padx=(6, 0))
|
||||||
|
|
||||||
|
self._copy_btn = tk.Button(
|
||||||
|
pw_frame, text="📋 Copy",
|
||||||
|
command=self._copy,
|
||||||
|
bg=C["accent"], fg=C["white"],
|
||||||
|
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||||||
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
|
padx=10, pady=4,
|
||||||
|
)
|
||||||
|
self._copy_btn.pack(side="left", padx=(6, 0))
|
||||||
|
|
||||||
|
# Countdown label
|
||||||
|
self._countdown_lbl = tk.Label(
|
||||||
|
self, text="",
|
||||||
|
bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL,
|
||||||
|
)
|
||||||
|
self._countdown_lbl.pack(pady=(0, 4))
|
||||||
|
|
||||||
|
# Warning
|
||||||
|
tk.Label(
|
||||||
|
self,
|
||||||
|
text="⚠ This dialog will close automatically. "
|
||||||
|
"The clipboard is cleared after 30 seconds.",
|
||||||
|
bg=C["bg"], fg=C["warning"],
|
||||||
|
font=FONT_SMALL, wraplength=430, justify="left",
|
||||||
|
).pack(anchor="w", padx=24, pady=(0, 8))
|
||||||
|
|
||||||
|
# Close button
|
||||||
|
tk.Button(
|
||||||
|
self, text="Close",
|
||||||
|
command=self._close,
|
||||||
|
bg=C["surface2"], fg=C["text"],
|
||||||
|
activebackground=C["danger"], activeforeground=C["white"],
|
||||||
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
|
padx=12, pady=6,
|
||||||
|
).pack(pady=(0, 16))
|
||||||
|
|
||||||
|
def _toggle_reveal(self):
|
||||||
|
self._revealed = not self._revealed
|
||||||
|
self._pw_entry.config(show="" if self._revealed else "•")
|
||||||
|
|
||||||
|
def _copy(self):
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append(self._password)
|
||||||
|
self._copy_btn.config(text="✔ Copied!", bg=COLOURS["success"])
|
||||||
|
self.after(2000, lambda: self._copy_btn.config(
|
||||||
|
text="📋 Copy", bg=COLOURS["accent"]))
|
||||||
|
# Schedule clipboard wipe
|
||||||
|
if self._clip_job:
|
||||||
|
try:
|
||||||
|
self.after_cancel(self._clip_job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._clip_job = self.after(
|
||||||
|
self._CLIP_CLEAR_S * 1000, self._clear_clipboard)
|
||||||
|
|
||||||
|
def _clear_clipboard(self):
|
||||||
|
try:
|
||||||
|
self.clipboard_clear()
|
||||||
|
self.clipboard_append("")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
if not self.winfo_exists():
|
||||||
|
return
|
||||||
|
self._countdown_lbl.config(
|
||||||
|
text=f"Auto-closes in {self._remaining}s")
|
||||||
|
if self._remaining <= 0:
|
||||||
|
self._close()
|
||||||
|
return
|
||||||
|
self._remaining -= 1
|
||||||
|
self._tick_job = self.after(1000, self._tick)
|
||||||
|
|
||||||
|
def _close(self):
|
||||||
|
# Cancel any pending jobs
|
||||||
|
for job in (self._clip_job, self._tick_job):
|
||||||
|
if job:
|
||||||
|
try:
|
||||||
|
self.after_cancel(job)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._clear_clipboard()
|
||||||
|
try:
|
||||||
|
self.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -427,6 +427,21 @@ class WebsiteDialog(tk.Toplevel):
|
|||||||
if not name or not url:
|
if not name or not url:
|
||||||
show_error("Name and URL are required.")
|
show_error("Name and URL are required.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Normalise and validate URL before writing to the database.
|
||||||
|
# Reject schemes that could be executed in-browser (javascript:, data:, etc.)
|
||||||
|
# and enforce that a host is present so the health-check thread and the
|
||||||
|
# "open in browser" action both receive a usable address.
|
||||||
|
url = _validate_and_normalise_url(url)
|
||||||
|
if url is None:
|
||||||
|
show_error(
|
||||||
|
"The URL entered is not valid.\n\n"
|
||||||
|
"Please enter a full web address, e.g.:\n"
|
||||||
|
" https://www.example.com\n"
|
||||||
|
" http://intranet.local/app"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if visibility == "assigned" and not assigned_user_ids:
|
if visibility == "assigned" and not assigned_user_ids:
|
||||||
show_error("Please assign at least one user, or set visibility to All Users.")
|
show_error("Please assign at least one user, or set visibility to All Users.")
|
||||||
return
|
return
|
||||||
@@ -463,3 +478,42 @@ class WebsiteDialog(tk.Toplevel):
|
|||||||
self.destroy()
|
self.destroy()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
show_error(f"Save failed:\n{e}")
|
show_error(f"Save failed:\n{e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── URL validation helper ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _validate_and_normalise_url(raw: str) -> "str | None":
|
||||||
|
"""
|
||||||
|
Validate and normalise a user-supplied URL string.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If no scheme is present, prepend 'https://'.
|
||||||
|
- Only 'http' and 'https' schemes are accepted.
|
||||||
|
- A non-empty netloc (host) must be present.
|
||||||
|
- Returns the normalised URL string on success, None on failure.
|
||||||
|
|
||||||
|
This prevents dangerous schemes (javascript:, data:, file:, etc.) from
|
||||||
|
reaching the database, the health-check thread, or webbrowser.open().
|
||||||
|
"""
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prepend https:// if no scheme is given so urlparse can parse the host
|
||||||
|
if "://" not in raw:
|
||||||
|
raw = "https://" + raw
|
||||||
|
|
||||||
|
try:
|
||||||
|
parts = urllib.parse.urlparse(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if parts.scheme.lower() not in ("http", "https"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not parts.netloc:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Reconstruct a clean URL (strips any leading/trailing whitespace artefacts)
|
||||||
|
return urllib.parse.urlunparse(parts)
|
||||||
|
|||||||
+284
-4
@@ -217,9 +217,17 @@ class AiSummaryView(ttk.Frame):
|
|||||||
|
|
||||||
self._build_criteria_panel()
|
self._build_criteria_panel()
|
||||||
|
|
||||||
pane = tk.PanedWindow(self, orient="horizontal",
|
# Notebook — Analyze tab (existing layout) + History tab (new)
|
||||||
|
self._nb = ttk.Notebook(self)
|
||||||
|
self._nb.pack(fill="both", expand=True, pady=(0, 6))
|
||||||
|
|
||||||
|
# ── Analyze tab ───────────────────────────────────────────────────────
|
||||||
|
analyze_tab = tk.Frame(self._nb, bg=C["bg"])
|
||||||
|
self._nb.add(analyze_tab, text="✨ Analyze")
|
||||||
|
|
||||||
|
pane = tk.PanedWindow(analyze_tab, orient="horizontal",
|
||||||
bg=C["border"], sashwidth=4, sashrelief="flat")
|
bg=C["border"], sashwidth=4, sashrelief="flat")
|
||||||
pane.pack(fill="both", expand=True, pady=(0, 6))
|
pane.pack(fill="both", expand=True)
|
||||||
|
|
||||||
left = tk.Frame(pane, bg=C["bg"])
|
left = tk.Frame(pane, bg=C["bg"])
|
||||||
pane.add(left, minsize=260, width=300)
|
pane.add(left, minsize=260, width=300)
|
||||||
@@ -229,6 +237,12 @@ class AiSummaryView(ttk.Frame):
|
|||||||
pane.add(right, minsize=350)
|
pane.add(right, minsize=350)
|
||||||
self._build_output_panel(right)
|
self._build_output_panel(right)
|
||||||
|
|
||||||
|
# ── History tab ───────────────────────────────────────────────────────
|
||||||
|
history_tab = tk.Frame(self._nb, bg=C["bg"])
|
||||||
|
self._nb.add(history_tab, text="🕑 History")
|
||||||
|
self._build_history_panel(history_tab)
|
||||||
|
self._nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
|
||||||
|
|
||||||
status_bar = tk.Frame(self, bg=C["surface2"], pady=4)
|
status_bar = tk.Frame(self, bg=C["surface2"], pady=4)
|
||||||
status_bar.pack(fill="x", side="bottom")
|
status_bar.pack(fill="x", side="bottom")
|
||||||
tk.Label(status_bar, textvariable=self._status_var,
|
tk.Label(status_bar, textvariable=self._status_var,
|
||||||
@@ -858,12 +872,24 @@ class AiSummaryView(ttk.Frame):
|
|||||||
)
|
)
|
||||||
full_output = header + result
|
full_output = header + result
|
||||||
|
|
||||||
self.after(0, lambda t=full_output, v=verdict: self._on_success(t, v))
|
# Build artefacts for history persistence
|
||||||
|
file_names_str = ", ".join(os.path.basename(fp) for fp in file_paths)
|
||||||
|
criteria_snapshot = (
|
||||||
|
"\n".join(
|
||||||
|
f"{i+1}. {c['title']}: {c['description']}"
|
||||||
|
for i, c in enumerate(active_criteria)
|
||||||
|
) if active_criteria else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.after(0, lambda t=full_output, v=verdict,
|
||||||
|
fn=file_names_str, m=model, cs=criteria_snapshot:
|
||||||
|
self._on_success(t, v, fn, m, cs))
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.after(0, lambda e=exc: self._on_error(e))
|
self.after(0, lambda e=exc: self._on_error(e))
|
||||||
|
|
||||||
def _on_success(self, text: str, verdict):
|
def _on_success(self, text: str, verdict, file_names: str,
|
||||||
|
model: str, criteria_snapshot: str):
|
||||||
self._stop_spinner()
|
self._stop_spinner()
|
||||||
self._set_output_text(text)
|
self._set_output_text(text)
|
||||||
if verdict:
|
if verdict:
|
||||||
@@ -876,6 +902,25 @@ class AiSummaryView(ttk.Frame):
|
|||||||
f"'{self.current_user.get('username')}' "
|
f"'{self.current_user.get('username')}' "
|
||||||
f"({len(self._files)} file(s)). Verdict: {verdict or 'N/A'}."
|
f"({len(self._files)} file(s)). Verdict: {verdict or 'N/A'}."
|
||||||
)
|
)
|
||||||
|
# Persist to ai_analysis_log so the history panel can display it
|
||||||
|
try:
|
||||||
|
from models import save_ai_analysis
|
||||||
|
save_ai_analysis(
|
||||||
|
user_id=self.current_user["id"],
|
||||||
|
file_names=file_names,
|
||||||
|
model=model,
|
||||||
|
verdict=verdict,
|
||||||
|
criteria_snapshot=criteria_snapshot,
|
||||||
|
summary_text=text,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[AI SUMMARY] Could not save analysis to history: {e}")
|
||||||
|
# Refresh history panel if it exists (it is built lazily on tab switch)
|
||||||
|
if hasattr(self, "_refresh_history"):
|
||||||
|
try:
|
||||||
|
self._refresh_history()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _on_error(self, exc: Exception):
|
def _on_error(self, exc: Exception):
|
||||||
self._stop_spinner()
|
self._stop_spinner()
|
||||||
@@ -904,6 +949,241 @@ class AiSummaryView(ttk.Frame):
|
|||||||
self._progress.pack_forget()
|
self._progress.pack_forget()
|
||||||
self._run_btn.config(state="normal", text="✨ Analyze with AI")
|
self._run_btn.config(state="normal", text="✨ Analyze with AI")
|
||||||
|
|
||||||
|
# -- History panel --------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_history_panel(self, parent):
|
||||||
|
"""
|
||||||
|
Build the analysis history tab.
|
||||||
|
Admin: sees all users' analyses.
|
||||||
|
User: sees only their own.
|
||||||
|
Treeview columns: Date/Time, User (admin only), Files, Model, Verdict
|
||||||
|
Double-click or View button restores the full result in the output panel.
|
||||||
|
"""
|
||||||
|
C = COLOURS
|
||||||
|
|
||||||
|
# ── Toolbar ───────────────────────────────────────────────────────────
|
||||||
|
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
|
||||||
|
toolbar.pack(fill="x")
|
||||||
|
|
||||||
|
tk.Label(toolbar, text="📜 Analysis History",
|
||||||
|
bg=C["surface"], fg=C["text"],
|
||||||
|
font=FONT_BOLD).pack(side="left")
|
||||||
|
|
||||||
|
tk.Button(
|
||||||
|
toolbar, text="🔍 View Result",
|
||||||
|
command=self._history_view_selected,
|
||||||
|
bg=C["accent"], fg=C["white"],
|
||||||
|
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||||||
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
|
padx=8, pady=4,
|
||||||
|
).pack(side="right", padx=(4, 0))
|
||||||
|
|
||||||
|
tk.Button(
|
||||||
|
toolbar, text="↻ Refresh",
|
||||||
|
command=self._refresh_history,
|
||||||
|
bg=C["surface2"], fg=C["text"],
|
||||||
|
activebackground=C["accent"], activeforeground=C["white"],
|
||||||
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
|
padx=8, pady=4,
|
||||||
|
).pack(side="right", padx=(4, 0))
|
||||||
|
|
||||||
|
# ── Verdict filter ────────────────────────────────────────────────────
|
||||||
|
filter_frame = tk.Frame(parent, bg=C["surface"], padx=10, pady=4)
|
||||||
|
filter_frame.pack(fill="x")
|
||||||
|
|
||||||
|
tk.Label(filter_frame, text="Filter by verdict:",
|
||||||
|
bg=C["surface"], fg=C["text_dim"],
|
||||||
|
font=FONT_SMALL).pack(side="left")
|
||||||
|
|
||||||
|
self._hist_filter_var = tk.StringVar(value="All")
|
||||||
|
for label in ("All", "PURSUE", "PASS", "UNCLEAR", "— none —"):
|
||||||
|
tk.Radiobutton(
|
||||||
|
filter_frame, text=label,
|
||||||
|
variable=self._hist_filter_var, value=label,
|
||||||
|
command=self._apply_history_filter,
|
||||||
|
bg=C["surface"], fg=C["text"],
|
||||||
|
activebackground=C["surface"],
|
||||||
|
activeforeground=C["accent"],
|
||||||
|
selectcolor=C["surface2"],
|
||||||
|
font=FONT_SMALL, cursor="hand2",
|
||||||
|
).pack(side="left", padx=(8, 0))
|
||||||
|
|
||||||
|
# ── Treeview ──────────────────────────────────────────────────────────
|
||||||
|
tree_frame = tk.Frame(parent, bg=C["bg"])
|
||||||
|
tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 4))
|
||||||
|
|
||||||
|
if self._is_admin:
|
||||||
|
cols = ("ID", "Date/Time", "User", "Files", "Model", "Verdict")
|
||||||
|
widths = [40, 140, 100, 280, 160, 80]
|
||||||
|
else:
|
||||||
|
cols = ("ID", "Date/Time", "Files", "Model", "Verdict")
|
||||||
|
widths = [40, 140, 360, 160, 80]
|
||||||
|
|
||||||
|
self._hist_tree = ttk.Treeview(
|
||||||
|
tree_frame, columns=cols,
|
||||||
|
show="headings", selectmode="browse",
|
||||||
|
)
|
||||||
|
for col, w in zip(cols, widths):
|
||||||
|
self._hist_tree.heading(col, text=col)
|
||||||
|
self._hist_tree.column(
|
||||||
|
col, width=w,
|
||||||
|
anchor="center" if col in ("ID", "Verdict") else "w",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Colour-code verdict rows
|
||||||
|
self._hist_tree.tag_configure("PURSUE", foreground=C["success"])
|
||||||
|
self._hist_tree.tag_configure("PASS", foreground=C["danger"])
|
||||||
|
self._hist_tree.tag_configure("UNCLEAR", foreground=C["warning"])
|
||||||
|
self._hist_tree.tag_configure("none", foreground=C["text_dim"])
|
||||||
|
|
||||||
|
vsb = ttk.Scrollbar(tree_frame, orient="vertical",
|
||||||
|
command=self._hist_tree.yview)
|
||||||
|
self._hist_tree.configure(yscrollcommand=vsb.set)
|
||||||
|
vsb.pack(side="right", fill="y")
|
||||||
|
self._hist_tree.pack(side="left", fill="both", expand=True)
|
||||||
|
|
||||||
|
self._hist_tree.bind("<Double-1>",
|
||||||
|
lambda _: self._history_view_selected())
|
||||||
|
|
||||||
|
# ── Detail strip ─────────────────────────────────────────────────────
|
||||||
|
detail_bar = tk.Frame(parent, bg=C["surface2"], pady=6, padx=10)
|
||||||
|
detail_bar.pack(fill="x", side="bottom")
|
||||||
|
self._hist_detail_lbl = tk.Label(
|
||||||
|
detail_bar, text="Select a row to see details.",
|
||||||
|
bg=C["surface2"], fg=C["text_dim"],
|
||||||
|
font=FONT_SMALL, anchor="w", wraplength=900, justify="left",
|
||||||
|
)
|
||||||
|
self._hist_detail_lbl.pack(fill="x")
|
||||||
|
self._hist_tree.bind("<<TreeviewSelect>>", self._on_history_select)
|
||||||
|
|
||||||
|
# Store all loaded rows for client-side filtering
|
||||||
|
self._hist_all_rows = []
|
||||||
|
|
||||||
|
self._refresh_history()
|
||||||
|
|
||||||
|
def _refresh_history(self):
|
||||||
|
"""Reload history from DB and repopulate the treeview."""
|
||||||
|
try:
|
||||||
|
from models import get_ai_analysis_history
|
||||||
|
uid = None if self._is_admin else self.current_user["id"]
|
||||||
|
self._hist_all_rows = get_ai_analysis_history(user_id=uid, limit=200)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not load AI analysis history: {e}")
|
||||||
|
self._hist_all_rows = []
|
||||||
|
self._apply_history_filter()
|
||||||
|
|
||||||
|
def _apply_history_filter(self):
|
||||||
|
"""Re-populate the treeview using the active verdict filter."""
|
||||||
|
self._hist_tree.delete(*self._hist_tree.get_children())
|
||||||
|
verdict_filter = self._hist_filter_var.get()
|
||||||
|
|
||||||
|
for row in self._hist_all_rows:
|
||||||
|
verdict = row.get("verdict") or ""
|
||||||
|
# Map filter labels to DB values
|
||||||
|
if verdict_filter == "All":
|
||||||
|
pass
|
||||||
|
elif verdict_filter == "— none —":
|
||||||
|
if verdict:
|
||||||
|
continue
|
||||||
|
elif verdict != verdict_filter:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dt_str = str(row.get("analyzed_at", ""))[:16]
|
||||||
|
files = (row.get("file_names") or "")[:60]
|
||||||
|
if len(row.get("file_names") or "") > 60:
|
||||||
|
files += "…"
|
||||||
|
tag = verdict if verdict else "none"
|
||||||
|
|
||||||
|
if self._is_admin:
|
||||||
|
values = (
|
||||||
|
row["id"], dt_str,
|
||||||
|
row.get("username") or "—",
|
||||||
|
files, row.get("model") or "",
|
||||||
|
verdict or "—",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
values = (
|
||||||
|
row["id"], dt_str,
|
||||||
|
files, row.get("model") or "",
|
||||||
|
verdict or "—",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._hist_tree.insert(
|
||||||
|
"", "end", iid=str(row["id"]),
|
||||||
|
tags=(tag,), values=values,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_history_select(self, event=None):
|
||||||
|
"""Show a one-line detail strip when a history row is selected."""
|
||||||
|
sel = self._hist_tree.selection()
|
||||||
|
if not sel:
|
||||||
|
return
|
||||||
|
analysis_id = int(sel[0])
|
||||||
|
try:
|
||||||
|
from models import get_ai_analysis_detail
|
||||||
|
detail = get_ai_analysis_detail(analysis_id)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not detail:
|
||||||
|
return
|
||||||
|
criteria_info = (
|
||||||
|
f" | Criteria: {detail['criteria_snapshot'][:80]}…"
|
||||||
|
if detail.get("criteria_snapshot") else
|
||||||
|
" | No criteria evaluated"
|
||||||
|
)
|
||||||
|
self._hist_detail_lbl.config(
|
||||||
|
text=(
|
||||||
|
f"ID {detail['id']} | "
|
||||||
|
f"{str(detail.get('analyzed_at', ''))[:16]} | "
|
||||||
|
f"Model: {detail.get('model', '')} | "
|
||||||
|
f"Verdict: {detail.get('verdict') or 'N/A'}"
|
||||||
|
f"{criteria_info}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _history_view_selected(self):
|
||||||
|
"""
|
||||||
|
Load the selected history entry's full summary into the output panel
|
||||||
|
and switch to the Analyze tab so the user can read it.
|
||||||
|
"""
|
||||||
|
sel = self._hist_tree.selection()
|
||||||
|
if not sel:
|
||||||
|
show_error("Please select an analysis to view.")
|
||||||
|
return
|
||||||
|
analysis_id = int(sel[0])
|
||||||
|
try:
|
||||||
|
from models import get_ai_analysis_detail
|
||||||
|
detail = get_ai_analysis_detail(analysis_id)
|
||||||
|
except Exception as e:
|
||||||
|
show_error(f"Could not load analysis:\n{e}")
|
||||||
|
return
|
||||||
|
if not detail:
|
||||||
|
show_error("Analysis record not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Switch to Analyze tab
|
||||||
|
self._nb.select(0)
|
||||||
|
# Restore output
|
||||||
|
self._set_output_text(detail["summary_text"])
|
||||||
|
verdict = detail.get("verdict")
|
||||||
|
if verdict:
|
||||||
|
self._show_verdict_banner(verdict)
|
||||||
|
else:
|
||||||
|
self._hide_verdict_banner()
|
||||||
|
self._set_status(
|
||||||
|
f"Viewing history entry #{analysis_id} "
|
||||||
|
f"({str(detail.get('analyzed_at', ''))[:16]})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_tab_changed(self, event=None):
|
||||||
|
"""Refresh history data whenever the user switches to the History tab."""
|
||||||
|
try:
|
||||||
|
current = self._nb.index(self._nb.select())
|
||||||
|
if current == 1: # History tab is index 1
|
||||||
|
self._refresh_history()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# -- Status bar ------------------------------------------------------------
|
# -- Status bar ------------------------------------------------------------
|
||||||
|
|
||||||
def _set_status(self, msg: str):
|
def _set_status(self, msg: str):
|
||||||
|
|||||||
Reference in New Issue
Block a user