288 lines
12 KiB
Python
288 lines
12 KiB
Python
"""
|
|
ui/history.py
|
|
-------------
|
|
Draw History browser screen.
|
|
Shows a sortable, filterable Treeview of all draw records.
|
|
"""
|
|
|
|
import os
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
|
|
from db.models import get_all_games, get_game_by_name, get_draws_with_game
|
|
from core.exporter import (
|
|
export_draws_excel, export_draws_csv, default_path, ensure_exports_dir,
|
|
)
|
|
|
|
_COLUMNS = ("game", "date", "numbers", "bonus", "multiplier", "source")
|
|
_LABELS = {
|
|
"game": "Game",
|
|
"date": "Date",
|
|
"numbers": "Numbers",
|
|
"bonus": "Bonus",
|
|
"multiplier": "Mult.",
|
|
"source": "Source",
|
|
}
|
|
_WIDTHS = {
|
|
"game": 120,
|
|
"date": 100,
|
|
"numbers": 190,
|
|
"bonus": 55,
|
|
"multiplier": 55,
|
|
"source": 130,
|
|
}
|
|
_ANCHORS = {c: "center" for c in _COLUMNS}
|
|
_ANCHORS["numbers"] = "w"
|
|
_ANCHORS["source"] = "w"
|
|
|
|
|
|
def _fmt_numbers(raw: str) -> str:
|
|
"""'1,13,36,61,69' → '1 13 36 61 69'"""
|
|
return " ".join(raw.split(",")) if raw else ""
|
|
|
|
|
|
class HistoryScreen(ttk.Frame):
|
|
def __init__(self, parent, **kwargs):
|
|
super().__init__(parent, **kwargs)
|
|
self._sort_col = "date"
|
|
self._sort_asc = False # newest first
|
|
|
|
self._build_ui()
|
|
self.refresh()
|
|
|
|
# ── UI construction ───────────────────────────────────────────────────────
|
|
|
|
def _build_ui(self):
|
|
# ── Filter bar ────────────────────────────────────────────────────────
|
|
bar = ttk.Frame(self, padding=(6, 6, 6, 4))
|
|
bar.pack(fill="x")
|
|
|
|
ttk.Label(bar, text="Game:").pack(side="left")
|
|
self._game_var = tk.StringVar(value="All Games")
|
|
self._game_cb = ttk.Combobox(
|
|
bar, textvariable=self._game_var, state="readonly", width=15
|
|
)
|
|
self._game_cb.pack(side="left", padx=(4, 14))
|
|
|
|
ttk.Label(bar, text="From:").pack(side="left")
|
|
self._from_entry = ttk.Entry(bar, width=11)
|
|
self._from_entry.pack(side="left", padx=(4, 0))
|
|
self._from_entry.insert(0, "YYYY-MM-DD")
|
|
self._from_entry.bind("<FocusIn>", lambda e: self._clear_hint(self._from_entry, "YYYY-MM-DD"))
|
|
self._from_entry.bind("<FocusOut>", lambda e: self._restore_hint(self._from_entry, "YYYY-MM-DD"))
|
|
|
|
ttk.Label(bar, text="To:", padding=(8, 0, 0, 0)).pack(side="left")
|
|
self._to_entry = ttk.Entry(bar, width=11)
|
|
self._to_entry.pack(side="left", padx=(4, 14))
|
|
self._to_entry.insert(0, "YYYY-MM-DD")
|
|
self._to_entry.bind("<FocusIn>", lambda e: self._clear_hint(self._to_entry, "YYYY-MM-DD"))
|
|
self._to_entry.bind("<FocusOut>", lambda e: self._restore_hint(self._to_entry, "YYYY-MM-DD"))
|
|
|
|
ttk.Label(bar, text="Number:", padding=(8, 0, 0, 0)).pack(side="left")
|
|
self._number_var = tk.StringVar()
|
|
self._number_entry = ttk.Entry(bar, textvariable=self._number_var, width=5)
|
|
self._number_entry.pack(side="left", padx=(4, 14))
|
|
self._number_entry.bind("<Return>", lambda e: self._apply_filter())
|
|
|
|
ttk.Button(bar, text="Search", command=self._apply_filter).pack(side="left", padx=(0, 4))
|
|
ttk.Button(bar, text="Clear", command=self._clear_filter).pack(side="left")
|
|
|
|
ttk.Button(bar, text="↻ Refresh", command=self.refresh).pack(side="right")
|
|
ttk.Button(bar, text="Export CSV", command=self._export_csv).pack(side="right", padx=(0, 4))
|
|
ttk.Button(bar, text="Export Excel",command=self._export_excel).pack(side="right", padx=(0, 4))
|
|
|
|
# ── Treeview + scrollbars ─────────────────────────────────────────────
|
|
tree_frame = ttk.Frame(self)
|
|
tree_frame.pack(fill="both", expand=True, padx=6, pady=(2, 0))
|
|
|
|
self._tree = ttk.Treeview(
|
|
tree_frame, columns=_COLUMNS, show="headings", selectmode="browse"
|
|
)
|
|
for col in _COLUMNS:
|
|
self._tree.heading(
|
|
col, text=_LABELS[col],
|
|
command=lambda c=col: self._sort_by(c),
|
|
)
|
|
self._tree.column(col, width=_WIDTHS[col], anchor=_ANCHORS[col], stretch=(col == "numbers"))
|
|
|
|
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._tree.yview)
|
|
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._tree.xview)
|
|
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
|
|
|
self._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)
|
|
|
|
# ── Row count ─────────────────────────────────────────────────────────
|
|
self._count_var = tk.StringVar(value="0 rows")
|
|
ttk.Label(self, textvariable=self._count_var, anchor="e",
|
|
padding=(6, 2)).pack(fill="x")
|
|
|
|
# ── Hint helpers ──────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def _clear_hint(entry: ttk.Entry, hint: str):
|
|
if entry.get() == hint:
|
|
entry.delete(0, "end")
|
|
|
|
@staticmethod
|
|
def _restore_hint(entry: ttk.Entry, hint: str):
|
|
if entry.get().strip() == "":
|
|
entry.insert(0, hint)
|
|
|
|
# ── Data loading ──────────────────────────────────────────────────────────
|
|
|
|
def refresh(self):
|
|
"""Reload games dropdown and re-run current filter."""
|
|
self._load_games()
|
|
self._apply_filter()
|
|
|
|
def _load_games(self):
|
|
games = get_all_games()
|
|
names = ["All Games"] + [g["name"] for g in games]
|
|
self._game_cb["values"] = names
|
|
if self._game_var.get() not in names:
|
|
self._game_var.set("All Games")
|
|
|
|
def _apply_filter(self):
|
|
game_name = self._game_var.get()
|
|
raw_from = self._from_entry.get().strip()
|
|
raw_to = self._to_entry.get().strip()
|
|
date_from = raw_from if raw_from and raw_from != "YYYY-MM-DD" else None
|
|
date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
|
|
|
|
raw_num = self._number_var.get().strip()
|
|
number = int(raw_num) if raw_num.isdigit() else None
|
|
|
|
if game_name == "All Games":
|
|
game_id = None
|
|
else:
|
|
game = get_game_by_name(game_name)
|
|
game_id = game["id"] if game else None
|
|
|
|
rows = get_draws_with_game(
|
|
game_id=game_id,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
order="DESC" if not self._sort_asc else "ASC",
|
|
number=number,
|
|
)
|
|
self._populate(rows)
|
|
|
|
def _populate(self, rows):
|
|
self._tree.delete(*self._tree.get_children())
|
|
for row in rows:
|
|
self._tree.insert("", "end", values=(
|
|
row["game_name"],
|
|
row["draw_date"],
|
|
_fmt_numbers(row["numbers"]),
|
|
row["bonus"] or "",
|
|
row["multiplier"] or "",
|
|
row["source"] or "",
|
|
))
|
|
count = len(rows)
|
|
self._count_var.set(f"{count} row{'s' if count != 1 else ''}")
|
|
|
|
# ── Sorting ───────────────────────────────────────────────────────────────
|
|
|
|
def _sort_by(self, col):
|
|
if self._sort_col == col:
|
|
self._sort_asc = not self._sort_asc
|
|
else:
|
|
# Remove indicator from previous column
|
|
self._tree.heading(self._sort_col, text=_LABELS[self._sort_col])
|
|
self._sort_col = col
|
|
self._sort_asc = col != "date" # date defaults descending, others ascending
|
|
|
|
arrow = "▲" if self._sort_asc else "▼"
|
|
self._tree.heading(col, text=f"{_LABELS[col]} {arrow}")
|
|
|
|
col_idx = _COLUMNS.index(col)
|
|
items = [(self._tree.set(iid, col), iid) for iid in self._tree.get_children()]
|
|
|
|
# Numeric sort for bonus/multiplier columns
|
|
if col in ("bonus", "multiplier"):
|
|
def key(x):
|
|
try:
|
|
return (0, int(x[0]))
|
|
except (ValueError, TypeError):
|
|
return (1, x[0])
|
|
items.sort(key=key, reverse=not self._sort_asc)
|
|
else:
|
|
items.sort(key=lambda x: x[0], reverse=not self._sort_asc)
|
|
|
|
for i, (_, iid) in enumerate(items):
|
|
self._tree.move(iid, "", i)
|
|
|
|
# ── Clear filter ──────────────────────────────────────────────────────────
|
|
|
|
def _clear_filter(self):
|
|
self._game_var.set("All Games")
|
|
self._from_entry.delete(0, "end")
|
|
self._from_entry.insert(0, "YYYY-MM-DD")
|
|
self._to_entry.delete(0, "end")
|
|
self._to_entry.insert(0, "YYYY-MM-DD")
|
|
self._number_var.set("")
|
|
self._sort_col = "date"
|
|
self._sort_asc = False
|
|
for c in _COLUMNS:
|
|
self._tree.heading(c, text=_LABELS[c])
|
|
self._apply_filter()
|
|
|
|
# ── Export ────────────────────────────────────────────────────────────────
|
|
|
|
def _current_filter(self):
|
|
"""Return (game_id, date_from, date_to) for the active filter."""
|
|
name = self._game_var.get()
|
|
if name == "All Games":
|
|
game_id = None
|
|
else:
|
|
g = get_game_by_name(name)
|
|
game_id = g["id"] if g else None
|
|
|
|
raw_from = self._from_entry.get().strip()
|
|
raw_to = self._to_entry.get().strip()
|
|
date_from = raw_from if raw_from and raw_from != "YYYY-MM-DD" else None
|
|
date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
|
|
return game_id, date_from, date_to
|
|
|
|
def _active_number(self):
|
|
raw = self._number_var.get().strip()
|
|
return int(raw) if raw.isdigit() else None
|
|
|
|
def _export_excel(self):
|
|
ensure_exports_dir()
|
|
fp = filedialog.asksaveasfilename(
|
|
title="Export draw history",
|
|
defaultextension=".xlsx",
|
|
filetypes=[("Excel workbook", "*.xlsx")],
|
|
initialfile=f"draws_{__import__('datetime').datetime.now().strftime('%Y%m%d')}.xlsx",
|
|
)
|
|
if not fp:
|
|
return
|
|
try:
|
|
game_id, date_from, date_to = self._current_filter()
|
|
export_draws_excel(fp, game_id=game_id, date_from=date_from, date_to=date_to)
|
|
self._count_var.set(f"Exported → {os.path.basename(fp)}")
|
|
except Exception as e:
|
|
messagebox.showerror("Export failed", str(e))
|
|
|
|
def _export_csv(self):
|
|
ensure_exports_dir()
|
|
fp = filedialog.asksaveasfilename(
|
|
title="Export draw history",
|
|
defaultextension=".csv",
|
|
filetypes=[("CSV file", "*.csv")],
|
|
initialfile=f"draws_{__import__('datetime').datetime.now().strftime('%Y%m%d')}.csv",
|
|
)
|
|
if not fp:
|
|
return
|
|
try:
|
|
game_id, date_from, date_to = self._current_filter()
|
|
export_draws_csv(fp, game_id=game_id, date_from=date_from, date_to=date_to)
|
|
self._count_var.set(f"Exported → {os.path.basename(fp)}")
|
|
except Exception as e:
|
|
messagebox.showerror("Export failed", str(e))
|