04/23 Enhance app functionalities 2

This commit is contained in:
2026-04-23 17:32:44 -04:00
parent f4eea48e4d
commit 58c6218c14
8 changed files with 609 additions and 28 deletions
+171 -10
View File
@@ -6,7 +6,7 @@ import tkinter as tk
from tkinter import ttk
import logging
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
)
@@ -143,15 +143,9 @@ class AdminUsersView(ttk.Frame):
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."
)
# Show the temporary password in a purpose-built secure dialog
# instead of show_info (which leaves the password visible indefinitely)
_PasswordResetDialog(self, username=username, password=pwd)
except Exception as e:
show_error(f"Password reset failed:\n{e}")
@@ -296,3 +290,170 @@ class UserDialog(tk.Toplevel):
self.destroy()
except Exception as 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
+54
View File
@@ -427,6 +427,21 @@ class WebsiteDialog(tk.Toplevel):
if not name or not url:
show_error("Name and URL are required.")
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:
show_error("Please assign at least one user, or set visibility to All Users.")
return
@@ -463,3 +478,42 @@ class WebsiteDialog(tk.Toplevel):
self.destroy()
except Exception as 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
View File
@@ -217,9 +217,17 @@ class AiSummaryView(ttk.Frame):
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")
pane.pack(fill="both", expand=True, pady=(0, 6))
pane.pack(fill="both", expand=True)
left = tk.Frame(pane, bg=C["bg"])
pane.add(left, minsize=260, width=300)
@@ -229,6 +237,12 @@ class AiSummaryView(ttk.Frame):
pane.add(right, minsize=350)
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.pack(fill="x", side="bottom")
tk.Label(status_bar, textvariable=self._status_var,
@@ -858,12 +872,24 @@ class AiSummaryView(ttk.Frame):
)
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:
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._set_output_text(text)
if verdict:
@@ -876,6 +902,25 @@ class AiSummaryView(ttk.Frame):
f"'{self.current_user.get('username')}' "
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):
self._stop_spinner()
@@ -904,6 +949,241 @@ class AiSummaryView(ttk.Frame):
self._progress.pack_forget()
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 ------------------------------------------------------------
def _set_status(self, msg: str):