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
+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}")