Files

372 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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))