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 -m PyInstaller lottosight.spec --clean)",
"Bash(python -c \"from ui.dashboard import DashboardScreen\")", "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/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 ### ✅ Phase 12 — Number Search + Next Draw Schedule
- [x] Number search in History screen - [x] Number search in History screen
- [x] Add `number` param to `get_draws_with_game()` — SQL: `',' || numbers || ',' LIKE ?` for exact boundary matching - [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 # 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): def get_all_games(active_only=False):
""" """
Return list of all games as sqlite3.Row objects. 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 ( from core.predictor import (
hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, 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 from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,11 +74,14 @@ class PredictorScreen(ttk.Frame):
gen_frame = ttk.Frame(nb) gen_frame = ttk.Frame(nb)
saved_frame = ttk.Frame(nb) saved_frame = ttk.Frame(nb)
check_frame = ttk.Frame(nb)
nb.add(gen_frame, text="Generate") nb.add(gen_frame, text="Generate")
nb.add(saved_frame, text="Saved") nb.add(saved_frame, text="Saved")
nb.add(check_frame, text="Check Ticket")
self._build_generate_tab(gen_frame) self._build_generate_tab(gen_frame)
self._build_saved_tab(saved_frame) self._build_saved_tab(saved_frame)
self._build_check_tab(check_frame)
# ── Generate tab ────────────────────────────────────────────────────────── # ── Generate tab ──────────────────────────────────────────────────────────
@@ -224,6 +228,7 @@ class PredictorScreen(ttk.Frame):
def refresh(self): def refresh(self):
self._load_games() self._load_games()
self._load_check_games()
self._refresh_saved() self._refresh_saved()
def _load_games(self): def _load_games(self):
@@ -409,3 +414,172 @@ class PredictorScreen(ttk.Frame):
return return
delete_all_predictions(game_id=game_id) delete_all_predictions(game_id=game_id)
self._refresh_saved() 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 import tkinter as tk
from tkinter import ttk from tkinter import ttk, messagebox
import logging import logging
from db.database import get_db_stats from db.database import get_db_stats
from db.models import ( from db.models import (
get_all_games, get_draw_count, set_game_active, get_all_games, get_draw_count, set_game_active,
get_last_fetch_per_source, get_predictions, get_last_fetch_per_source, get_predictions,
add_game, delete_game, _BUILTIN_GAMES,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -131,6 +132,17 @@ class SettingsScreen(ttk.Frame):
foreground="#777777", foreground="#777777",
).pack(side="left", padx=(16, 0)) ).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): def _refresh_sources(self):
for w in self._sources_body.winfo_children(): for w in self._sources_body.winfo_children():
w.destroy() w.destroy()
@@ -190,3 +202,102 @@ class SettingsScreen(ttk.Frame):
self._on_fetch() self._on_fetch()
else: else:
self._fetch_msg_var.set("Fetch not available.") 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))