04/24 Added import website in bulk

This commit is contained in:
2026-04-24 13:57:49 -04:00
parent 5f2a4e4749
commit 88b5e85c5b
7 changed files with 567 additions and 33 deletions
+40 -3
View File
@@ -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("<Configure>", _on_sidebar_configure)
sidebar_canvas.bind("<Configure>", _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("<MouseWheel>", _on_mousewheel)
# App branding
brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20)
+11 -11
View File
@@ -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()
+30 -6
View File
@@ -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,
"<p>You can now enable daily reports from the Email Settings panel.</p>"
"</body></html>"
)
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:
+22 -7
View File
@@ -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()
+318
View File
@@ -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}")
+143 -3
View File
@@ -375,6 +375,20 @@ class AiSummaryView(ttk.Frame):
).pack(side="left", padx=(0, 4))
self._criteria_tree.bind("<Double-1>",
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("<Double-1>",
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,
Binary file not shown.