diff --git a/app.py b/app.py index b95ddfa..0b6fc34 100644 --- a/app.py +++ b/app.py @@ -131,9 +131,46 @@ class App(tk.Tk): child.destroy() # ── Sidebar ────────────────────────────────────────────────────────── - sidebar = tk.Frame(self, bg=COLOURS["surface"], width=200) - sidebar.pack(side="left", fill="y") - sidebar.pack_propagate(False) + # The sidebar outer frame holds the fixed-width column. + # Inside it we place a Canvas + Scrollbar so that the nav items are + # scrollable on shorter screens (especially for admin with many items). + sidebar_outer = tk.Frame(self, bg=COLOURS["surface"], width=200) + sidebar_outer.pack(side="left", fill="y") + sidebar_outer.pack_propagate(False) + + sidebar_canvas = tk.Canvas( + sidebar_outer, bg=COLOURS["surface"], + highlightthickness=0, width=200, + ) + sidebar_scrollbar = ttk.Scrollbar( + sidebar_outer, orient="vertical", command=sidebar_canvas.yview) + sidebar_canvas.configure(yscrollcommand=sidebar_scrollbar.set) + + # Scrollbar only visible on overflow — pack canvas first so it fills + sidebar_canvas.pack(side="left", fill="both", expand=True) + sidebar_scrollbar.pack(side="right", fill="y") + + # The actual sidebar frame lives inside the canvas window + sidebar = tk.Frame(sidebar_canvas, bg=COLOURS["surface"], width=200) + sidebar_window = sidebar_canvas.create_window( + (0, 0), window=sidebar, anchor="nw", width=200) + + def _on_sidebar_configure(event): + sidebar_canvas.configure( + scrollregion=sidebar_canvas.bbox("all")) + + def _on_canvas_resize(event): + sidebar_canvas.itemconfig(sidebar_window, width=event.width) + + sidebar.bind("", _on_sidebar_configure) + sidebar_canvas.bind("", _on_canvas_resize) + + # Mouse-wheel scrolling inside the sidebar + def _on_mousewheel(event): + sidebar_canvas.yview_scroll( + int(-1 * (event.delta / 120)), "units") + + sidebar.bind_all("", _on_mousewheel) # App branding brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20) @@ -611,4 +648,4 @@ class App(tk.Tk): if __name__ == "__main__": app = App() - app.mainloop() + app.mainloop() \ No newline at end of file diff --git a/models.py b/models.py index c7742b0..4da6fec 100644 --- a/models.py +++ b/models.py @@ -308,7 +308,7 @@ def get_all_users(): try: conn = get_connection() cur = conn.cursor(dictionary=True) - cur.execute("SELECT id, username, role, full_name, is_active, created_at FROM users ORDER BY username") + cur.execute("SELECT id, username, role, full_name, email, is_active, created_at FROM users ORDER BY username") rows = cur.fetchall() cur.close() return rows @@ -322,7 +322,7 @@ def get_user_by_id(user_id: int): try: conn = get_connection() cur = conn.cursor(dictionary=True) - cur.execute("SELECT id, username, role, full_name, is_active FROM users WHERE id=%s", (user_id,)) + cur.execute("SELECT id, username, role, full_name, email, is_active FROM users WHERE id=%s", (user_id,)) row = cur.fetchone() cur.close() return row @@ -331,40 +331,40 @@ def get_user_by_id(user_id: int): conn.close() -def create_user(admin_id, username, password, role, full_name): +def create_user(admin_id, username, password, role, full_name, email=None): conn = None try: conn = get_connection() cur = conn.cursor() cur.execute( - "INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,%s,%s)", - (username, _hash_password(password), role, full_name) + "INSERT INTO users (username, password, role, full_name, email) VALUES (%s,%s,%s,%s,%s)", + (username, _hash_password(password), role, full_name, email or None) ) conn.commit() new_id = cur.lastrowid cur.close() log_action(admin_id, "CREATE_USER", "users", new_id, - f"Created user '{username}' role='{role}'.") + f"Created user '{username}' role='{role}' email='{email or ''}' .") return new_id finally: if conn: conn.close() -def update_user(admin_id, user_id, username, role, full_name, is_active, password=None): +def update_user(admin_id, user_id, username, role, full_name, is_active, password=None, email=None): conn = None try: conn = get_connection() cur = conn.cursor() if password: cur.execute( - "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, password=%s WHERE id=%s", - (username, role, full_name, is_active, _hash_password(password), user_id) + "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, email=%s, password=%s WHERE id=%s", + (username, role, full_name, is_active, email or None, _hash_password(password), user_id) ) else: cur.execute( - "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s WHERE id=%s", - (username, role, full_name, is_active, user_id) + "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, email=%s WHERE id=%s", + (username, role, full_name, is_active, email or None, user_id) ) conn.commit() cur.close() diff --git a/utils/scheduler.py b/utils/scheduler.py index 9aa0b34..5c27656 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -27,6 +27,7 @@ import smtplib import threading from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid, formataddr logger = logging.getLogger("scheduler") @@ -213,10 +214,21 @@ def send_test_email(smtp_host: str, smtp_port: int, "

