04/24 Added import website in bulk
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+26
-2
@@ -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["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["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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
Reference in New Issue
Block a user