From f2a8d3f15b575f799ea06fa2ed9dd8109d89b5f5 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Fri, 24 Apr 2026 14:26:07 -0400 Subject: [PATCH] 04/24 Fixed bulk import check for duplicate --- models.py | 36 ++++++++++ views/admin_shifts_view.py | 46 +++++++++++-- views/admin_websites_view.py | 125 ++++++++++++++++++++++++++--------- website_import_template.xlsx | Bin 7326 -> 0 bytes 4 files changed, 170 insertions(+), 37 deletions(-) delete mode 100644 website_import_template.xlsx diff --git a/models.py b/models.py index 4da6fec..4afea8e 100644 --- a/models.py +++ b/models.py @@ -445,6 +445,42 @@ def get_all_websites(): conn.close() +def get_existing_website_urls() -> set: + """Return a set of normalised (lowercased, stripped) URLs already in the DB. + Used by the bulk import dialog to detect duplicates before inserting. + Includes both active and inactive websites so re-importing a soft-deleted + site is also flagged rather than silently creating a second record. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT url FROM websites") + rows = cur.fetchall() + cur.close() + return {(r[0] or "").strip().lower() for r in rows} + finally: + if conn: + conn.close() + + +def get_existing_website_names() -> set: + """Return a set of normalised (lowercased, stripped) names already in the DB. + Used alongside get_existing_website_urls() for duplicate detection. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("SELECT name FROM websites") + rows = cur.fetchall() + cur.close() + return {(r[0] or "").strip().lower() for r in rows} + finally: + if conn: + conn.close() + + def get_website_by_id(website_id: int): conn = None try: diff --git a/views/admin_shifts_view.py b/views/admin_shifts_view.py index 61662b8..f1ab7d3 100644 --- a/views/admin_shifts_view.py +++ b/views/admin_shifts_view.py @@ -450,12 +450,26 @@ class ShiftDialog(tk.Toplevel): ) self._user_picker.grid(row=0, column=0, sticky="nsew", pady=(0, 8)) - # Websites picker + # Websites picker — include check_type suffix in label and colour map + _TYPE_SUFFIX = {"daily": " [D]", "weekly": " [W]"} + _TYPE_COLOUR = { + "daily": COLOURS.get("accent", "#5B4DE8"), + "weekly": COLOURS.get("warning", "#F57C00"), + } + site_items = [ + (w["id"], w["name"] + _TYPE_SUFFIX.get(w.get("check_type", "daily"), "")) + for w in self._all_websites + ] + site_colours = { + w["id"]: _TYPE_COLOUR.get(w.get("check_type", "daily"), COLOURS["text"]) + for w in self._all_websites + } self._site_picker = _DualListPicker( pane, title="Assigned Websites", - all_items=[(w["id"], w["name"]) for w in self._all_websites], + all_items=site_items, allow_reorder=True, + item_colours=site_colours, ) self._site_picker.grid(row=1, column=0, sticky="nsew") @@ -541,11 +555,13 @@ class _DualListPicker(ttk.Frame): Supports optional drag-to-reorder on the assigned list. """ def __init__(self, parent, title: str, all_items: list, - allow_reorder=False): + allow_reorder=False, item_colours: dict = None): super().__init__(parent) self._all_items = all_items # [(id, label), ...] self._allow_reorder = allow_reorder self._drag_start = None + # item_colours: {id: colour_str} — applied per-item after refresh + self._item_colours = item_colours or {} self._build(title) def _build(self, title: str): @@ -555,7 +571,19 @@ class _DualListPicker(ttk.Frame): ttk.Label(self, text=title, style="Heading.TLabel").grid( - row=0, column=0, columnspan=3, sticky="w", pady=(4, 6)) + row=0, column=0, columnspan=3, sticky="w", pady=(4, 2)) + + # Colour legend — only shown when item_colours are provided + if self._item_colours: + legend = tk.Frame(self, bg=COLOURS["bg"]) + legend.grid(row=0, column=0, columnspan=3, sticky="e", pady=(4, 2)) + for lbl_text, colour in [ + ("● Daily", COLOURS.get("accent", "#5B4DE8")), + ("● Weekly", COLOURS.get("warning", "#F57C00")), + ]: + tk.Label(legend, text=lbl_text, + bg=COLOURS["bg"], fg=colour, + font=FONT_SMALL).pack(side="left", padx=(0, 10)) # ── Available list ──────────────────────────────────────────────────── avail_frame = tk.Frame(self, bg=COLOURS["surface"]) @@ -632,12 +660,16 @@ class _DualListPicker(ttk.Frame): def _refresh_listboxes(self): self._avail_lb.delete(0, "end") - for _, lbl in self._avail_data: + for idx, (id_, lbl) in enumerate(self._avail_data): self._avail_lb.insert("end", lbl) + if id_ in self._item_colours: + self._avail_lb.itemconfig(idx, fg=self._item_colours[id_]) self._assign_lb.delete(0, "end") - for _, lbl in self._assign_data: + for idx, (id_, lbl) in enumerate(self._assign_data): self._assign_lb.insert("end", lbl) + if id_ in self._item_colours: + self._assign_lb.itemconfig(idx, fg=self._item_colours[id_]) def _add(self): sel = list(self._avail_lb.curselection()) @@ -747,4 +779,4 @@ def _valid_time(s: str) -> bool: h, m = int(parts[0]), int(parts[1]) return 0 <= h <= 23 and 0 <= m <= 59 except ValueError: - return False + return False \ No newline at end of file diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index 14dc0b7..c32d526 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -608,15 +608,17 @@ class BulkImportDialog(tk.Toplevel): 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] + cols = ("name", "url", "check_type", "note", "visibility", "status") + widths = [150, 210, 80, 150, 80, 110] 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.heading(col, text=col.capitalize()) 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"]) + self._tree.tag_configure("skip", foreground=C["text_dim"]) + self._tree.tag_configure("valid", foreground=C["text"]) + self._tree.tag_configure("dup_url", foreground=C["warning"]) + self._tree.tag_configure("dup_name", foreground=C["warning"]) vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview) @@ -672,8 +674,18 @@ class BulkImportDialog(tk.Toplevel): text="No data rows found.", fg=COLOURS["warning"]) return + # Fetch existing URLs and names once for the entire preview pass + try: + from models import get_existing_website_urls, get_existing_website_names + existing_urls = get_existing_website_urls() + existing_names = get_existing_website_names() + except Exception: + existing_urls = set() + existing_names = set() + valid_count = 0 skip_count = 0 + dup_count = 0 for row in rows[:200]: # preview cap name = (row.get("name") or "").strip() url = (row.get("url") or "").strip() @@ -687,27 +699,47 @@ class BulkImportDialog(tk.Toplevel): if vis not in self._ALLOWED_VIS: vis = "all" - skip = not name or not url - tag = "skip" if skip else "valid" + # Classify the row + skip = not name or not url + dup_url = not skip and url.lower() in existing_urls + dup_name = not skip and not dup_url and name.lower() in existing_names + if skip: + tag = "skip" + status = "⚠ Missing field" skip_count += 1 + elif dup_url: + tag = "dup_url" + status = "⚠ Duplicate URL" + dup_count += 1 + elif dup_name: + tag = "dup_name" + status = "⚠ Duplicate name" + dup_count += 1 else: + tag = "valid" + status = "✔ Will import" valid_count += 1 self._tree.insert("", "end", tags=(tag,), - values=(name, url, ct, note[:60], vis)) + values=(name, url, ct, note[:50], vis, status)) self._rows.append({ "name": name, "url": url, "check_type": ct, "note": note, "visibility": vis, - "_skip": skip, + "_skip": skip or dup_url or dup_name, }) total = len(rows) shown = min(total, 200) more = f" ({total - shown} more not shown)" if total > 200 else "" + parts = [f"✔ {valid_count} will import"] + if dup_count: + parts.append(f"⚠ {dup_count} duplicate(s) skipped") + if skip_count: + parts.append(f"✕ {skip_count} missing required field(s)") self._status_lbl.config( - text=(f"Preview: {valid_count} valid row(s), " - f"{skip_count} skipped (missing name/url).{more}"), + text="Preview: " + ", ".join(parts) + more + ". " + "(Duplicates are highlighted in orange.)", fg=COLOURS["text_dim"], ) if valid_count: @@ -762,20 +794,55 @@ class BulkImportDialog(tk.Toplevel): # ── Import ──────────────────────────────────────────────────────────────── def _do_import(self): - """Insert all valid rows via create_website(). Skips duplicates by URL.""" - from models import create_website - import re + """Insert all valid, non-duplicate rows via create_website(). + + Duplicate detection is done in the application layer by fetching + existing URLs and names from the DB immediately before inserting. + This is necessary because there is no UNIQUE constraint on the + websites table, so relying on a DB error (1062) would not work. + """ + from models import ( + create_website, + get_existing_website_urls, + get_existing_website_names, + ) to_import = [r for r in self._rows if not r["_skip"]] if not to_import: - show_error("No valid rows to import.") + show_error("No valid rows to import.\n\n" + "All rows are either missing required fields " + "or are duplicates of existing websites.") return - inserted = 0 - skipped = 0 - errors = [] + # Re-fetch existing state at import time — user may have been on + # the preview screen for a while; another admin could have added + # a site in the interim. + try: + existing_urls = get_existing_website_urls() + existing_names = get_existing_website_names() + except Exception as e: + show_error(f"Could not verify existing websites:\n{e}") + return + + inserted = 0 + dup_url = 0 + dup_name = 0 + errors = [] for r in to_import: + url_key = r["url"].strip().lower() + name_key = r["name"].strip().lower() + + if url_key in existing_urls: + dup_url += 1 + logger.info(f"[IMPORT] Skipped — duplicate URL: {r['url']}") + continue + + if name_key in existing_names: + dup_name += 1 + logger.info(f"[IMPORT] Skipped — duplicate name: {r['name']}") + continue + try: create_website( admin_id=self.current_user["id"], @@ -786,26 +853,24 @@ class BulkImportDialog(tk.Toplevel): credentials=[], visibility=r["visibility"], ) + # Track inserted URL/name so intra-file duplicates are + # also caught (e.g. the same URL appears twice in the CSV). + existing_urls.add(url_key) + existing_names.add(name_key) 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}") + 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 dup_url: + msg += f"\n⚠ {dup_url} row(s) skipped — URL already exists." + if dup_name: + msg += f"\n⚠ {dup_name} row(s) skipped — name already exists." if errors: msg += f"\n✕ {len(errors)} error(s):\n" + "\n".join(errors[:5]) show_info(msg) diff --git a/website_import_template.xlsx b/website_import_template.xlsx deleted file mode 100644 index 8dea9ceb865697f653f51b763601144fbe05a7bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7326 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 nA`f{Up2@#?>h7zC|K)@#LXhv