You can now enable daily reports from the Email Settings panel.

" "" ) + plain = ( + "Website Checker — SMTP Test\n\n" + "This is a test email confirming your SMTP configuration is working.\n" + "You can now enable daily reports from the Email Settings panel." + ) msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = smtp_user - msg["To"] = ", ".join(recipients) + msg["Subject"] = subject + msg["From"] = formataddr(("Website Checker", smtp_user)) + msg["To"] = ", ".join(recipients) + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker") + msg["X-Mailer"] = "WebChecker" + # Plain-text part must come first; HTML second. + # Spam filters heavily penalise HTML-only messages with no text/plain alternative. + msg.attach(MIMEText(plain, "plain", "utf-8")) msg.attach(MIMEText(body, "html", "utf-8")) server = _make_smtp_server(smtp_host, smtp_port, security) @@ -316,10 +328,22 @@ def _send_report(cfg: dict): today = datetime.date.today().strftime("%d %b %Y") subject = f"Website Checker — Daily Report {today}" + smtp_user = cfg["smtp_user"] + plain = ( + f"Website Checker — Daily Report {today}\n\n" + "Please view this report in an HTML-capable email client for full formatting.\n" + "This email was sent automatically by Website Checker." + ) msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = cfg["smtp_user"] - msg["To"] = ", ".join(cfg["recipients"]) + msg["Subject"] = subject + msg["From"] = formataddr(("Website Checker", smtp_user)) + msg["To"] = ", ".join(cfg["recipients"]) + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker") + msg["X-Mailer"] = "WebChecker" + # Plain-text part must come first; HTML second. + # Spam filters heavily penalise HTML-only messages with no text/plain alternative. + msg.attach(MIMEText(plain, "plain", "utf-8")) msg.attach(MIMEText(html, "html", "utf-8")) try: @@ -424,4 +448,4 @@ def stop(): """Signal the scheduler to stop cleanly.""" global _stop_event _stop_event.set() - logger.info("Email scheduler stopped.") + logger.info("Email scheduler stopped.") \ No newline at end of file diff --git a/views/admin_users_view.py b/views/admin_users_view.py index 44d07b4..2ba49e5 100644 --- a/views/admin_users_view.py +++ b/views/admin_users_view.py @@ -44,9 +44,9 @@ class AdminUsersView(ttk.Frame): command=self._reset_password).pack(side="right", padx=(0, 8)) # Treeview - cols = ("ID", "Username", "Full Name", "Role", "Active", "Created") + cols = ("ID", "Username", "Full Name", "Email", "Role", "Active", "Created") self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") - widths = [40, 140, 180, 80, 60, 160] + widths = [40, 130, 160, 180, 80, 60, 140] for col, w in zip(cols, widths): self.tree.heading(col, text=col) self.tree.column(col, width=w, anchor="center" if w < 120 else "w") @@ -68,8 +68,8 @@ class AdminUsersView(ttk.Frame): created = str(u["created_at"])[:16] if u["created_at"] else "" self.tree.insert("", "end", iid=str(u["id"]), values=(u["id"], u["username"], - u["full_name"] or "", u["role"], - active, created)) + u["full_name"] or "", u["email"] or "", + u["role"], active, created)) except Exception as e: show_error(f"Failed to load users:\n{e}") @@ -134,6 +134,7 @@ class AdminUsersView(ttk.Frame): user_data["full_name"], user_data["is_active"], password=pwd, + email=user_data.get("email"), ) log_action( self.current_user["id"], "RESET_PASSWORD", "users", uid, @@ -202,7 +203,7 @@ class UserDialog(tk.Toplevel): def _centre(self): self.update_idletasks() - w, h = 420, 420 + w, h = 440, 490 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") @@ -245,12 +246,21 @@ class UserDialog(tk.Toplevel): ttk.Checkbutton(form, variable=self.active_var).grid(row=5, column=1, sticky="w", pady=6) + ttk.Label(form, text="Email (optional)").grid(row=6, column=0, sticky="w", + padx=(0, 10), pady=6) + self.email_var = tk.StringVar() + ttk.Entry(form, textvariable=self.email_var).grid( + row=6, column=1, sticky="ew", pady=6) + ttk.Label(form, text="Used for system reminders and notifications.", + style="Dim.TLabel").grid(row=7, column=1, sticky="w") + if self.is_edit: d = self.user_data self.full_name_var.set(d.get("full_name") or "") self.username_var.set(d.get("username") or "") self.role_var.set(d.get("role") or "user") self.active_var.set(bool(d.get("is_active", 1))) + self.email_var.set(d.get("email") or "") btn_frame = ttk.Frame(self) btn_frame.pack(fill="x", padx=24, pady=(16, 20)) @@ -264,6 +274,7 @@ class UserDialog(tk.Toplevel): password = self.password_var.get() role = self.role_var.get() is_active = int(self.active_var.get()) + email = self.email_var.get().strip() or None if not username: show_error("Username is required.") @@ -271,6 +282,9 @@ class UserDialog(tk.Toplevel): if not self.is_edit and not password: show_error("Password is required for new users.") return + if email and "@" not in email: + show_error("Please enter a valid email address.") + return try: if self.is_edit: @@ -278,12 +292,13 @@ class UserDialog(tk.Toplevel): update_user(self.current_user["id"], self.user_data["id"], username, role, full_name, is_active, - password if password else None) + password if password else None, + email=email) logger.info(f"User id={self.user_data['id']} updated by admin.") show_info("User updated successfully.") else: from models import create_user - create_user(self.current_user["id"], username, password, role, full_name) + create_user(self.current_user["id"], username, password, role, full_name, email=email) logger.info(f"New user '{username}' created by admin.") show_info("User created successfully.") self.on_save() diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index 586bcfc..14dc0b7 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -34,6 +34,9 @@ class AdminWebsitesView(ttk.Frame): ttk.Button(toolbar, text="+ Add Website", command=self._open_add).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="📥 Import", + style="Ghost.TButton", + command=self._open_import).pack(side="right", padx=(4, 0)) ttk.Button(toolbar, text="✎ Edit", style="Ghost.TButton", command=self._open_edit).pack(side="right", padx=(4, 0)) @@ -88,6 +91,10 @@ class AdminWebsitesView(ttk.Frame): # ─── Actions ────────────────────────────────────────────────────────────── + def _open_import(self): + """Open the bulk import dialog.""" + BulkImportDialog(self, self.current_user, on_complete=self._load_websites) + def _open_add(self): WebsiteDialog(self, self.current_user, website_data=None, on_save=self._load_websites) @@ -517,3 +524,314 @@ def _validate_and_normalise_url(raw: str) -> "str | None": # Reconstruct a clean URL (strips any leading/trailing whitespace artefacts) return urllib.parse.urlunparse(parts) + + +# ─── Bulk Import Dialog ──────────────────────────────────────────────────────── + +class BulkImportDialog(tk.Toplevel): + """ + Modal dialog for bulk-importing websites from a CSV or Excel file. + + Workflow: + 1. User picks a .csv or .xlsx/.xls file (or downloads the template). + 2. File is parsed and previewed in a treeview (up to 200 rows shown). + 3. User confirms → rows are inserted via create_website(); duplicates skipped. + + Expected columns (case-insensitive, order-independent): + name * — website display name (required) + url * — full URL (required) + check_type — 'daily' or 'weekly' (default: daily) + note — optional description + visibility — 'all' or 'assigned' (default: all) + """ + + _TEMPLATE_PATH = "website_import_template.xlsx" + _REQUIRED_COLS = {"name", "url"} + _ALLOWED_TYPES = {"daily", "weekly"} + _ALLOWED_VIS = {"all", "assigned"} + + def __init__(self, parent, current_user: dict, on_complete): + super().__init__(parent) + self.current_user = current_user + self.on_complete = on_complete + self._rows: list = [] # parsed preview rows + + self.title("Import Websites") + self.configure(bg=COLOURS["bg"]) + self.resizable(True, True) + self.grab_set() + self._build_ui() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 780, 540 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + # ── UI ──────────────────────────────────────────────────────────────────── + + def _build_ui(self): + C = COLOURS + + ttk.Label(self, text="Import Websites", + style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4)) + + # ── File picker row ─────────────────────────────────────────────────── + picker = tk.Frame(self, bg=C["bg"]) + picker.pack(fill="x", padx=24, pady=(0, 8)) + + tk.Label(picker, text="File:", bg=C["bg"], fg=C["text"], + font=FONT_SMALL).pack(side="left") + + self._file_var = tk.StringVar() + tk.Entry(picker, textvariable=self._file_var, state="readonly", + readonlybackground=C["surface2"], fg=C["text"], + relief="flat", width=50, font=FONT_SMALL).pack( + side="left", padx=(6, 6), fill="x", expand=True) + + ttk.Button(picker, text="Browse…", + command=self._browse).pack(side="left", padx=(0, 6)) + + ttk.Button(picker, text="⬇ Download Template", + style="Ghost.TButton", + command=self._download_template).pack(side="left") + + # ── Status label ────────────────────────────────────────────────────── + self._status_lbl = tk.Label( + self, text="Select a .xlsx or .csv file to preview.", + bg=C["bg"], fg=C["text_dim"], font=FONT_SMALL, anchor="w") + self._status_lbl.pack(fill="x", padx=24, pady=(0, 6)) + + # ── Preview treeview ────────────────────────────────────────────────── + tree_frame = tk.Frame(self, bg=C["bg"]) + tree_frame.pack(fill="both", expand=True, padx=24) + + cols = ("name", "url", "check_type", "note", "visibility") + widths = [160, 240, 90, 180, 90] + self._tree = ttk.Treeview(tree_frame, columns=cols, + show="headings", selectmode="none", height=12) + for col, w in zip(cols, widths): + self._tree.heading(col, text=col) + self._tree.column(col, width=w, anchor="w") + self._tree.tag_configure("skip", foreground=C["text_dim"]) + self._tree.tag_configure("valid", foreground=C["text"]) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", + command=self._tree.yview) + self._tree.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + self._tree.pack(side="left", fill="both", expand=True) + + # ── Footer buttons ──────────────────────────────────────────────────── + ttk.Separator(self, orient="horizontal").pack(fill="x", padx=0, pady=8) + btn_row = ttk.Frame(self) + btn_row.pack(fill="x", padx=24, pady=(0, 16)) + + self._import_btn = ttk.Button( + btn_row, text="Import", + command=self._do_import, state="disabled") + self._import_btn.pack(side="right", padx=(6, 0)) + ttk.Button(btn_row, text="Cancel", style="Ghost.TButton", + command=self.destroy).pack(side="right") + + # ── File handling ───────────────────────────────────────────────────────── + + def _browse(self): + from tkinter import filedialog + path = filedialog.askopenfilename( + title="Select import file", + filetypes=[ + ("Spreadsheets", "*.xlsx *.xls *.csv"), + ("Excel", "*.xlsx *.xls"), + ("CSV", "*.csv"), + ("All files", "*.*"), + ], + ) + if not path: + return + self._file_var.set(path) + self._parse_file(path) + + def _parse_file(self, path: str): + """Parse the selected file and populate the preview treeview.""" + self._tree.delete(*self._tree.get_children()) + self._rows.clear() + self._import_btn.config(state="disabled") + + try: + rows = self._read_file(path) + except Exception as e: + self._status_lbl.config( + text=f"Error reading file: {e}", fg=COLOURS["danger"]) + return + + if not rows: + self._status_lbl.config( + text="No data rows found.", fg=COLOURS["warning"]) + return + + valid_count = 0 + skip_count = 0 + for row in rows[:200]: # preview cap + name = (row.get("name") or "").strip() + url = (row.get("url") or "").strip() + ct = (row.get("check_type") or "daily").strip().lower() + note = (row.get("note") or "").strip() + vis = (row.get("visibility") or "all").strip().lower() + + # Normalise / default + if ct not in self._ALLOWED_TYPES: + ct = "daily" + if vis not in self._ALLOWED_VIS: + vis = "all" + + skip = not name or not url + tag = "skip" if skip else "valid" + if skip: + skip_count += 1 + else: + valid_count += 1 + + self._tree.insert("", "end", tags=(tag,), + values=(name, url, ct, note[:60], vis)) + self._rows.append({ + "name": name, "url": url, + "check_type": ct, "note": note, "visibility": vis, + "_skip": skip, + }) + + total = len(rows) + shown = min(total, 200) + more = f" ({total - shown} more not shown)" if total > 200 else "" + self._status_lbl.config( + text=(f"Preview: {valid_count} valid row(s), " + f"{skip_count} skipped (missing name/url).{more}"), + fg=COLOURS["text_dim"], + ) + if valid_count: + self._import_btn.config(state="normal") + + def _read_file(self, path: str) -> list: + """Return list of dicts from CSV or Excel. Header row normalised to lowercase.""" + import os + ext = os.path.splitext(path)[1].lower() + if ext == ".csv": + return self._read_csv(path) + elif ext in (".xlsx", ".xls"): + return self._read_excel(path) + else: + raise ValueError(f"Unsupported file type: {ext}") + + def _read_csv(self, path: str) -> list: + import csv + rows = [] + with open(path, newline="", encoding="utf-8-sig") as fh: + reader = csv.DictReader(fh) + for row in reader: + rows.append({k.strip().lower(): v for k, v in row.items()}) + return rows + + def _read_excel(self, path: str) -> list: + try: + import openpyxl + except ImportError: + raise ImportError( + "openpyxl is required for Excel import.\n" + "Install it with: pip install openpyxl" + ) + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + ws = wb.active + rows_iter = ws.iter_rows(values_only=True) + headers = None + result = [] + for row in rows_iter: + # Skip completely empty rows + if all(v is None for v in row): + continue + if headers is None: + headers = [str(c).strip().lower() if c is not None else "" for c in row] + continue + row_dict = {headers[i]: (str(v).strip() if v is not None else "") + for i, v in enumerate(row) if i < len(headers)} + result.append(row_dict) + wb.close() + return result + + # ── Import ──────────────────────────────────────────────────────────────── + + def _do_import(self): + """Insert all valid rows via create_website(). Skips duplicates by URL.""" + from models import create_website + import re + + to_import = [r for r in self._rows if not r["_skip"]] + if not to_import: + show_error("No valid rows to import.") + return + + inserted = 0 + skipped = 0 + errors = [] + + for r in to_import: + try: + create_website( + admin_id=self.current_user["id"], + name=r["name"], + url=r["url"], + check_type=r["check_type"], + note=r["note"], + credentials=[], + visibility=r["visibility"], + ) + inserted += 1 + logger.info( + f"[IMPORT] Website '{r['name']}' ({r['url']}) imported " + f"by admin_id={self.current_user['id']}." + ) + except Exception as e: + err_str = str(e) + # Duplicate entry (MySQL error 1062) → count as skip, not error + if "1062" in err_str or "Duplicate" in err_str: + skipped += 1 + logger.info( + f"[IMPORT] Skipped duplicate URL: {r['url']}") + else: + errors.append(f"{r['name']}: {e}") + logger.error( + f"[IMPORT] Failed to import '{r['name']}': {e}") + + msg = f"Import complete.\n\n✔ {inserted} website(s) imported." + if skipped: + msg += f"\n⏭ {skipped} duplicate URL(s) skipped." + if errors: + msg += f"\n✕ {len(errors)} error(s):\n" + "\n".join(errors[:5]) + show_info(msg) + self.on_complete() + self.destroy() + + # ── Template download ───────────────────────────────────────────────────── + + def _download_template(self): + import os, shutil + from tkinter import filedialog + src = self._TEMPLATE_PATH + if not os.path.exists(src): + show_error( + "Template file not found.\n" + f"Expected at: {os.path.abspath(src)}" + ) + return + dest = filedialog.asksaveasfilename( + title="Save import template as…", + initialfile="website_import_template.xlsx", + defaultextension=".xlsx", + filetypes=[("Excel Workbook", "*.xlsx")], + ) + if not dest: + return + shutil.copy2(src, dest) + logger.info(f"Import template downloaded to: {dest}") + show_info(f"Template saved to:\n{dest}") \ No newline at end of file diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index 6eef6f1..2c39712 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -375,6 +375,20 @@ class AiSummaryView(ttk.Frame): ).pack(side="left", padx=(0, 4)) self._criteria_tree.bind("", lambda _: self._open_edit_criterion()) + else: + # User (read-only): provide a View Details button and double-click binding + btn_bar = tk.Frame(self._criteria_card, bg=C["surface"], pady=4) + btn_bar.pack(fill="x") + tk.Button( + btn_bar, text="👁 View Details", + command=self._open_view_criterion, + bg=C["surface2"], fg=C["text"], + activebackground=C["accent"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, pady=3, + ).pack(side="left", padx=(0, 4)) + self._criteria_tree.bind("", + lambda _: self._open_view_criterion()) tk.Label( self._criteria_card, @@ -444,6 +458,27 @@ class AiSummaryView(ttk.Frame): CriterionDialog(self, self.current_user, criterion_data=data, on_save=self._load_criteria_tree) + def _open_view_criterion(self): + """Open a read-only detail dialog for the selected criterion (user role).""" + cid = self._get_selected_criterion_id() + if not cid: + show_error("Please select a criterion to view.") + return + try: + from models import get_all_criteria + all_rows = {r["id"]: r for r in get_all_criteria()} + data = all_rows.get(cid) + except Exception as e: + show_error(f"Could not load criterion: {e}") + return + if not data: + show_error("Criterion not found.") + return + logger.info( + f"Criterion id={cid} viewed by user_id={self.current_user['id']}." + ) + CriterionDetailDialog(self, data) + def _delete_criterion(self): cid = self._get_selected_criterion_id() if not cid: @@ -1194,6 +1229,109 @@ class AiSummaryView(ttk.Frame): # Criterion Add / Edit Dialog (admin only) # ------------------------------------------------------------------------------ +class CriterionDetailDialog(tk.Toplevel): + """Read-only modal dialog that displays the full details of an AI evaluation criterion. + + Shown to regular users who click 'View Details' or double-click a row in the + criteria treeview. Admins continue to use CriterionDialog (edit mode) instead. + """ + + def __init__(self, parent, criterion_data: dict): + super().__init__(parent) + self._data = criterion_data + + self.title("Criterion Details") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + self._build_ui() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 560, 380 + 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 + d = self._data + + # ── Header ──────────────────────────────────────────────────────────── + hdr = tk.Frame(self, bg=C["surface"], padx=24, pady=14) + hdr.pack(fill="x") + + status_text = "Active" if d.get("is_active") else "Inactive" + status_color = C["accent"] if d.get("is_active") else C["danger"] + + tk.Label( + hdr, + text=d.get("title") or "Untitled", + bg=C["surface"], fg=C["text"], + font=FONT_HEADING, wraplength=480, justify="left", + ).pack(anchor="w") + + meta_row = tk.Frame(hdr, bg=C["surface"]) + meta_row.pack(anchor="w", pady=(4, 0)) + tk.Label( + meta_row, + text=f"Sort order: {d.get('sort_order', 0)}", + bg=C["surface"], fg=C["text_dim"], + font=FONT_SMALL, + ).pack(side="left") + tk.Label( + meta_row, text=" · ", + bg=C["surface"], fg=C["text_dim"], + font=FONT_SMALL, + ).pack(side="left") + tk.Label( + meta_row, text=status_text, + bg=C["surface"], fg=status_color, + font=FONT_SMALL, + ).pack(side="left") + + ttk.Separator(self, orient="horizontal").pack(fill="x", padx=0) + + # ── Description (scrollable, read-only) ─────────────────────────────── + body = tk.Frame(self, bg=C["bg"], padx=24, pady=16) + body.pack(fill="both", expand=True) + + tk.Label( + body, text="Description", + bg=C["bg"], fg=C["text_dim"], + font=FONT_SMALL, + ).pack(anchor="w", pady=(0, 4)) + + txt_frame = tk.Frame(body, bg=C["surface2"]) + txt_frame.pack(fill="both", expand=True) + + vsb = ttk.Scrollbar(txt_frame, orient="vertical") + vsb.pack(side="right", fill="y") + + txt = tk.Text( + txt_frame, wrap="word", + bg=C["surface2"], fg=C["text"], + relief="flat", font=FONT, + state="normal", + yscrollcommand=vsb.set, + padx=10, pady=8, + ) + txt.insert("1.0", d.get("description") or "No description provided.") + txt.config(state="disabled") # read-only after inserting content + txt.pack(fill="both", expand=True) + vsb.config(command=txt.yview) + + # ── Footer ──────────────────────────────────────────────────────────── + ttk.Separator(self, orient="horizontal").pack(fill="x") + footer = ttk.Frame(self) + footer.pack(fill="x", padx=24, pady=12) + ttk.Button( + footer, text="Close", + command=self.destroy, + ).pack(side="right") + + class CriterionDialog(tk.Toplevel): """Modal dialog for creating or editing an AI evaluation criterion.""" @@ -1207,14 +1345,16 @@ class CriterionDialog(tk.Toplevel): self.title("Edit Criterion" if self.is_edit else "Add Criterion") self.configure(bg=COLOURS["bg"]) - self.resizable(False, False) + self.resizable(False, True) # allow vertical resize for varied DPI/fonts self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() - w, h = 560, 400 + # Height increased to 520 so buttons are always visible at standard DPI. + # The dialog is vertically resizable so higher-DPI systems can expand it. + w, h = 560, 520 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") @@ -1250,7 +1390,7 @@ class CriterionDialog(tk.Toplevel): desc_vsb = ttk.Scrollbar(desc_frame, orient="vertical") desc_vsb.pack(side="right", fill="y") self._desc_txt = tk.Text( - desc_frame, height=6, wrap="word", + desc_frame, height=5, wrap="word", bg=C["surface2"], fg=C["text"], insertbackground=C["text"], relief="flat", font=FONT, @@ -1545,4 +1685,4 @@ def _human_size(n: int) -> str: if n < 1024: return f"{n:.0f} {unit}" n /= 1024 - return f"{n:.1f} GB" + return f"{n:.1f} GB" \ No newline at end of file diff --git a/website_import_template.xlsx b/website_import_template.xlsx new file mode 100644 index 0000000..8dea9ce Binary files /dev/null and b/website_import_template.xlsx differ