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