04/21 Fisrt commit
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user