""" 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("", lambda e: _resize()) canvas.bind("", lambda e: _resize()) canvas.bind("", lambda e: canvas.bind_all("", lambda ev: canvas.yview_scroll(-1 * (ev.delta // 120), "units"))) canvas.bind("", lambda e: canvas.unbind_all("")) 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))