Files
WebChecker/views/reports_view.py
T
2026-04-21 17:16:37 -04:00

514 lines
22 KiB
Python

"""
views/reports_view.py — Reports panel (Admin only).
Tabs
────
1. Shift Detail — every check event, filterable by date range / user / website
2. Unchecked — sites not checked on a given date, per user
3. Summary — per-user per-day completion percentage
All three tabs share the same Export toolbar (CSV + Excel).
"""
import os
import tkinter as tk
from tkinter import ttk, filedialog
import logging
from datetime import date, timedelta
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info, DateEntry,
)
logger = logging.getLogger("reports_view")
# ─── Column definitions per report type ───────────────────────────────────────
SHIFT_COLS = ["check_date", "checked_at", "username", "full_name",
"website_name", "url", "user_note", "status"]
UNCHECKED_COLS = ["check_date", "username", "full_name",
"website_name", "url", "status"]
SUMMARY_COLS = ["check_date", "username", "full_name",
"checked_count", "total_sites", "pct_complete"]
class ReportsView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._users = [] # [{id, username, full_name}, ...]
self._websites = [] # [{id, name}, ...]
self._load_filter_options()
self._build_ui()
# ─── Bootstrap ────────────────────────────────────────────────────────────
def _load_filter_options(self):
try:
from models import get_report_filter_options
self._users, self._websites = get_report_filter_options()
except Exception as e:
logger.error(f"Failed to load filter options: {e}")
# ─── Layout ───────────────────────────────────────────────────────────────
def _build_ui(self):
# ── Page header ───────────────────────────────────────────────────────
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 12))
ttk.Label(hdr, text="Reports", style="Heading.TLabel").pack(side="left")
# ── Notebook (tabs) ───────────────────────────────────────────────────
self.nb = ttk.Notebook(self)
self.nb.pack(fill="both", expand=True)
self._tab_shift = self._make_tab("📋 Shift Detail", SHIFT_COLS,
self._run_shift_report)
self._tab_unchecked = self._make_tab("⚠️ Unchecked", UNCHECKED_COLS,
self._run_unchecked_report)
self._tab_summary = self._make_tab("📊 Summary", SUMMARY_COLS,
self._run_summary_report)
self._build_chart_tab()
def _make_tab(self, label: str, columns: list, run_fn) -> dict:
"""
Build one tab with a filter strip, result treeview, and export toolbar.
Returns a dict of widget references used by the run / export functions.
"""
frame = ttk.Frame(self.nb)
self.nb.add(frame, text=label)
# ── Filter strip ──────────────────────────────────────────────────────
filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10)
filter_card.pack(fill="x", padx=0, pady=(0, 10))
widgets = {}
is_unchecked = (columns == UNCHECKED_COLS)
is_summary = (columns == SUMMARY_COLS)
col = 0
def _lbl(text):
nonlocal col
tk.Label(filter_card, text=text,
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).grid(row=0, column=col, padx=(14, 2), pady=6, sticky="w")
col += 1
def _cb(values, width=18):
nonlocal col
var = tk.StringVar(value=values[0])
cb = ttk.Combobox(filter_card, textvariable=var,
values=values, state="readonly", width=width)
cb.grid(row=0, column=col, padx=(0, 8), pady=6)
col += 1
return var, cb
# Date from / Date to — use DateEntry calendar pickers
today_str = date.today().isoformat()
week_ago = (date.today() - timedelta(days=6)).isoformat()
if is_unchecked:
_lbl("Date")
de = DateEntry(filter_card, initial_date=today_str, width=11)
de.grid(row=0, column=col, padx=(0, 8), pady=6)
col += 1
widgets["date_single"] = de
else:
_lbl("From")
de_from = DateEntry(filter_card, initial_date=week_ago, width=11)
de_from.grid(row=0, column=col, padx=(0, 4), pady=6); col += 1
_lbl("To")
de_to = DateEntry(filter_card, initial_date=today_str, width=11)
de_to.grid(row=0, column=col, padx=(0, 8), pady=6); col += 1
widgets["date_from"] = de_from
widgets["date_to"] = de_to
# User filter (all tabs)
_lbl("User")
user_labels = ["All Users"] + [
f"{u['username']}{u['full_name'] or ''}" for u in self._users
]
uvar, _ = _cb(user_labels, width=22)
widgets["user_var"] = uvar
# Website filter (shift detail only)
if not is_unchecked and not is_summary:
_lbl("Website")
site_labels = ["All Websites"] + [w["name"] for w in self._websites]
svar, _ = _cb(site_labels, width=22)
widgets["site_var"] = svar
# Run button
run_btn = tk.Button(
filter_card, text=" Run Report ",
command=lambda w=widgets: run_fn(w),
bg=COLOURS["accent"], fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD, cursor="hand2", padx=10, pady=4,
)
run_btn.grid(row=0, column=col, padx=(4, 14), pady=6); col += 1
filter_card.columnconfigure(col, weight=1)
# ── Status / count bar ────────────────────────────────────────────────
status_bar = ttk.Frame(frame)
status_bar.pack(fill="x", pady=(0, 4))
count_lbl = ttk.Label(status_bar, text="Run a report to see results.",
style="Dim.TLabel")
count_lbl.pack(side="left")
widgets["count_lbl"] = count_lbl
# Export buttons
tk.Button(
status_bar, text="⬇ Export Excel",
command=lambda w=widgets, c=columns: self._export(w, c, "excel"),
bg=COLOURS["success"], fg=COLOURS["white"],
activebackground="#3d9140", activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4,
).pack(side="right", padx=(4, 0))
tk.Button(
status_bar, text="⬇ Export CSV",
command=lambda w=widgets, c=columns: self._export(w, c, "csv"),
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["border"], activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4,
).pack(side="right", padx=(4, 0))
# ── Treeview ──────────────────────────────────────────────────────────
tree_frame = ttk.Frame(frame)
tree_frame.pack(fill="both", expand=True)
col_display = [c.replace("_", " ").title() for c in columns]
tree = ttk.Treeview(tree_frame, columns=col_display,
show="headings", selectmode="browse")
col_widths = {
"Check Date": 95, "Checked At": 140, "Username": 110,
"Full Name": 150, "Website Name": 170, "Url": 230,
"User Note": 200, "Status": 90,
"Checked Count": 100, "Total Sites": 90, "Pct Complete": 100,
}
for disp in col_display:
w = col_widths.get(disp, 120)
tree.heading(disp, text=disp,
command=lambda d=disp, t=tree: self._sort_tree(t, d))
tree.column(disp, width=w, anchor="center" if w < 130 else "w",
minwidth=60)
# Colour-tag rows by status
tree.tag_configure("checked", foreground=COLOURS["success"])
tree.tag_configure("unchecked", foreground=COLOURS["danger"])
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
widgets["tree"] = tree
widgets["columns"] = columns
widgets["rows"] = [] # populated after each run
return widgets
# ─── Report Runners ───────────────────────────────────────────────────────
def _resolve_user_id(self, user_var):
"""Translate the combobox display string back to a DB user id or None."""
val = user_var.get()
if val == "All Users":
return None
username = val.split(" — ")[0].strip()
for u in self._users:
if u["username"] == username:
return u["id"]
return None
def _resolve_website_id(self, site_var):
val = site_var.get()
if val == "All Websites":
return None
for w in self._websites:
if w["name"] == val:
return w["id"]
return None
def _populate_tree(self, widgets: dict, rows: list):
"""Clear and refill the treeview; update count label."""
tree = widgets["tree"]
columns = widgets["columns"]
tree.delete(*tree.get_children())
for row in rows:
values = [str(row.get(c) or "") for c in columns]
tag = "unchecked" if row.get("status") == "Not Checked" else "checked"
tree.insert("", "end", values=values, tags=(tag,))
widgets["rows"] = rows
widgets["count_lbl"].config(
text=f"{len(rows)} record{'s' if len(rows) != 1 else ''} found."
)
def _run_shift_report(self, widgets: dict):
from models import get_shift_report
try:
date_from = widgets["date_from"].get().strip() or None
date_to = widgets["date_to"].get().strip() or None
user_id = self._resolve_user_id(widgets["user_var"])
website_id = self._resolve_website_id(widgets["site_var"])
rows = get_shift_report(date_from, date_to, user_id, website_id)
self._populate_tree(widgets, rows)
logger.info(
f"[REPORT] Shift Detail run by {self.current_user['username']} - "
f"from={date_from} to={date_to} user={user_id} site={website_id} "
f"-> {len(rows)} rows"
)
except Exception as e:
show_error(f"Report failed:\n{e}")
def _run_unchecked_report(self, widgets: dict):
from models import get_unchecked_report
try:
target_date = widgets["date_single"].get().strip() or None
user_id = self._resolve_user_id(widgets["user_var"])
rows = get_unchecked_report(target_date, user_id)
self._populate_tree(widgets, rows)
logger.info(
f"[REPORT] Unchecked run by {self.current_user['username']} - "
f"date={target_date} user={user_id} -> {len(rows)} rows"
)
except Exception as e:
show_error(f"Report failed:\n{e}")
def _run_summary_report(self, widgets: dict):
from models import get_summary_report
try:
date_from = widgets["date_from"].get().strip() or None
date_to = widgets["date_to"].get().strip() or None
rows = get_summary_report(date_from, date_to)
self._populate_tree(widgets, rows)
logger.info(
f"[REPORT] Summary run by {self.current_user['username']} - "
f"from={date_from} to={date_to} -> {len(rows)} rows"
)
except Exception as e:
show_error(f"Report failed:\n{e}")
# ─── Export ───────────────────────────────────────────────────────────────
def _export(self, widgets: dict, columns: list, fmt: str):
rows = widgets.get("rows", [])
if not rows:
show_error("No data to export. Please run a report first.")
return
save_dir = filedialog.askdirectory(title="Select folder to save report")
if not save_dir:
return
# Derive a meaningful base filename from the active tab
tab_text = self.nb.tab(self.nb.select(), "text").strip()
base = "report_" + tab_text.replace(" ", "_").replace(" ", "_") \
.replace("/", "").replace("⚠️", "unchecked") \
.replace("📋", "shift").replace("📊", "summary") \
.lower()
try:
from utils.export import export_csv, export_excel
if fmt == "csv":
path = export_csv(rows, columns, base, save_dir)
else:
sheet = tab_text.replace(" ", " ").strip()
path = export_excel(rows, columns, base, save_dir, sheet_title=sheet)
from models import log_action
log_action(
self.current_user["id"],
f"EXPORT_{fmt.upper()}",
"reports",
None,
f"Exported '{tab_text.strip()}' ({len(rows)} rows) → {path}"
)
show_info(f"Export complete!\n\nSaved to:\n{path}")
except Exception as e:
show_error(f"Export failed:\n{e}")
# ─── Column Sort ──────────────────────────────────────────────────────────
@staticmethod
def _sort_tree(tree: ttk.Treeview, col: str):
"""Toggle ascending / descending sort on a treeview column."""
items = [(tree.set(k, col), k) for k in tree.get_children("")]
reverse = getattr(tree, f"_sort_rev_{col}", False)
items.sort(reverse=reverse)
for idx, (_, k) in enumerate(items):
tree.move(k, "", idx)
setattr(tree, f"_sort_rev_{col}", not reverse)
# ─── Chart Tab ────────────────────────────────────────────────────────────
def _build_chart_tab(self):
"""Fourth tab: completion bar chart powered by matplotlib."""
frame = ttk.Frame(self.nb)
self.nb.add(frame, text="📈 Completion Chart")
# Filter strip
filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10)
filter_card.pack(fill="x", pady=(0, 10))
tk.Label(filter_card, text="From",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).grid(row=0, column=0, padx=(14, 2), pady=6)
today_str = date.today().isoformat()
week_ago = (date.today() - timedelta(days=6)).isoformat()
self._chart_from = DateEntry(filter_card, initial_date=week_ago, width=11)
self._chart_from.grid(row=0, column=1, padx=(0, 4), pady=6)
tk.Label(filter_card, text="To",
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
font=FONT_SMALL).grid(row=0, column=2, padx=(4, 2), pady=6)
self._chart_to = DateEntry(filter_card, initial_date=today_str, width=11)
self._chart_to.grid(row=0, column=3, padx=(0, 12), pady=6)
tk.Button(
filter_card, text=" Generate Chart ",
command=self._run_chart,
bg=COLOURS["accent"], fg=COLOURS["white"],
activebackground=COLOURS["accent_hover"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=10, pady=4,
).grid(row=0, column=4, padx=(0, 14), pady=6)
filter_card.columnconfigure(5, weight=1)
# Chart canvas placeholder
self._chart_status = ttk.Label(
frame,
text="Select a date range and click Generate Chart.",
style="Dim.TLabel"
)
self._chart_status.pack(pady=20)
self._chart_frame = ttk.Frame(frame)
self._chart_frame.pack(fill="both", expand=True, padx=8, pady=8)
def _run_chart(self):
"""Fetch summary data and render a grouped bar chart."""
date_from = self._chart_from.get().strip() or None
date_to = self._chart_to.get().strip() or None
try:
from models import get_summary_report
rows = get_summary_report(date_from, date_to)
except Exception as e:
show_error(f"Failed to load chart data:\n{e}")
return
if not rows:
self._chart_status.config(text="No data found for the selected range.")
return
self._chart_status.config(text="")
self._render_chart(rows, date_from, date_to)
logger.info(f"[CHART] Generated completion chart "
f"from={date_from} to={date_to} rows={len(rows)}")
def _render_chart(self, rows: list, date_from, date_to):
"""Build and embed a matplotlib bar chart in the chart frame."""
# Clear previous chart
for w in self._chart_frame.winfo_children():
w.destroy()
try:
import matplotlib
matplotlib.use("Agg") # non-interactive backend — safe for Tkinter
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
except ImportError:
show_error(
"matplotlib is required for charts.\n"
"Install it with: pip install matplotlib"
)
return
C = COLOURS
# ── Aggregate per-user average completion ──────────────────────────────
from collections import defaultdict
user_pcts: dict[str, list] = defaultdict(list)
for row in rows:
user_pcts[row["username"]].append(float(row["pct_complete"] or 0))
users = list(user_pcts.keys())
avg_pct = [sum(v) / len(v) for v in user_pcts.values()]
# Colour bars by completion level
bar_colours = [
"#4caf50" if p >= 100 else
"#f0a500" if p >= 50 else
"#e05c5c"
for p in avg_pct
]
# ── Plot ──────────────────────────────────────────────────────────────
fig_bg = C["surface"]
axes_bg = C["surface2"]
fig, ax = plt.subplots(figsize=(max(6, len(users) * 0.9 + 2), 4),
facecolor=fig_bg)
ax.set_facecolor(axes_bg)
x = range(len(users))
bars = ax.bar(x, avg_pct, color=bar_colours,
width=0.55, zorder=2)
# Grid lines
ax.yaxis.set_major_locator(mticker.MultipleLocator(20))
ax.set_ylim(0, 110)
ax.grid(axis="y", color=C["border"], linewidth=0.7, zorder=1)
ax.set_axisbelow(True)
# Labels
ax.set_xticks(list(x))
ax.set_xticklabels(users, rotation=30, ha="right",
color=C["text"], fontsize=9)
ax.set_ylabel("Avg Completion (%)", color=C["text_dim"], fontsize=9)
ax.tick_params(colors=C["text_dim"], which="both")
for spine in ax.spines.values():
spine.set_edgecolor(C["border"])
title_range = ""
if date_from and date_to:
title_range = f" ({date_from} to {date_to})"
ax.set_title(f"User Completion{title_range}",
color=C["text"], fontsize=11, pad=10)
# Value labels on bars
for bar, pct in zip(bars, avg_pct):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 1.5,
f"{pct:.0f}%",
ha="center", va="bottom",
color=C["text"], fontsize=8, fontweight="bold",
)
fig.tight_layout()
# ── Embed in Tkinter ──────────────────────────────────────────────────
canvas = FigureCanvasTkAgg(fig, master=self._chart_frame)
canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True)
plt.close(fig) # free memory