466 lines
20 KiB
Python
466 lines
20 KiB
Python
"""
|
||
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="✎ 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("<Double-1>", 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_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("<Configure>",
|
||
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("<MouseWheel>",
|
||
lambda ev: _safe_scroll(ev))
|
||
def _leave(e):
|
||
try:
|
||
canvas.unbind_all("<MouseWheel>")
|
||
except Exception:
|
||
pass
|
||
def _safe_scroll(ev):
|
||
try:
|
||
canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units")
|
||
except Exception:
|
||
pass
|
||
|
||
canvas.bind("<Enter>", _enter)
|
||
canvas.bind("<Leave>", _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
|
||
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}")
|