05/23 Phase 14

This commit is contained in:
2026-05-23 12:58:57 -04:00
parent ae0c64f731
commit 8eea1e976f
9 changed files with 851 additions and 2 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"Bash(python -m PyInstaller lottosight.spec --clean)",
"Bash(python -c \"from ui.dashboard import DashboardScreen\")",
"Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)",
"Bash(python -m pytest tests/ -q)"
"Bash(python -m pytest tests/ -q)",
"Bash(python -c ' *)"
]
}
}
+35
View File
@@ -325,6 +325,41 @@ All actions are logged to console and optionally to a log file:
---
### ✅ Phase 13 — Ticket Checker
- [x] Write `core/checker.py`
- [x] `prize_tier(main_matches, bonus_match)` — maps match counts to Jackpot/Match 5/Match 4+Bonus/… tier labels
- [x] `check_ticket(game_id, numbers, bonus)` — compares ticket against all draws, returns matches sorted by quality desc
- [x] `parse_numbers(raw)` — parses space- or comma-separated user input, raises ValueError on bad input
- [x] Add "Check Ticket" tab to `ui/predictor_ui.py` (3rd tab in Notebook)
- [x] Game dropdown, Numbers entry, Bonus entry, Check / Clear buttons
- [x] Hint label with accepted input format
- [x] Results treeview: Draw Date, Draw Numbers, Bonus, Main Hits, Bonus Hit, Prize Tier
- [x] Row colours: purple=Jackpot, green=≥3 matches or bonus hit, grey=low match
- [x] Summary bar: "N draws matched • Best: <tier>"
- [x] Input validation: number count, range check, bonus range
- [x] 27 tests in `tests/test_checker.py` (254/254 total passing)
---
### ✅ Phase 14 — Custom Game Management
- [x] Add `_BUILTIN_GAMES = {"Powerball", "Mega Millions"}` constant to `db/models.py`
- [x] Add `add_game(name, main_count, main_max, bonus_count, bonus_max) -> int`
- [x] Rejects blank names, duplicate names, and builtin game names (ValueError)
- [x] Inserts with `active=1`, returns new row id
- [x] Add `delete_game(game_id) -> bool`
- [x] Refuses builtin games → returns False
- [x] Refuses games with existing draw records → returns False
- [x] Cascades: deletes predictions for game first, then removes game → returns True
- [x] Update `ui/settings.py`
- [x] Delete button on each non-builtin game row; warns if draws exist, asks confirm
- [x] "+ Add Custom Game" button at bottom of Games section
- [x] `_AddGameDialog` modal (tk.Toplevel): name + main_count/max + bonus_count/max spinboxes
- [x] Error label in dialog for inline validation feedback
- [x] Refresh games list after add or delete
- [x] Write `tests/test_custom_games.py` — 20 tests (274/274 total passing)
---
### ✅ Phase 12 — Number Search + Next Draw Schedule
- [x] Number search in History screen
- [x] Add `number` param to `get_draws_with_game()` — SQL: `',' || numbers || ',' LIKE ?` for exact boundary matching
+108
View File
@@ -0,0 +1,108 @@
"""
core/checker.py
---------------
Ticket checker — compare a user-supplied ticket against all historical draws.
Returns every draw where at least one number (main or bonus) matched,
sorted by match quality descending.
Prize tiers follow Powerball / Mega Millions conventions:
Jackpot 5 main + bonus
Match 5 5 main, no bonus
Match 4 + Bonus 4 main + bonus
Match 4 4 main
Match 3 + Bonus 3 main + bonus
Match 3 3 main
Match 2 + Bonus 2 main + bonus
Match 1 + Bonus 1 main + bonus
Bonus Only 0 main + bonus
"""
from db.models import get_all_draws_numbers, get_game_by_id
# (required_main_matches, requires_bonus_match, tier_label)
_TIERS = [
(5, True, "Jackpot"),
(5, False, "Match 5"),
(4, True, "Match 4 + Bonus"),
(4, False, "Match 4"),
(3, True, "Match 3 + Bonus"),
(3, False, "Match 3"),
(2, True, "Match 2 + Bonus"),
(1, True, "Match 1 + Bonus"),
(0, True, "Bonus Only"),
]
def prize_tier(main_matches: int, bonus_match: bool) -> str:
"""Return the prize tier label for a given match result."""
for req_main, req_bonus, label in _TIERS:
if main_matches == req_main:
if req_bonus and not bonus_match:
continue
return label
return "No Prize"
def check_ticket(game_id: int, numbers: list[int],
bonus: int | None = None) -> list[dict]:
"""
Compare *numbers* (and optional *bonus*) against all draws for *game_id*.
Returns a list of dicts for draws with ≥ 1 matched number (main or bonus),
sorted by (main_matches DESC, bonus_match DESC, draw_date DESC).
Each dict contains:
draw_date str
draw_numbers list[int]
draw_bonus int | None
main_matches int
bonus_match bool
prize_tier str
"""
draws = get_all_draws_numbers(game_id)
ticket_set = set(numbers)
results = []
for draw in draws:
draw_set = set(draw["numbers"])
main_matches = len(ticket_set & draw_set)
bonus_match = (
bonus is not None
and draw["bonus"] is not None
and bonus == draw["bonus"]
)
if main_matches == 0 and not bonus_match:
continue
results.append({
"draw_date": draw["draw_date"],
"draw_numbers": draw["numbers"],
"draw_bonus": draw["bonus"],
"main_matches": main_matches,
"bonus_match": bonus_match,
"prize_tier": prize_tier(main_matches, bonus_match),
})
results.sort(
key=lambda x: (x["main_matches"], x["bonus_match"], x["draw_date"]),
reverse=True,
)
return results
def parse_numbers(raw: str) -> list[int]:
"""
Parse a user-typed string of lottery numbers into a sorted list of ints.
Accepts space- or comma-separated input: '7 14 32 56 68' or '7,14,32,56,68'.
Raises ValueError if any token is not a positive integer.
"""
tokens = raw.replace(",", " ").split()
if not tokens:
raise ValueError("No numbers entered.")
nums = []
for t in tokens:
if not t.isdigit():
raise ValueError(f"'{t}' is not a valid number.")
nums.append(int(t))
return sorted(nums)
Binary file not shown.
+67
View File
@@ -19,6 +19,73 @@ logger = logging.getLogger(__name__)
# GAMES
# ══════════════════════════════════════════════════════════════════════════════
_BUILTIN_GAMES = {"Powerball", "Mega Millions"}
def add_game(name: str, main_count: int, main_max: int,
bonus_count: int = 0, bonus_max: int = 0) -> int:
"""
Insert a new game row. Returns the new game id.
Raises ValueError if name is blank or already exists.
"""
name = name.strip()
if not name:
raise ValueError("Game name cannot be blank.")
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM games WHERE name = ?", (name,)
)
if cursor.fetchone():
raise ValueError(f"A game named '{name}' already exists.")
cursor.execute(
"""INSERT INTO games (name, main_count, main_max, bonus_count, bonus_max, active)
VALUES (?, ?, ?, ?, ?, 1)""",
(name, main_count, main_max, bonus_count, bonus_max),
)
conn.commit()
new_id = cursor.lastrowid
logger.info("[DB] Added custom game id=%d name='%s'", new_id, name)
return new_id
except Exception:
conn.rollback()
raise
finally:
conn.close()
def delete_game(game_id: int) -> bool:
"""
Delete a game by id. Returns True on success, False if the game has draws
(to protect data integrity) or is a built-in game.
"""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT name FROM games WHERE id = ?", (game_id,))
row = cursor.fetchone()
if row is None:
return False
if row["name"] in _BUILTIN_GAMES:
logger.warning("[DB] Refused to delete built-in game '%s'", row["name"])
return False
cursor.execute("SELECT COUNT(*) as cnt FROM draws WHERE game_id = ?", (game_id,))
if cursor.fetchone()["cnt"] > 0:
logger.warning("[DB] Refused to delete game id=%d — has draws", game_id)
return False
cursor.execute("DELETE FROM predictions WHERE game_id = ?", (game_id,))
cursor.execute("DELETE FROM games WHERE id = ?", (game_id,))
conn.commit()
logger.info("[DB] Deleted custom game id=%d", game_id)
return True
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_all_games(active_only=False):
"""
Return list of all games as sqlite3.Row objects.
+161
View File
@@ -0,0 +1,161 @@
"""
tests/test_checker.py
----------------------
Tests for core/checker.py — ticket checking and prize tier logic.
Draw data (Powerball):
Draw 1 2024-01-01: [1, 13, 36, 61, 69] bonus=7
Draw 2 2024-01-03: [1, 2, 13, 45, 69] bonus=15
Draw 3 2024-01-05: [2, 13, 22, 36, 55] bonus=3
"""
import pytest
from db.models import get_game_by_name, insert_draw
from core.checker import check_ticket, prize_tier, parse_numbers
DRAWS = [
("2024-01-01", [1, 13, 36, 61, 69], 7),
("2024-01-03", [1, 2, 13, 45, 69], 15),
("2024-01-05", [2, 13, 22, 36, 55], 3),
]
@pytest.fixture
def pb(tmp_db):
game = get_game_by_name("Powerball")
for date, nums, bonus in DRAWS:
insert_draw(game["id"], date, nums, bonus=bonus)
return game
# ── prize_tier ────────────────────────────────────────────────────────────────
def test_jackpot(tmp_db):
assert prize_tier(5, True) == "Jackpot"
def test_match_5(tmp_db):
assert prize_tier(5, False) == "Match 5"
def test_match_4_bonus(tmp_db):
assert prize_tier(4, True) == "Match 4 + Bonus"
def test_match_4(tmp_db):
assert prize_tier(4, False) == "Match 4"
def test_match_3_bonus(tmp_db):
assert prize_tier(3, True) == "Match 3 + Bonus"
def test_match_3(tmp_db):
assert prize_tier(3, False) == "Match 3"
def test_match_2_bonus(tmp_db):
assert prize_tier(2, True) == "Match 2 + Bonus"
def test_match_1_bonus(tmp_db):
assert prize_tier(1, True) == "Match 1 + Bonus"
def test_bonus_only(tmp_db):
assert prize_tier(0, True) == "Bonus Only"
def test_no_prize(tmp_db):
assert prize_tier(0, False) == "No Prize"
def test_no_prize_2_no_bonus(tmp_db):
assert prize_tier(2, False) == "No Prize"
def test_no_prize_1_no_bonus(tmp_db):
assert prize_tier(1, False) == "No Prize"
# ── check_ticket — exact match ────────────────────────────────────────────────
def test_jackpot_ticket_found(pb):
results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=7)
assert len(results) >= 1
top = results[0]
assert top["main_matches"] == 5
assert top["bonus_match"] is True
assert top["prize_tier"] == "Jackpot"
assert top["draw_date"] == "2024-01-01"
def test_match_5_no_bonus(pb):
results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=99)
top = results[0]
assert top["main_matches"] == 5
assert top["bonus_match"] is False
assert top["prize_tier"] == "Match 5"
def test_partial_match_3(pb):
# Numbers 1, 13, 69 appear in draw 1 and draw 2
results = check_ticket(pb["id"], [1, 13, 69, 4, 8])
dates_matched = {r["draw_date"] for r in results}
assert "2024-01-01" in dates_matched
assert "2024-01-03" in dates_matched
def test_bonus_only_match(pb):
# No main number matches but bonus=7 matches draw 1
results = check_ticket(pb["id"], [3, 4, 5, 6, 7], bonus=7)
bonus_only = [r for r in results if r["draw_date"] == "2024-01-01"]
assert len(bonus_only) == 1
assert bonus_only[0]["main_matches"] == 0
assert bonus_only[0]["bonus_match"] is True
assert bonus_only[0]["prize_tier"] == "Bonus Only"
def test_no_match_returns_empty(pb):
# Numbers that don't appear in any draw
results = check_ticket(pb["id"], [3, 4, 5, 6, 7])
assert results == []
def test_no_bonus_in_ticket(pb):
results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=None)
top = results[0]
assert top["bonus_match"] is False
assert top["prize_tier"] == "Match 5"
def test_results_sorted_by_matches_desc(pb):
# Mix of matches: use numbers that appear in multiple draws with varying counts
results = check_ticket(pb["id"], [1, 13, 36, 61, 69])
if len(results) > 1:
for i in range(len(results) - 1):
assert results[i]["main_matches"] >= results[i + 1]["main_matches"]
def test_result_contains_required_keys(pb):
results = check_ticket(pb["id"], [1, 13, 36, 61, 69])
for key in ("draw_date", "draw_numbers", "draw_bonus",
"main_matches", "bonus_match", "prize_tier"):
assert key in results[0]
def test_empty_db_returns_empty(tmp_db):
pb = get_game_by_name("Powerball")
assert check_ticket(pb["id"], [1, 2, 3, 4, 5]) == []
# ── parse_numbers ─────────────────────────────────────────────────────────────
def test_parse_space_separated(tmp_db):
assert parse_numbers("7 14 32 56 68") == [7, 14, 32, 56, 68]
def test_parse_comma_separated(tmp_db):
assert parse_numbers("7,14,32,56,68") == [7, 14, 32, 56, 68]
def test_parse_mixed_delimiters(tmp_db):
assert parse_numbers("7, 14, 32") == [7, 14, 32]
def test_parse_returns_sorted(tmp_db):
assert parse_numbers("68 7 32") == [7, 32, 68]
def test_parse_empty_raises(tmp_db):
with pytest.raises(ValueError, match="No numbers"):
parse_numbers(" ")
def test_parse_non_digit_raises(tmp_db):
with pytest.raises(ValueError):
parse_numbers("7 14 abc")
+192
View File
@@ -0,0 +1,192 @@
"""
tests/test_custom_games.py
---------------------------
Tests for add_game() and delete_game() in db/models.py,
covering custom game management.
"""
import pytest
from db.models import (
add_game, delete_game, get_all_games, get_game_by_name,
get_game_by_id, insert_draw, _BUILTIN_GAMES,
)
# ── add_game ──────────────────────────────────────────────────────────────────
def test_add_game_creates_row(tmp_db):
gid = add_game("EuroMillions", 5, 50, bonus_count=2, bonus_max=12)
game = get_game_by_id(gid)
assert game is not None
assert game["name"] == "EuroMillions"
def test_add_game_returns_id(tmp_db):
gid = add_game("UK Lotto", 6, 59)
assert isinstance(gid, int)
assert gid > 0
def test_add_game_default_active(tmp_db):
gid = add_game("Test Game", 5, 45)
game = get_game_by_id(gid)
assert game["active"] == 1
def test_add_game_appears_in_get_all_games(tmp_db):
add_game("EuroMillions", 5, 50, bonus_count=2, bonus_max=12)
names = [g["name"] for g in get_all_games()]
assert "EuroMillions" in names
def test_add_game_parameters_stored_correctly(tmp_db):
gid = add_game("Custom", 6, 45, bonus_count=1, bonus_max=20)
game = get_game_by_id(gid)
assert game["main_count"] == 6
assert game["main_max"] == 45
assert game["bonus_count"] == 1
assert game["bonus_max"] == 20
def test_add_game_duplicate_name_raises(tmp_db):
add_game("Test Game", 5, 45)
with pytest.raises(ValueError, match="already exists"):
add_game("Test Game", 6, 50)
def test_add_game_blank_name_raises(tmp_db):
with pytest.raises(ValueError, match="blank"):
add_game(" ", 5, 45)
def test_add_game_no_bonus(tmp_db):
gid = add_game("No Bonus Game", 6, 49, bonus_count=0, bonus_max=0)
game = get_game_by_id(gid)
assert game["bonus_count"] == 0
assert game["bonus_max"] == 0
def test_add_game_builtin_name_raises(tmp_db):
with pytest.raises(ValueError):
add_game("Powerball", 5, 69, 1, 26)
# ── delete_game ───────────────────────────────────────────────────────────────
def test_delete_custom_game_no_draws(tmp_db):
gid = add_game("Temp Game", 5, 45)
result = delete_game(gid)
assert result is True
assert get_game_by_id(gid) is None
def test_delete_custom_game_removed_from_list(tmp_db):
gid = add_game("Gone Game", 5, 45)
delete_game(gid)
names = [g["name"] for g in get_all_games()]
assert "Gone Game" not in names
def test_delete_game_with_draws_returns_false(tmp_db):
gid = add_game("Active Game", 5, 45)
insert_draw(gid, "2024-01-01", [1, 2, 3, 4, 5])
result = delete_game(gid)
assert result is False
assert get_game_by_id(gid) is not None
def test_delete_builtin_powerball_returns_false(tmp_db):
pb = get_game_by_name("Powerball")
result = delete_game(pb["id"])
assert result is False
assert get_game_by_name("Powerball") is not None
def test_delete_builtin_mega_millions_returns_false(tmp_db):
mm = get_game_by_name("Mega Millions")
result = delete_game(mm["id"])
assert result is False
def test_delete_nonexistent_game_returns_false(tmp_db):
result = delete_game(99999)
assert result is False
def test_delete_game_clears_predictions(tmp_db):
from db.models import insert_prediction, get_predictions
gid = add_game("Prediction Game", 5, 45)
insert_prediction(gid, "Hot Numbers", [1, 2, 3, 4, 5])
delete_game(gid)
preds = get_predictions(game_id=gid, limit=100)
assert preds == []
# ── integration: custom game works with existing features ─────────────────────
def test_custom_game_works_with_frequency_analysis(tmp_db):
from core.analyzer import frequency_analysis
gid = add_game("My Lottery", 6, 49, bonus_count=1, bonus_max=10)
insert_draw(gid, "2024-01-01", [1, 7, 14, 22, 35, 49])
insert_draw(gid, "2024-01-03", [1, 7, 15, 23, 36, 48])
freq = frequency_analysis(gid)
assert freq[1] == 2
assert freq[7] == 2
def test_custom_game_works_with_predictor(tmp_db):
from core.predictor import hot_numbers
gid = add_game("My Lottery", 6, 49, bonus_count=1, bonus_max=10)
insert_draw(gid, "2024-01-01", [1, 7, 14, 22, 35, 49], bonus=3)
insert_draw(gid, "2024-01-03", [1, 7, 15, 23, 36, 48], bonus=5)
result = hot_numbers(gid)
assert len(result["numbers"]) == 6
assert all(1 <= n <= 49 for n in result["numbers"])
# ── UI smoke tests ────────────────────────────────────────────────────────────
def _has_display():
try:
import tkinter as tk
r = tk.Tk(); r.withdraw(); r.destroy()
return True
except Exception:
return False
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_settings_shows_add_button(tmp_db):
import tkinter as tk
from ui.settings import SettingsScreen
try:
root = tk.Tk()
root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
screen = SettingsScreen(root)
screen.refresh()
assert screen.winfo_exists()
finally:
root.destroy()
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_settings_custom_game_shows_delete_button(tmp_db):
import tkinter as tk
from ui.settings import SettingsScreen
add_game("My Custom Lottery", 5, 50)
try:
root = tk.Tk()
root.withdraw()
except Exception:
pytest.skip("Tkinter init failed")
try:
screen = SettingsScreen(root)
screen.refresh()
assert screen.winfo_exists()
finally:
root.destroy()
+174
View File
@@ -21,6 +21,7 @@ from db.models import (
from core.predictor import (
hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick,
)
from core.checker import check_ticket, parse_numbers
from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir
logger = logging.getLogger(__name__)
@@ -73,11 +74,14 @@ class PredictorScreen(ttk.Frame):
gen_frame = ttk.Frame(nb)
saved_frame = ttk.Frame(nb)
check_frame = ttk.Frame(nb)
nb.add(gen_frame, text="Generate")
nb.add(saved_frame, text="Saved")
nb.add(check_frame, text="Check Ticket")
self._build_generate_tab(gen_frame)
self._build_saved_tab(saved_frame)
self._build_check_tab(check_frame)
# ── Generate tab ──────────────────────────────────────────────────────────
@@ -224,6 +228,7 @@ class PredictorScreen(ttk.Frame):
def refresh(self):
self._load_games()
self._load_check_games()
self._refresh_saved()
def _load_games(self):
@@ -409,3 +414,172 @@ class PredictorScreen(ttk.Frame):
return
delete_all_predictions(game_id=game_id)
self._refresh_saved()
# ── Check Ticket tab ──────────────────────────────────────────────────────
def _build_check_tab(self, parent):
# Input bar
bar = ttk.Frame(parent, padding=(6, 8, 6, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left")
self._chk_game_var = tk.StringVar()
self._chk_game_cb = ttk.Combobox(
bar, textvariable=self._chk_game_var, state="readonly", width=15
)
self._chk_game_cb.pack(side="left", padx=(4, 14))
ttk.Label(bar, text="Numbers:").pack(side="left")
self._chk_nums_var = tk.StringVar()
chk_nums_entry = ttk.Entry(bar, textvariable=self._chk_nums_var, width=22)
chk_nums_entry.pack(side="left", padx=(4, 4))
chk_nums_entry.bind("<Return>", lambda e: self._run_check())
ttk.Label(bar, text="Bonus:").pack(side="left")
self._chk_bonus_var = tk.StringVar()
chk_bonus_entry = ttk.Entry(bar, textvariable=self._chk_bonus_var, width=5)
chk_bonus_entry.pack(side="left", padx=(4, 14))
chk_bonus_entry.bind("<Return>", lambda e: self._run_check())
ttk.Button(bar, text="Check", command=self._run_check).pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Clear", command=self._clear_check).pack(side="left")
self._chk_status_var = tk.StringVar()
ttk.Label(bar, textvariable=self._chk_status_var,
foreground="#c0392b").pack(side="left", padx=(12, 0))
# Hint text below bar
hint = ttk.Frame(parent, padding=(6, 0, 6, 4))
hint.pack(fill="x")
ttk.Label(hint,
text="Enter space- or comma-separated main numbers, and optionally a bonus number.",
foreground="#888888", font=("TkDefaultFont", 8)).pack(anchor="w")
# Results treeview
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(0, 4))
c_cols = ("date", "draw_numbers", "bonus", "main", "bonus_hit", "tier")
self._chk_tree = ttk.Treeview(
tree_frame, columns=c_cols, show="headings", selectmode="browse"
)
self._chk_tree.heading("date", text="Draw Date")
self._chk_tree.heading("draw_numbers", text="Draw Numbers")
self._chk_tree.heading("bonus", text="Bonus")
self._chk_tree.heading("main", text="Main Hits")
self._chk_tree.heading("bonus_hit", text="Bonus Hit")
self._chk_tree.heading("tier", text="Prize Tier")
self._chk_tree.column("date", width=100, anchor="center", stretch=False)
self._chk_tree.column("draw_numbers", width=190, anchor="w")
self._chk_tree.column("bonus", width=55, anchor="center", stretch=False)
self._chk_tree.column("main", width=70, anchor="center", stretch=False)
self._chk_tree.column("bonus_hit", width=70, anchor="center", stretch=False)
self._chk_tree.column("tier", width=160, anchor="w", stretch=False)
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._chk_tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._chk_tree.xview)
self._chk_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._chk_tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
tree_frame.rowconfigure(0, weight=1)
tree_frame.columnconfigure(0, weight=1)
# Row colour tags
self._chk_tree.tag_configure("jackpot", foreground="#8e44ad", font=("TkDefaultFont", 9, "bold"))
self._chk_tree.tag_configure("high", foreground="#1e8449")
self._chk_tree.tag_configure("low", foreground="#555555")
# Summary label
self._chk_summary_var = tk.StringVar()
ttk.Label(parent, textvariable=self._chk_summary_var,
anchor="e", padding=(6, 2)).pack(fill="x")
def _run_check(self):
self._chk_status_var.set("")
self._chk_tree.delete(*self._chk_tree.get_children())
self._chk_summary_var.set("")
# Resolve game
game = get_game_by_name(self._chk_game_var.get())
if game is None:
self._chk_status_var.set("Select a game first.")
return
# Parse main numbers
try:
numbers = parse_numbers(self._chk_nums_var.get())
except ValueError as e:
self._chk_status_var.set(str(e))
return
if len(numbers) != game["main_count"]:
self._chk_status_var.set(
f"{game['name']} requires {game['main_count']} main numbers "
f"(got {len(numbers)})."
)
return
# Validate range
invalid = [n for n in numbers if not (1 <= n <= game["main_max"])]
if invalid:
self._chk_status_var.set(
f"Numbers out of range 1{game['main_max']}: {invalid}"
)
return
# Parse optional bonus
bonus = None
raw_bonus = self._chk_bonus_var.get().strip()
if raw_bonus:
if not raw_bonus.isdigit():
self._chk_status_var.set("Bonus must be a number.")
return
bonus = int(raw_bonus)
if game["bonus_max"] and not (1 <= bonus <= game["bonus_max"]):
self._chk_status_var.set(
f"Bonus out of range 1{game['bonus_max']}."
)
return
results = check_ticket(game["id"], numbers, bonus=bonus)
for r in results:
nums_fmt = " ".join(f"{n:02d}" for n in r["draw_numbers"])
bonus_str = str(r["draw_bonus"]) if r["draw_bonus"] is not None else ""
bonus_hit = "" if r["bonus_match"] else ""
tier = r["prize_tier"]
if tier == "Jackpot":
tag = "jackpot"
elif r["main_matches"] >= 3 or r["bonus_match"]:
tag = "high"
else:
tag = "low"
self._chk_tree.insert("", "end", values=(
r["draw_date"], nums_fmt, bonus_str,
r["main_matches"], bonus_hit, tier,
), tags=(tag,))
total = len(results)
best = results[0]["prize_tier"] if results else ""
self._chk_summary_var.set(
f"{total} draw{'s' if total != 1 else ''} matched • Best: {best}"
)
def _clear_check(self):
self._chk_nums_var.set("")
self._chk_bonus_var.set("")
self._chk_status_var.set("")
self._chk_tree.delete(*self._chk_tree.get_children())
self._chk_summary_var.set("")
def _load_check_games(self):
games = get_all_games(active_only=True)
names = [g["name"] for g in games]
self._chk_game_cb["values"] = names
if not self._chk_game_var.get() or self._chk_game_var.get() not in names:
if names:
self._chk_game_var.set(names[0])
+112 -1
View File
@@ -7,13 +7,14 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread.
"""
import tkinter as tk
from tkinter import ttk
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__)
@@ -131,6 +132,17 @@ class SettingsScreen(ttk.Frame):
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()
@@ -190,3 +202,102 @@ class SettingsScreen(ttk.Frame):
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))