05/23 Phase 6

This commit is contained in:
2026-05-23 11:37:17 -04:00
parent cf574455d7
commit c1405b5fb8
6 changed files with 369 additions and 9 deletions
+192
View File
@@ -0,0 +1,192 @@
"""
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
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,
)
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))
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.")