diff --git a/CLAUDE.md b/CLAUDE.md index 24567b5..c9b2a20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,7 +222,7 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char - last_sent_date in-memory (resets on restart) - 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. Key internals: @@ -238,6 +238,15 @@ Key internals: Key Deadlines & Action Items - Groq API key stored DPAPI-encrypted in config.ini [groq] section - 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: 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 | | 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 | 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) | --- diff --git a/config.py b/config.py index f32ed3f..b388684 100644 --- a/config.py +++ b/config.py @@ -402,6 +402,24 @@ def initialize_database(): conn.commit() 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 cursor.execute("SELECT COUNT(*) FROM users") (count,) = cursor.fetchone() diff --git a/example.xlsx b/example.xlsx deleted file mode 100644 index ab2671e..0000000 Binary files a/example.xlsx and /dev/null differ diff --git a/models.py b/models.py index 427ea0d..c69162c 100644 --- a/models.py +++ b/models.py @@ -993,7 +993,17 @@ def get_unchecked_report(target_date=None, user_id=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 """ conn = None @@ -1015,16 +1025,38 @@ def get_summary_report(date_from=None, date_to=None): cur.execute( f""" SELECT - DATE(sc.checked_at) AS check_date, + DATE(sc.checked_at) AS check_date, u.username, - COALESCE(u.full_name, u.username) AS full_name, - COUNT(DISTINCT sc.website_id) AS checked_count, - (SELECT COUNT(*) FROM websites WHERE is_active=1) AS total_sites, + COALESCE(u.full_name, u.username) AS full_name, + COUNT(DISTINCT sc.website_id) AS checked_count, + ( + 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( 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 - ) AS pct_complete + ) AS pct_complete FROM shift_checks sc JOIN users u ON u.id = sc.user_id {where_clause} @@ -1430,17 +1462,22 @@ def get_unchecked_sites_for_user(user_id: int): # ─── AI Criteria CRUD ───────────────────────────────────────────────────────── 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 try: conn = get_connection() cur = conn.cursor(dictionary=True) cur.execute( """ - SELECT c.*, u.username AS creator - FROM ai_criteria c - LEFT JOIN users u ON u.id = c.created_by - ORDER BY c.sort_order, c.id + SELECT id, title, description, is_active, sort_order, + created_by, created_at, updated_at + FROM ai_criteria + ORDER BY sort_order, id """ ) rows = cur.fetchall() diff --git a/utils/scheduler.py b/utils/scheduler.py index c8cabff..61cc643 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -111,7 +111,14 @@ def _build_html_report() -> str: rows = stats.get("user_stats", []) table_rows = "" + skipped_no_shift = 0 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) color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f" table_rows += ( @@ -119,11 +126,21 @@ def _build_html_report() -> str: f"
" + f"{skipped_no_shift} user(s) had no shifts scheduled today and are " + f"excluded from this report.
" + ) if skipped_no_shift else "" return f""" @@ -142,6 +159,7 @@ def _build_html_report() -> str: {table_rows} +{no_shift_note}Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
diff --git a/views/admin_users_view.py b/views/admin_users_view.py index 3887f7a..44d07b4 100644 --- a/views/admin_users_view.py +++ b/views/admin_users_view.py @@ -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 \ No newline at end of file diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index ea9eaac..586bcfc 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -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) diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index a99a084..6eef6f1 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -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("<