""" views/admin_websites_view.py β€” Admin panel: Website Link Management tab. Changes: added Check Type (Daily / Weekly) field and treeview column. """ import tkinter as tk from tkinter import ttk import logging from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, scrolled_text, show_error, show_info, confirm_delete ) logger = logging.getLogger("admin_websites_view") CHECK_TYPE_OPTIONS = ["daily", "weekly"] CHECK_TYPE_LABELS = {"daily": "πŸ“… Daily", "weekly": "πŸ—“ Weekly"} class AdminWebsitesView(ttk.Frame): def __init__(self, parent, current_user: dict): super().__init__(parent) self.current_user = current_user self._build_ui() self._load_websites() # ─── Layout ─────────────────────────────────────────────────────────────── def _build_ui(self): toolbar = ttk.Frame(self) toolbar.pack(fill="x", pady=(0, 10)) ttk.Label(toolbar, text="Website Link Management", style="Heading.TLabel").pack(side="left") 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)) ttk.Button(toolbar, text="βœ• Delete", style="Danger.TButton", command=self._delete_selected).pack(side="right") cols = ("ID", "Name", "Type", "URL", "Note", "Created By") widths = [40, 170, 80, 230, 150, 100] self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") for col, w in zip(cols, widths): self.tree.heading(col, text=col) self.tree.column(col, width=w, anchor="center" if w <= 80 else "w") self.tree.pack(fill="both", expand=True) self.tree.bind("", lambda _: self._open_edit()) # Tag weekly rows with a subtle colour self.tree.tag_configure("weekly", foreground=COLOURS["warning"]) vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) self.tree.configure(yscrollcommand=vsb.set) vsb.place(relx=1, rely=0, relheight=1, anchor="ne") # ─── Data ───────────────────────────────────────────────────────────────── def _load_websites(self): from models import get_all_websites self.tree.delete(*self.tree.get_children()) try: for w in get_all_websites(): ct = w.get("check_type") or "daily" tag = "weekly" if ct == "weekly" else "" self.tree.insert( "", "end", iid=str(w["id"]), tags=(tag,), values=( w["id"], w["name"], CHECK_TYPE_LABELS.get(ct, ct), w["url"], (w["note"] or "")[:60], w["creator"] or "", ) ) except Exception as e: show_error(f"Failed to load websites:\n{e}") def _get_selected_id(self): sel = self.tree.selection() return int(sel[0]) if sel else None # ─── 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) def _open_edit(self): wid = self._get_selected_id() if not wid: show_error("Please select a website to edit.") return from models import get_website_by_id, get_website_credentials, get_website_assigned_users data = get_website_by_id(wid) creds = get_website_credentials(wid) data["credentials"] = creds data["assigned_users"] = get_website_assigned_users(wid) WebsiteDialog(self, self.current_user, website_data=data, on_save=self._load_websites) def _delete_selected(self): wid = self._get_selected_id() if not wid: show_error("Please select a website to delete.") return vals = self.tree.item(wid, "values") name = vals[1] if vals else str(wid) if confirm_delete(name): try: from models import delete_website delete_website(self.current_user["id"], wid) logger.info(f"Website id={wid} deleted by admin.") show_info(f"Website '{name}' deleted.") self._load_websites() except Exception as e: show_error(f"Delete failed:\n{e}") # ─── Website Dialog (Add / Edit) ────────────────────────────────────────────── class WebsiteDialog(tk.Toplevel): def __init__(self, parent, current_user, website_data, on_save): super().__init__(parent) self.current_user = current_user self.website_data = website_data self.on_save = on_save self.is_edit = website_data is not None self.cred_rows = [] self.title("Edit Website" if self.is_edit else "Add Website") self.configure(bg=COLOURS["bg"]) self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() w, h = 600, 680 x = (self.winfo_screenwidth() - w) // 2 y = (self.winfo_screenheight() - h) // 2 self.geometry(f"{w}x{h}+{x}+{y}") def _build_ui(self): # Scrollable container canvas = tk.Canvas(self, bg=COLOURS["bg"], highlightthickness=0) vsb = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) canvas.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") canvas.pack(fill="both", expand=True) self.inner = ttk.Frame(canvas) self.inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) canvas.create_window((0, 0), window=self.inner, anchor="nw") # Scope mousewheel to canvas hover β€” avoids stale-widget errors on close def _enter(e): canvas.bind_all("", lambda ev: _safe_scroll(ev)) def _leave(e): try: canvas.unbind_all("") except Exception: pass def _safe_scroll(ev): try: canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units") except Exception: pass canvas.bind("", _enter) canvas.bind("", _leave) self.protocol("WM_DELETE_WINDOW", lambda: (_leave(None), self.destroy())) ttk.Label(self.inner, text="Edit Website" if self.is_edit else "New Website", style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4)) form = ttk.Frame(self.inner) form.pack(fill="x", padx=24, pady=4) form.columnconfigure(1, weight=1) def text_row(label, r): ttk.Label(form, text=label).grid(row=r, column=0, sticky="nw", padx=(0, 10), pady=6) var = tk.StringVar() ent = ttk.Entry(form, textvariable=var) ent.grid(row=r, column=1, sticky="ew", pady=6) return var self.name_var = text_row("Name *", 0) self.url_var = text_row("URL *", 1) # ── Check Type ──────────────────────────────────────────────────────── ttk.Label(form, text="Check Type").grid( row=2, column=0, sticky="w", padx=(0, 10), pady=6) type_frame = tk.Frame(form, bg=COLOURS["bg"]) type_frame.grid(row=2, column=1, sticky="w", pady=6) self.check_type_var = tk.StringVar(value="daily") for val, lbl in [("daily", "πŸ“… Daily"), ("weekly", "πŸ—“ Weekly")]: rb = tk.Radiobutton( type_frame, text=lbl, variable=self.check_type_var, value=val, bg=COLOURS["bg"], fg=COLOURS["text"], activebackground=COLOURS["bg"], activeforeground=COLOURS["accent"], selectcolor=COLOURS["surface2"], font=FONT, cursor="hand2", ) rb.pack(side="left", padx=(0, 16)) # ── Note ────────────────────────────────────────────────────────────── ttk.Label(form, text="Note").grid(row=3, column=0, sticky="nw", padx=(0, 10), pady=6) note_frame, self.note_txt = scrolled_text(form, height=4) note_frame.grid(row=3, column=1, sticky="ew", pady=6) # ── Visibility ──────────────────────────────────────────────────────── ttk.Label(form, text="Visibility").grid( row=4, column=0, sticky="w", padx=(0, 10), pady=6) vis_frame = tk.Frame(form, bg=COLOURS["bg"]) vis_frame.grid(row=4, column=1, sticky="w", pady=6) self.visibility_var = tk.StringVar(value="all") for val, lbl in [("all", "πŸ‘₯ All Users"), ("assigned", "πŸ”’ Assigned Only")]: rb = tk.Radiobutton( vis_frame, text=lbl, variable=self.visibility_var, value=val, bg=COLOURS["bg"], fg=COLOURS["text"], activebackground=COLOURS["bg"], activeforeground=COLOURS["accent"], selectcolor=COLOURS["surface2"], font=FONT, cursor="hand2", command=self._on_visibility_change, ) rb.pack(side="left", padx=(0, 16)) # ── Assigned users (shown only when visibility='assigned') ───────────── self._user_assign_frame = ttk.Frame(self.inner) self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4)) ttk.Label(self._user_assign_frame, text="Assigned Users", style="Heading.TLabel").pack(anchor="w", pady=(4, 6)) # Load all active regular users for the picker from models import get_all_users try: all_users = [u for u in get_all_users() if u["is_active"] and u["role"] == "user"] except Exception: all_users = [] self._user_vars = {} # user_id -> BooleanVar user_grid = tk.Frame(self._user_assign_frame, bg=COLOURS["surface"], padx=12, pady=8) user_grid.pack(fill="x") for i, u in enumerate(all_users): var = tk.BooleanVar(value=False) self._user_vars[u["id"]] = var col, row = i % 3, i // 3 tk.Checkbutton( user_grid, text=f"{u['username']} ({u['full_name'] or ''})", variable=var, bg=COLOURS["surface"], fg=COLOURS["text"], activebackground=COLOURS["surface"], activeforeground=COLOURS["accent"], selectcolor=COLOURS["surface2"], font=FONT_SMALL, cursor="hand2", anchor="w", ).grid(row=row, column=col, sticky="w", padx=8, pady=2) # Hide initially; shown when visibility='assigned' self._user_assign_frame.pack_forget() # ── Credentials section (collapsed by default) ─────────────────────── ttk.Separator(self.inner, orient="horizontal").pack( fill="x", padx=24, pady=12) # Toggle header row cred_toggle = ttk.Frame(self.inner) cred_toggle.pack(fill="x", padx=24) ttk.Label(cred_toggle, text="Login Credentials", style="Heading.TLabel").pack(side="left") self._cred_expanded = False self._cred_toggle_btn = ttk.Button( cred_toggle, text="πŸ”‘ Show Credentials", style="Ghost.TButton", command=self._toggle_credentials, ) self._cred_toggle_btn.pack(side="right") ttk.Button(cred_toggle, text="οΌ‹ Add Credential", command=self._add_cred_row_visible).pack(side="right", padx=(0, 6)) # Collapsible body self._cred_body = ttk.Frame(self.inner) self.creds_container = ttk.Frame(self._cred_body) self.creds_container.pack(fill="x") # _cred_body is NOT packed initially β€” hidden by default # ── Buttons ─────────────────────────────────────────────────────────── ttk.Separator(self.inner, orient="horizontal").pack( fill="x", padx=24, pady=12) btn_frame = ttk.Frame(self.inner) btn_frame.pack(fill="x", padx=24, pady=(0, 20)) ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(6, 0)) ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", command=self.destroy).pack(side="right") # Pre-populate if editing if self.is_edit: d = self.website_data self.name_var.set(d.get("name") or "") self.url_var.set(d.get("url") or "") self.check_type_var.set(d.get("check_type") or "daily") self.note_txt.insert("1.0", d.get("note") or "") vis = d.get("visibility") or "all" self.visibility_var.set(vis) # Pre-tick assigned users assigned_ids = {u["id"] for u in d.get("assigned_users", [])} for uid, var in self._user_vars.items(): var.set(uid in assigned_ids) self._on_visibility_change() existing_creds = d.get("credentials", []) for cred in existing_creds: self._add_cred_row(cred) # Auto-expand credentials section if creds already exist if existing_creds: self._expand_credentials() # For new websites: credentials section stays collapsed def _on_visibility_change(self): """Show or hide the user assignment panel based on visibility selection.""" if self.visibility_var.get() == "assigned": # Use before=self._cred_body (not self.creds_container) because # pack's `before` argument requires the reference widget to share # the same parent. _user_assign_frame and _cred_body are both # children of self.inner; creds_container is a child of _cred_body, # so referencing it across that boundary raises a TclError. self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4), before=self._cred_body) else: self._user_assign_frame.pack_forget() def _expand_credentials(self): """Show the credentials body and update the toggle button label.""" self._cred_expanded = True self._cred_body.pack(fill="x", padx=24, pady=(4, 0)) self._cred_toggle_btn.config(text="πŸ”’ Hide Credentials") def _toggle_credentials(self): """Show or hide the collapsible credentials section.""" if self._cred_expanded: self._cred_expanded = False self._cred_body.pack_forget() self._cred_toggle_btn.config(text="πŸ”‘ Show Credentials") else: self._expand_credentials() def _add_cred_row_visible(self): """Expand the section (if collapsed) then add an empty credential row.""" if not self._cred_expanded: self._expand_credentials() self._add_cred_row() def _add_cred_row(self, cred=None): frame = ttk.Frame(self.creds_container, style="Surface.TFrame") frame.pack(fill="x", pady=4, ipady=4) label_var = tk.StringVar(value=cred.get("label", "") if cred else "") user_var = tk.StringVar(value=cred.get("username", "") if cred else "") pass_var = tk.StringVar(value=cred.get("password", "") if cred else "") for col_n, (lbl, var, show) in enumerate([ ("Label", label_var, None), ("Username", user_var, None), ("Password", pass_var, "β€’"), ]): tk.Label(frame, text=lbl, bg=COLOURS["surface"], fg=COLOURS["text_dim"], font=FONT_SMALL).grid( row=0, column=col_n*2, sticky="w", padx=(8, 2)) ent = ttk.Entry(frame, textvariable=var, show=show or "", width=14) ent.grid(row=0, column=col_n*2+1, sticky="ew", padx=(0, 8), pady=4) ttk.Button(frame, text="βœ•", style="Danger.TButton", width=3, command=lambda f=frame: self._remove_cred_row(f)).grid( row=0, column=6, padx=(0, 8)) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) frame.columnconfigure(5, weight=1) self.cred_rows.append((label_var, user_var, pass_var, frame)) def _remove_cred_row(self, frame): self.cred_rows = [(l, u, p, f) for l, u, p, f in self.cred_rows if f is not frame] frame.destroy() def _save(self): name = self.name_var.get().strip() url = self.url_var.get().strip() check_type = self.check_type_var.get() note = self.note_txt.get("1.0", "end-1c").strip() visibility = self.visibility_var.get() assigned_user_ids = [uid for uid, var in self._user_vars.items() if var.get()] 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 credentials = [] for label_var, user_var, pass_var, _ in self.cred_rows: u = user_var.get().strip() if u: credentials.append({ "label": label_var.get().strip(), "username": u, "password": pass_var.get().strip(), }) try: if self.is_edit: from models import update_website update_website(self.current_user["id"], self.website_data["id"], name, url, check_type, note, credentials, visibility, assigned_user_ids) logger.info(f"Website id={self.website_data['id']} updated " f"check_type='{check_type}' visibility='{visibility}'.") show_info("Website updated successfully.") else: from models import create_website create_website(self.current_user["id"], name, url, check_type, note, credentials, visibility, assigned_user_ids) logger.info(f"New website '{name}' created " f"check_type='{check_type}' visibility='{visibility}'.") show_info("Website created successfully.") self.on_save() 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) # ─── 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}")