04/24 fix bugs
This commit is contained in:
+326
-28
@@ -1,10 +1,30 @@
|
||||
"""
|
||||
views/admin_log_view.py — Admin panel: Activity Log tab.
|
||||
views/admin_log_view.py — Admin panel: Logs
|
||||
|
||||
Two tabs:
|
||||
Activity Log — user-action audit trail (existing, from activity_log table)
|
||||
App Log — application-level logging (new, from app_log table)
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from utils.ui_helpers import COLOURS, FONT_HEADING, show_error
|
||||
from tkinter import ttk, messagebox
|
||||
import logging
|
||||
|
||||
from utils.ui_helpers import (
|
||||
COLOURS, FONT, FONT_BOLD, FONT_SMALL, FONT_HEADING,
|
||||
show_error, show_info,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("admin_log_view")
|
||||
|
||||
# Colour map for log level badges
|
||||
_LEVEL_COLOURS = {
|
||||
"DEBUG": "text_dim",
|
||||
"INFO": "text",
|
||||
"WARNING": "warning",
|
||||
"ERROR": "danger",
|
||||
"CRITICAL": "danger",
|
||||
}
|
||||
|
||||
|
||||
class AdminLogView(ttk.Frame):
|
||||
@@ -12,39 +32,317 @@ class AdminLogView(ttk.Frame):
|
||||
super().__init__(parent)
|
||||
self.current_user = current_user
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
toolbar = ttk.Frame(self)
|
||||
toolbar.pack(fill="x", pady=(0, 10))
|
||||
ttk.Label(toolbar, text="Activity Log", style="Heading.TLabel").pack(side="left")
|
||||
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
|
||||
command=self._load).pack(side="right")
|
||||
# Page header
|
||||
hdr = ttk.Frame(self)
|
||||
hdr.pack(fill="x", pady=(0, 8))
|
||||
ttk.Label(hdr, text="Logs", style="Heading.TLabel").pack(side="left")
|
||||
|
||||
# Notebook with two tabs
|
||||
nb = ttk.Notebook(self)
|
||||
nb.pack(fill="both", expand=True)
|
||||
|
||||
# ── Tab 1 — Activity Log ──────────────────────────────────────────────
|
||||
act_tab = ttk.Frame(nb)
|
||||
nb.add(act_tab, text="📋 Activity Log")
|
||||
self._build_activity_tab(act_tab)
|
||||
|
||||
# ── Tab 2 — App Log ───────────────────────────────────────────────────
|
||||
app_tab = ttk.Frame(nb)
|
||||
nb.add(app_tab, text="🖥 App Log")
|
||||
self._build_app_log_tab(app_tab)
|
||||
|
||||
nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
|
||||
self._nb = nb
|
||||
|
||||
# Load the default tab
|
||||
self._load_activity()
|
||||
|
||||
# ── Activity Log tab ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_activity_tab(self, parent):
|
||||
C = COLOURS
|
||||
|
||||
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
|
||||
toolbar.pack(fill="x")
|
||||
|
||||
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
|
||||
command=self._load_activity).pack(side="right")
|
||||
|
||||
# Search
|
||||
tk.Label(toolbar, text="Search:",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL).pack(side="left", padx=(0, 4))
|
||||
self._act_search_var = tk.StringVar()
|
||||
self._act_search_var.trace_add("write", lambda *_: self._load_activity())
|
||||
tk.Entry(toolbar, textvariable=self._act_search_var,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT, width=24).pack(
|
||||
side="left", ipady=4)
|
||||
|
||||
cols = ("Time", "User", "Action", "Entity", "Entity ID", "Detail")
|
||||
self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse")
|
||||
widths = [140, 100, 130, 100, 70, 320]
|
||||
widths = [140, 100, 140, 100, 70, 320]
|
||||
self._act_tree = ttk.Treeview(
|
||||
parent, 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="w")
|
||||
self.tree.pack(fill="both", expand=True)
|
||||
self._act_tree.heading(col, text=col)
|
||||
self._act_tree.column(col, width=w, anchor="w")
|
||||
|
||||
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")
|
||||
vsb = ttk.Scrollbar(parent, orient="vertical",
|
||||
command=self._act_tree.yview)
|
||||
self._act_tree.configure(yscrollcommand=vsb.set)
|
||||
vsb.pack(side="right", fill="y")
|
||||
self._act_tree.pack(fill="both", expand=True)
|
||||
|
||||
def _load(self):
|
||||
# Status bar
|
||||
self._act_status = tk.Label(
|
||||
parent, text="", bg=C["surface2"], fg=C["text_dim"],
|
||||
font=FONT_SMALL, anchor="w")
|
||||
self._act_status.pack(fill="x", side="bottom")
|
||||
|
||||
def _load_activity(self, *_):
|
||||
from models import get_activity_log
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
search = self._act_search_var.get().strip() \
|
||||
if hasattr(self, "_act_search_var") else ""
|
||||
self._act_tree.delete(*self._act_tree.get_children())
|
||||
try:
|
||||
for entry in get_activity_log():
|
||||
self.tree.insert("", "end", values=(
|
||||
str(entry["logged_at"])[:16],
|
||||
entry.get("username") or "—",
|
||||
entry["action"],
|
||||
entry.get("entity") or "",
|
||||
entry.get("entity_id") or "",
|
||||
entry.get("detail") or "",
|
||||
))
|
||||
# Pass search to the DB query so filtering happens server-side.
|
||||
entries = get_activity_log(limit=500, search=search)
|
||||
except Exception as e:
|
||||
show_error(f"Failed to load activity log:\n{e}")
|
||||
return
|
||||
for entry in entries:
|
||||
self._act_tree.insert("", "end", values=(
|
||||
str(entry.get("logged_at", ""))[:16],
|
||||
entry.get("username") or "—",
|
||||
entry.get("action") or "",
|
||||
entry.get("entity") or "",
|
||||
entry.get("entity_id") or "",
|
||||
entry.get("detail") or "",
|
||||
))
|
||||
self._act_status.config(text=f" {len(entries)} records")
|
||||
|
||||
# ── App Log tab ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_app_log_tab(self, parent):
|
||||
C = COLOURS
|
||||
|
||||
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
|
||||
toolbar.pack(fill="x")
|
||||
|
||||
ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton",
|
||||
command=self._load_app_log).pack(side="right", padx=(4, 0))
|
||||
|
||||
# Purge button
|
||||
tk.Button(
|
||||
toolbar, text="🗑 Purge Old Entries",
|
||||
command=self._purge_app_log,
|
||||
bg=C["surface2"], fg=C["danger"],
|
||||
activebackground=C["danger"], activeforeground=C["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=8, pady=4,
|
||||
).pack(side="right", padx=(4, 0))
|
||||
|
||||
# Level filter
|
||||
tk.Label(toolbar, text="Level:",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL).pack(side="left", padx=(0, 4))
|
||||
self._level_var = tk.StringVar(value="All")
|
||||
level_cb = ttk.Combobox(
|
||||
toolbar, textvariable=self._level_var,
|
||||
values=["All", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
state="readonly", width=10,
|
||||
)
|
||||
level_cb.pack(side="left")
|
||||
level_cb.bind("<<ComboboxSelected>>", lambda _: self._load_app_log())
|
||||
|
||||
# Search
|
||||
tk.Label(toolbar, text="Search:",
|
||||
bg=C["surface"], fg=C["text_dim"],
|
||||
font=FONT_SMALL).pack(side="left", padx=(12, 4))
|
||||
self._app_search_var = tk.StringVar()
|
||||
self._app_search_var.trace_add("write", lambda *_: self._load_app_log())
|
||||
tk.Entry(toolbar, textvariable=self._app_search_var,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
insertbackground=C["text"],
|
||||
relief="flat", font=FONT, width=24).pack(
|
||||
side="left", ipady=4)
|
||||
|
||||
# Treeview
|
||||
cols = ("Time", "Level", "Logger", "Message")
|
||||
widths = [140, 75, 130, 480]
|
||||
self._app_tree = ttk.Treeview(
|
||||
parent, columns=cols, show="headings", selectmode="browse")
|
||||
for col, w in zip(cols, widths):
|
||||
self._app_tree.heading(col, text=col)
|
||||
self._app_tree.column(
|
||||
col, width=w,
|
||||
anchor="center" if col == "Level" else "w",
|
||||
)
|
||||
|
||||
# Level colour tags
|
||||
for level, colour_key in _LEVEL_COLOURS.items():
|
||||
self._app_tree.tag_configure(level, foreground=C[colour_key])
|
||||
# Bold for WARNING and above
|
||||
self._app_tree.tag_configure("WARNING", foreground=C["warning"],
|
||||
font=FONT_BOLD)
|
||||
self._app_tree.tag_configure("ERROR", foreground=C["danger"],
|
||||
font=FONT_BOLD)
|
||||
self._app_tree.tag_configure("CRITICAL", foreground=C["danger"],
|
||||
font=FONT_BOLD)
|
||||
|
||||
vsb = ttk.Scrollbar(parent, orient="vertical",
|
||||
command=self._app_tree.yview)
|
||||
self._app_tree.configure(yscrollcommand=vsb.set)
|
||||
vsb.pack(side="right", fill="y")
|
||||
self._app_tree.pack(fill="both", expand=True)
|
||||
|
||||
# Detail strip — shows full message when a row is selected
|
||||
detail_frame = tk.Frame(parent, bg=C["surface2"], pady=6, padx=10)
|
||||
detail_frame.pack(fill="x", side="bottom")
|
||||
self._app_detail_var = tk.StringVar(
|
||||
value="Select a row to see the full message.")
|
||||
tk.Label(
|
||||
detail_frame, textvariable=self._app_detail_var,
|
||||
bg=C["surface2"], fg=C["text"],
|
||||
font=FONT_SMALL, anchor="w",
|
||||
justify="left", wraplength=900,
|
||||
).pack(fill="x")
|
||||
self._app_tree.bind("<<TreeviewSelect>>", self._on_app_row_select)
|
||||
|
||||
# Status bar
|
||||
self._app_status = tk.Label(
|
||||
parent, text="", bg=C["surface2"], fg=C["text_dim"],
|
||||
font=FONT_SMALL, anchor="w")
|
||||
self._app_status.pack(fill="x", side="bottom")
|
||||
|
||||
# Store full messages keyed by tree iid for the detail strip
|
||||
self._app_full_messages: dict[str, str] = {}
|
||||
|
||||
def _load_app_log(self, *_):
|
||||
from models import get_app_log
|
||||
level = self._level_var.get()
|
||||
level_filter = "" if level == "All" else level
|
||||
search = self._app_search_var.get().strip()
|
||||
|
||||
self._app_tree.delete(*self._app_tree.get_children())
|
||||
self._app_full_messages.clear()
|
||||
self._app_detail_var.set("Select a row to see the full message.")
|
||||
|
||||
try:
|
||||
entries = get_app_log(
|
||||
limit=500,
|
||||
level_filter=level_filter,
|
||||
search=search,
|
||||
)
|
||||
except Exception as e:
|
||||
show_error(f"Failed to load app log:\n{e}")
|
||||
return
|
||||
|
||||
for entry in entries:
|
||||
lvl = entry.get("level", "INFO")
|
||||
msg_full = entry.get("message", "")
|
||||
# Truncate long messages in the tree; full text in detail strip
|
||||
msg_short = msg_full[:120] + ("…" if len(msg_full) > 120 else "")
|
||||
iid = str(entry["id"])
|
||||
self._app_tree.insert(
|
||||
"", "end", iid=iid, tags=(lvl,),
|
||||
values=(
|
||||
str(entry.get("logged_at", ""))[:19],
|
||||
lvl,
|
||||
entry.get("logger_name", ""),
|
||||
msg_short,
|
||||
),
|
||||
)
|
||||
self._app_full_messages[iid] = msg_full
|
||||
|
||||
count = len(entries)
|
||||
self._app_status.config(text=f" {count} records")
|
||||
|
||||
def _on_app_row_select(self, event=None):
|
||||
sel = self._app_tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
iid = sel[0]
|
||||
full = self._app_full_messages.get(iid, "")
|
||||
self._app_detail_var.set(full)
|
||||
|
||||
def _purge_app_log(self):
|
||||
"""Ask for confirmation, then delete log entries older than N days."""
|
||||
days_var = tk.StringVar(value="30")
|
||||
|
||||
dlg = tk.Toplevel(self)
|
||||
dlg.title("Purge App Log")
|
||||
dlg.configure(bg=COLOURS["bg"])
|
||||
dlg.resizable(False, False)
|
||||
dlg.grab_set()
|
||||
|
||||
w, h = 340, 170
|
||||
x = (dlg.winfo_screenwidth() - w) // 2
|
||||
y = (dlg.winfo_screenheight() - h) // 2
|
||||
dlg.geometry(f"{w}x{h}+{x}+{y}")
|
||||
|
||||
tk.Label(dlg,
|
||||
text="Delete app log entries older than:",
|
||||
bg=COLOURS["bg"], fg=COLOURS["text"],
|
||||
font=FONT_BOLD).pack(pady=(20, 8))
|
||||
|
||||
row = tk.Frame(dlg, bg=COLOURS["bg"])
|
||||
row.pack()
|
||||
ttk.Spinbox(row, from_=1, to=365,
|
||||
textvariable=days_var, width=6).pack(side="left")
|
||||
tk.Label(row, text=" days",
|
||||
bg=COLOURS["bg"], fg=COLOURS["text"],
|
||||
font=FONT).pack(side="left")
|
||||
|
||||
def _do_purge():
|
||||
try:
|
||||
days = int(days_var.get())
|
||||
if days < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
show_error("Please enter a valid number of days (1–365).")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Confirm Purge",
|
||||
f"Delete all app log entries older than {days} days?\n"
|
||||
"This cannot be undone.",
|
||||
parent=dlg,
|
||||
):
|
||||
return
|
||||
try:
|
||||
from models import purge_app_log
|
||||
deleted = purge_app_log(days)
|
||||
dlg.destroy()
|
||||
show_info(f"{deleted} old log record(s) deleted.")
|
||||
self._load_app_log()
|
||||
except Exception as e:
|
||||
show_error(f"Purge failed:\n{e}")
|
||||
|
||||
btn_frame = tk.Frame(dlg, bg=COLOURS["bg"])
|
||||
btn_frame.pack(pady=12)
|
||||
tk.Button(btn_frame, text="Purge",
|
||||
command=_do_purge,
|
||||
bg=COLOURS["danger"], fg=COLOURS["white"],
|
||||
activebackground="#c94444", activeforeground=COLOURS["white"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=12, pady=6).pack(side="left", padx=(0, 8))
|
||||
tk.Button(btn_frame, text="Cancel",
|
||||
command=dlg.destroy,
|
||||
bg=COLOURS["surface2"], fg=COLOURS["text"],
|
||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||
padx=12, pady=6).pack(side="left")
|
||||
|
||||
# ── Tab switch ────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_tab_changed(self, event=None):
|
||||
try:
|
||||
idx = self._nb.index(self._nb.select())
|
||||
if idx == 0:
|
||||
self._load_activity()
|
||||
else:
|
||||
self._load_app_log()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user