Files
lottosight/ui/settings.py
T
2026-05-23 12:58:57 -04:00

304 lines
11 KiB
Python
Raw 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
import logging
from db.database import get_db_stats
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,
)
logger = logging.getLogger(__name__)
_SOURCE_NAMES = {
"powerball_ny": "Powerball (NY)",
"megamillions_ny": "Mega Millions (NY)",
"megamillions_tx": "Mega Millions (TX)",
}
_ALL_SOURCES = ["powerball_ny", "megamillions_ny", "megamillions_tx"]
_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")
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))
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=(12, 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")
# ── 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 _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))