372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""
|
||
ui/settings.py
|
||
--------------
|
||
Settings screen: game toggles, data-source fetch history,
|
||
manual fetch button, and database statistics.
|
||
on_fetch: callable injected by main.py to trigger the shared fetch thread.
|
||
"""
|
||
|
||
import tkinter as tk
|
||
from tkinter import ttk, messagebox, filedialog
|
||
import logging
|
||
|
||
from db.database import get_db_stats, backup_db, restore_db
|
||
from db.models import (
|
||
get_all_games, get_draw_count, set_game_active,
|
||
get_last_fetch_per_source, get_predictions,
|
||
add_game, delete_game, _BUILTIN_GAMES,
|
||
)
|
||
from core.importer import import_draws_csv
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_SOURCE_NAMES = {
|
||
"powerball_ny": "Powerball (NY)",
|
||
"megamillions_ny": "Mega Millions (NY)",
|
||
"megamillions_tx": "Mega Millions (TX)",
|
||
"cash5_va": "Cash 5 (VA)",
|
||
"millionaireforlife_va": "Millionaire for Life (VA)",
|
||
"bankamillion_va": "Bank a Million (VA)",
|
||
}
|
||
_ALL_SOURCES = [
|
||
"powerball_ny", "megamillions_ny", "megamillions_tx",
|
||
"cash5_va", "millionaireforlife_va", "bankamillion_va",
|
||
]
|
||
|
||
_HEADER_FONT = ("TkDefaultFont", 10, "bold")
|
||
|
||
|
||
class SettingsScreen(ttk.Frame):
|
||
def __init__(self, parent, on_fetch=None, **kwargs):
|
||
super().__init__(parent, **kwargs)
|
||
self._on_fetch = on_fetch # injected by main.py
|
||
self._game_vars: dict[int, tk.BooleanVar] = {}
|
||
|
||
self._build_ui()
|
||
|
||
# ── Layout ────────────────────────────────────────────────────────────────
|
||
|
||
def _build_ui(self):
|
||
# Scrollable container
|
||
canvas = tk.Canvas(self, highlightthickness=0)
|
||
vsb = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
|
||
canvas.configure(yscrollcommand=vsb.set)
|
||
vsb.pack(side="right", fill="y")
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
inner = ttk.Frame(canvas, padding=(24, 18, 24, 18))
|
||
win = canvas.create_window((0, 0), window=inner, anchor="nw")
|
||
|
||
def _resize(event=None):
|
||
canvas.configure(scrollregion=canvas.bbox("all"))
|
||
canvas.itemconfig(win, width=canvas.winfo_width())
|
||
|
||
inner.bind("<Configure>", lambda e: _resize())
|
||
canvas.bind("<Configure>", lambda e: _resize())
|
||
canvas.bind("<Enter>",
|
||
lambda e: canvas.bind_all("<MouseWheel>",
|
||
lambda ev: canvas.yview_scroll(-1 * (ev.delta // 120), "units")))
|
||
canvas.bind("<Leave>",
|
||
lambda e: canvas.unbind_all("<MouseWheel>"))
|
||
|
||
self._inner = inner
|
||
self._build_sections()
|
||
|
||
def _section(self, title):
|
||
"""Create and return a new section frame appended to the inner frame."""
|
||
frame = ttk.Frame(self._inner)
|
||
frame.pack(fill="x", pady=(0, 22))
|
||
ttk.Label(frame, text=title, font=_HEADER_FONT).pack(anchor="w")
|
||
ttk.Separator(frame, orient="horizontal").pack(fill="x", pady=(4, 10))
|
||
body = ttk.Frame(frame)
|
||
body.pack(fill="x")
|
||
return body
|
||
|
||
def _build_sections(self):
|
||
self._games_body = self._section("Games")
|
||
self._sources_body = self._section("Data Sources")
|
||
self._fetch_body = self._build_fetch_section()
|
||
self._db_body = self._section("Database")
|
||
self._build_db_actions()
|
||
|
||
def _build_fetch_section(self):
|
||
body = self._section("Fetch Schedule")
|
||
|
||
row1 = ttk.Frame(body)
|
||
row1.pack(fill="x", pady=(0, 8))
|
||
ttk.Label(row1, text="Auto-fetch interval:", width=22, anchor="w").pack(side="left")
|
||
ttk.Label(row1, text="24 hours (fixed)", foreground="#666666").pack(side="left")
|
||
|
||
row2 = ttk.Frame(body)
|
||
row2.pack(fill="x")
|
||
self._fetch_btn = ttk.Button(row2, text="Fetch Now", command=self._do_fetch)
|
||
self._fetch_btn.pack(side="left")
|
||
self._fetch_msg_var = tk.StringVar()
|
||
ttk.Label(row2, textvariable=self._fetch_msg_var,
|
||
foreground="#555555").pack(side="left", padx=(12, 0))
|
||
|
||
return body
|
||
|
||
# ── Data loading ──────────────────────────────────────────────────────────
|
||
|
||
def refresh(self):
|
||
self._fetch_btn.config(state="normal")
|
||
self._fetch_msg_var.set("")
|
||
self._refresh_games()
|
||
self._refresh_sources()
|
||
self._refresh_db()
|
||
|
||
def _refresh_games(self):
|
||
for w in self._games_body.winfo_children():
|
||
w.destroy()
|
||
self._game_vars.clear()
|
||
|
||
games = get_all_games()
|
||
for game in games:
|
||
row = ttk.Frame(self._games_body)
|
||
row.pack(fill="x", pady=3)
|
||
|
||
var = tk.BooleanVar(value=bool(game["active"]))
|
||
self._game_vars[game["id"]] = var
|
||
|
||
ttk.Checkbutton(
|
||
row, text=game["name"], variable=var,
|
||
command=lambda gid=game["id"], v=var: self._toggle_game(gid, v),
|
||
).pack(side="left")
|
||
|
||
count = get_draw_count(game["id"])
|
||
ttk.Label(
|
||
row, text=f"{count:,} draws",
|
||
foreground="#777777",
|
||
).pack(side="left", padx=(16, 0))
|
||
|
||
ttk.Button(
|
||
row, text="Import CSV",
|
||
command=lambda gid=game["id"], gname=game["name"]: self._import_csv(gid, gname),
|
||
).pack(side="left", padx=(12, 0))
|
||
|
||
if game["name"] not in _BUILTIN_GAMES:
|
||
ttk.Button(
|
||
row, text="Delete",
|
||
command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname),
|
||
).pack(side="left", padx=(6, 0))
|
||
|
||
ttk.Button(
|
||
self._games_body, text="+ Add Custom Game",
|
||
command=self._open_add_game_dialog,
|
||
).pack(anchor="w", pady=(8, 0))
|
||
|
||
def _refresh_sources(self):
|
||
for w in self._sources_body.winfo_children():
|
||
w.destroy()
|
||
|
||
last = get_last_fetch_per_source() # {source: Row}
|
||
|
||
for source in _ALL_SOURCES:
|
||
row = ttk.Frame(self._sources_body)
|
||
row.pack(fill="x", pady=3)
|
||
|
||
ttk.Label(
|
||
row, text=_SOURCE_NAMES.get(source, source),
|
||
width=22, anchor="w",
|
||
).pack(side="left")
|
||
|
||
log = last.get(source)
|
||
if log:
|
||
ts = log["fetched_at"][:16].replace("T", " ")
|
||
status = log["status"]
|
||
added = log["added"]
|
||
skipped= log["skipped"]
|
||
color = "#27ae60" if status == "success" else "#e74c3c"
|
||
text = f"Last: {ts} +{added} added, {skipped} skipped"
|
||
ttk.Label(row, text=text, foreground=color).pack(side="left")
|
||
else:
|
||
ttk.Label(row, text="Never fetched", foreground="#aaaaaa").pack(side="left")
|
||
|
||
def _refresh_db(self):
|
||
for w in self._db_body.winfo_children():
|
||
w.destroy()
|
||
|
||
stats = get_db_stats() # {game_name: draw_count}
|
||
for name, count in sorted(stats.items()):
|
||
row = ttk.Frame(self._db_body)
|
||
row.pack(fill="x", pady=2)
|
||
ttk.Label(row, text=f"{name}:", width=18, anchor="w").pack(side="left")
|
||
ttk.Label(row, text=f"{count:,} draws", foreground="#555555").pack(side="left")
|
||
|
||
pred_count = len(get_predictions(limit=100_000))
|
||
row = ttk.Frame(self._db_body)
|
||
row.pack(fill="x", pady=2)
|
||
ttk.Label(row, text="Predictions:", width=18, anchor="w").pack(side="left")
|
||
ttk.Label(row, text=str(pred_count), foreground="#555555").pack(side="left")
|
||
|
||
def _build_db_actions(self):
|
||
body = self._section("Database Actions")
|
||
row = ttk.Frame(body)
|
||
row.pack(fill="x")
|
||
ttk.Button(row, text="Backup DB", command=self._backup_db ).pack(side="left", padx=(0, 8))
|
||
ttk.Button(row, text="Restore DB", command=self._restore_db).pack(side="left")
|
||
|
||
# ── Actions ───────────────────────────────────────────────────────────────
|
||
|
||
def _toggle_game(self, game_id: int, var: tk.BooleanVar):
|
||
active = var.get()
|
||
set_game_active(game_id, active)
|
||
logger.info("[SETTINGS] Game id=%d set active=%s", game_id, active)
|
||
self._refresh_games()
|
||
|
||
def _do_fetch(self):
|
||
if self._on_fetch:
|
||
self._fetch_btn.config(state="disabled")
|
||
self._fetch_msg_var.set("Fetching…")
|
||
self._on_fetch()
|
||
else:
|
||
self._fetch_msg_var.set("Fetch not available.")
|
||
|
||
def _import_csv(self, game_id: int, game_name: str):
|
||
fp = filedialog.askopenfilename(
|
||
title=f"Import draws for {game_name}",
|
||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||
)
|
||
if not fp:
|
||
return
|
||
result = import_draws_csv(game_id, fp)
|
||
added = result["added"]
|
||
skipped = result["skipped"]
|
||
errors = result["errors"]
|
||
msg = f"Added {added:,} draw{'s' if added != 1 else ''}."
|
||
if skipped:
|
||
msg += f"\nSkipped {skipped:,} duplicate{'s' if skipped != 1 else ''}."
|
||
if errors:
|
||
preview = "\n".join(errors[:5])
|
||
suffix = f"\n… and {len(errors) - 5} more." if len(errors) > 5 else ""
|
||
msg += f"\n\n{len(errors)} row error(s):\n{preview}{suffix}"
|
||
messagebox.showinfo("Import Complete", msg)
|
||
self._refresh_games()
|
||
|
||
def _backup_db(self):
|
||
try:
|
||
dest = backup_db()
|
||
messagebox.showinfo("Backup Complete", f"Database backed up to:\n{dest}")
|
||
except Exception as e:
|
||
messagebox.showerror("Backup Failed", str(e))
|
||
|
||
def _restore_db(self):
|
||
fp = filedialog.askopenfilename(
|
||
title="Select backup file to restore",
|
||
filetypes=[("SQLite DB", "*.db"), ("All files", "*.*")],
|
||
)
|
||
if not fp:
|
||
return
|
||
if not messagebox.askyesno(
|
||
"Confirm Restore",
|
||
"Restoring will overwrite the current database.\n"
|
||
"This cannot be undone. Continue?",
|
||
):
|
||
return
|
||
try:
|
||
restore_db(fp)
|
||
messagebox.showinfo("Restore Complete",
|
||
"Database restored. Please restart the app.")
|
||
except Exception as e:
|
||
messagebox.showerror("Restore Failed", str(e))
|
||
|
||
def _open_add_game_dialog(self):
|
||
_AddGameDialog(self, on_save=self._refresh_games)
|
||
|
||
def _delete_game(self, game_id: int, game_name: str):
|
||
count = get_draw_count(game_id)
|
||
if count > 0:
|
||
messagebox.showwarning(
|
||
"Cannot Delete",
|
||
f"'{game_name}' has {count:,} draw records.\n"
|
||
"Delete all draws for this game before removing it.",
|
||
)
|
||
return
|
||
if not messagebox.askyesno("Delete Game",
|
||
f"Permanently delete '{game_name}'?"):
|
||
return
|
||
if delete_game(game_id):
|
||
self._refresh_games()
|
||
else:
|
||
messagebox.showerror("Error", f"Could not delete '{game_name}'.")
|
||
|
||
|
||
class _AddGameDialog(tk.Toplevel):
|
||
"""Modal dialog for adding a custom lottery game."""
|
||
|
||
def __init__(self, parent, on_save):
|
||
super().__init__(parent)
|
||
self.title("Add Custom Game")
|
||
self.resizable(False, False)
|
||
self.grab_set() # modal
|
||
self._on_save = on_save
|
||
self._build()
|
||
self.transient(parent)
|
||
self.wait_visibility()
|
||
self.focus_set()
|
||
|
||
def _build(self):
|
||
pad = {"padx": 10, "pady": 4}
|
||
|
||
# Name
|
||
r = ttk.Frame(self, padding=(14, 14, 14, 4))
|
||
r.pack(fill="x")
|
||
ttk.Label(r, text="Game name:", width=18, anchor="w").pack(side="left")
|
||
self._name_var = tk.StringVar()
|
||
ttk.Entry(r, textvariable=self._name_var, width=22).pack(side="left")
|
||
|
||
# Main balls
|
||
r2 = ttk.Frame(self, padding=(14, 4, 14, 4))
|
||
r2.pack(fill="x")
|
||
ttk.Label(r2, text="Main balls:", width=18, anchor="w").pack(side="left")
|
||
self._main_count = tk.IntVar(value=5)
|
||
ttk.Spinbox(r2, from_=1, to=10, textvariable=self._main_count,
|
||
width=5).pack(side="left")
|
||
ttk.Label(r2, text=" out of 1–", foreground="#555").pack(side="left")
|
||
self._main_max = tk.IntVar(value=69)
|
||
ttk.Spinbox(r2, from_=1, to=99, textvariable=self._main_max,
|
||
width=5).pack(side="left")
|
||
|
||
# Bonus balls
|
||
r3 = ttk.Frame(self, padding=(14, 4, 14, 4))
|
||
r3.pack(fill="x")
|
||
ttk.Label(r3, text="Bonus balls:", width=18, anchor="w").pack(side="left")
|
||
self._bonus_count = tk.IntVar(value=1)
|
||
ttk.Spinbox(r3, from_=0, to=5, textvariable=self._bonus_count,
|
||
width=5).pack(side="left")
|
||
ttk.Label(r3, text=" out of 1–", foreground="#555").pack(side="left")
|
||
self._bonus_max = tk.IntVar(value=26)
|
||
ttk.Spinbox(r3, from_=0, to=99, textvariable=self._bonus_max,
|
||
width=5).pack(side="left")
|
||
|
||
# Error label
|
||
self._err_var = tk.StringVar()
|
||
ttk.Label(self, textvariable=self._err_var,
|
||
foreground="#c0392b",
|
||
padding=(14, 2)).pack(fill="x")
|
||
|
||
# Buttons
|
||
btn_row = ttk.Frame(self, padding=(14, 4, 14, 14))
|
||
btn_row.pack(fill="x")
|
||
ttk.Button(btn_row, text="Add Game", command=self._save).pack(side="right")
|
||
ttk.Button(btn_row, text="Cancel", command=self.destroy).pack(side="right", padx=(0, 6))
|
||
|
||
def _save(self):
|
||
name = self._name_var.get().strip()
|
||
if not name:
|
||
self._err_var.set("Game name is required.")
|
||
return
|
||
try:
|
||
add_game(
|
||
name=name,
|
||
main_count=int(self._main_count.get()),
|
||
main_max=int(self._main_max.get()),
|
||
bonus_count=int(self._bonus_count.get()),
|
||
bonus_max=int(self._bonus_max.get()),
|
||
)
|
||
self._on_save()
|
||
self.destroy()
|
||
except ValueError as e:
|
||
self._err_var.set(str(e))
|