05/23 Phase 18,19

This commit is contained in:
2026-05-23 16:31:55 -04:00
parent 7506e94d61
commit 2553406254
9 changed files with 674 additions and 5 deletions
+3 -1
View File
@@ -14,7 +14,9 @@
"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 -c ' *)"
"Bash(python -c ' *)",
"Bash(python -m pytest tests/test_backup.py -v)",
"Bash(python -m pytest --tb=short -q)"
]
}
}
+32
View File
@@ -341,6 +341,38 @@ All actions are logged to console and optionally to a log file:
---
### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup
- [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed
- [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing
- [x] Update `ui/dashboard.py`
- [x] Import `gap_analysis` from `core.analyzer`
- [x] Add "Most Overdue Numbers" section below Hot Numbers
- [x] `_refresh_overdue()` — top-5 highest-gap numbers per active game, shown as orange-highlighted `BallsBar` with gap counts
- [x] Called from `refresh()`
- [x] Update `ui/settings.py`
- [x] Import `backup_db, restore_db` from `db.database`
- [x] Add "Database Actions" section with Backup DB + Restore DB buttons
- [x] `_backup_db()` — runs backup, shows path in success dialog
- [x] `_restore_db()` — file dialog, confirm prompt, restores, advises restart
- [x] Write `tests/test_backup.py` — 10 tests (361/361 total passing)
---
### ✅ Phase 18 — Draw CSV Import
- [x] Write `core/importer.py`
- [x] `_parse_date(text)` — accepts ISO (YYYY-MM-DD), US slash (MM/DD/YYYY), US dash, day-first formats
- [x] `_is_header(row)` — heuristic: first cell is not a valid date
- [x] `_parse_row_lottosight` — handles LottoSight's own export format (Game, Date, Numbers, Bonus, …)
- [x] `_parse_row_generic` — handles wide format (Date, N1, N2, …) and packed format (Date, "N1,N2,…")
- [x] `import_draws_csv(game_id, filepath) -> {added, skipped, errors}` — validates count + range, calls `insert_draw`, deduplicates via existing DB logic
- [x] Update `ui/settings.py`
- [x] Add `filedialog` import
- [x] "Import CSV" button on every game row (builtin and custom)
- [x] `_import_csv(game_id, game_name)` — file dialog → import → show summary with error preview → refresh games
- [x] Write `tests/test_importer.py` — 23 tests (351/351 total passing)
---
### ✅ Phase 17 — Predictor Power Features
- [x] Add `quick_pick(game_id, exclude=None)` to `core/predictor.py` — pure random, no draw history required
- [x] Add `exclude: set | None = None` parameter to all 5 existing strategies + `_random_ticket` + `_fill_to_count`
+183
View File
@@ -0,0 +1,183 @@
"""
core/importer.py
----------------
CSV draw importer for LottoSight.
Supported formats (auto-detected):
1. LottoSight export — Game, Date, "N1,N2,...", Bonus, Multiplier, Source
2. Wide format — Date, N1, N2, ..., Nn [, Bonus]
3. Packed format — Date, "N1,N2,...", [Bonus]
Header rows are auto-detected and skipped.
Dates accepted: YYYY-MM-DD, MM/DD/YYYY, M/D/YYYY, MM-DD-YYYY.
Duplicate draws (same game + date already in DB) are skipped, not errored.
"""
import csv
from datetime import datetime
from db.models import get_game_by_id, insert_draw
# ── Date parsing ──────────────────────────────────────────────────────────────
_DATE_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y", "%d/%m/%Y", "%-m/%-d/%Y")
def _parse_date(text: str) -> str | None:
"""Return ISO date string (YYYY-MM-DD) or None."""
text = text.strip()
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(text, fmt).strftime("%Y-%m-%d")
except ValueError:
pass
return None
def _is_header(row: list[str]) -> bool:
"""Heuristic: first cell is not a recognisable date → row is a header."""
return bool(row) and _parse_date(row[0]) is None
# ── Row parsing ───────────────────────────────────────────────────────────────
def _parse_row_lottosight(cells: list[str]) -> dict | None:
"""Parse LottoSight export row: Game, Date, Numbers, Bonus, ..."""
if len(cells) < 3:
return None
date_str = _parse_date(cells[1])
if date_str is None:
return None
try:
numbers = [int(n.strip()) for n in cells[2].split(",") if n.strip()]
bonus = int(cells[3]) if len(cells) > 3 and cells[3].strip().isdigit() else None
except (ValueError, IndexError):
return None
return {"date": date_str, "numbers": numbers, "bonus": bonus}
def _parse_row_generic(cells: list[str], game: dict) -> dict | None:
"""Parse wide or packed format: Date, [nums...]"""
date_str = _parse_date(cells[0])
if date_str is None:
return None
rest = cells[1:]
if not rest:
return None
# Packed: second cell contains comma-separated numbers
if "," in rest[0]:
try:
numbers = [int(n.strip()) for n in rest[0].split(",") if n.strip()]
bonus = int(rest[1]) if len(rest) > 1 and rest[1].strip().isdigit() else None
return {"date": date_str, "numbers": numbers, "bonus": bonus}
except ValueError:
pass
# Wide: each number in its own column
nums: list[int] = []
for col in rest:
col = col.strip()
if col.isdigit():
nums.append(int(col))
else:
break # stop at first non-numeric cell (e.g. multiplier string)
if len(nums) < game["main_count"]:
return None
numbers = nums[: game["main_count"]]
bonus = (nums[game["main_count"]]
if game["bonus_count"] > 0 and len(nums) > game["main_count"]
else None)
return {"date": date_str, "numbers": numbers, "bonus": bonus}
# ── Public API ────────────────────────────────────────────────────────────────
def import_draws_csv(game_id: int, filepath: str) -> dict:
"""
Import draw records from *filepath* into the database for *game_id*.
Returns:
{"added": int, "skipped": int, "errors": list[str]}
"""
game = get_game_by_id(game_id)
if game is None:
return {"added": 0, "skipped": 0, "errors": [f"Unknown game_id {game_id}"]}
added = skipped = 0
errors: list[str] = []
# Read file
try:
with open(filepath, newline="", encoding="utf-8-sig") as f:
sample = f.read(4096)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
except csv.Error:
dialect = csv.excel
rows = list(csv.reader(f, dialect))
except FileNotFoundError:
return {"added": 0, "skipped": 0, "errors": [f"File not found: {filepath}"]}
except Exception as e:
return {"added": 0, "skipped": 0, "errors": [f"Could not read file: {e}"]}
if not rows:
return {"added": 0, "skipped": 0, "errors": ["File is empty"]}
# Detect LottoSight export format by header
lottosight_fmt = (
len(rows[0]) >= 3
and rows[0][0].strip().lower() == "game"
and rows[0][1].strip().lower() == "date"
)
# Determine start row (skip header if present)
start = 1 if (lottosight_fmt or _is_header(rows[0])) else 0
for line_num, row in enumerate(rows[start:], start=start + 1):
cells = [c.strip() for c in row]
if not any(cells):
continue # blank line
if lottosight_fmt:
parsed = _parse_row_lottosight(cells)
else:
parsed = _parse_row_generic(cells, game)
if parsed is None:
errors.append(f"Line {line_num}: could not parse — {row}")
continue
numbers = parsed["numbers"]
# Validate count
if len(numbers) != game["main_count"]:
errors.append(
f"Line {line_num}: expected {game['main_count']} numbers, "
f"got {len(numbers)}"
)
continue
# Validate range
bad = [n for n in numbers if not (1 <= n <= game["main_max"])]
if bad:
errors.append(
f"Line {line_num}: numbers out of range 1{game['main_max']}: {bad}"
)
continue
result = insert_draw(
game_id, parsed["date"], numbers,
bonus=parsed["bonus"], source="csv_import",
)
if result == "inserted":
added += 1
else:
skipped += 1
return {"added": added, "skipped": skipped, "errors": errors}
Binary file not shown.
+23
View File
@@ -8,6 +8,8 @@ All table definitions live here. Call init_db() once on app startup.
import sqlite3
import logging
import os
import shutil
from datetime import datetime
from core.paths import user_data_dir
@@ -143,6 +145,27 @@ def _seed_games(cursor, conn):
logger.info("[DB] Default games already seeded — skipped")
def backup_db(dest_dir: str | None = None) -> str:
"""Copy the live DB to dest_dir and return the backup file path."""
if dest_dir is None:
dest_dir = os.path.join(user_data_dir(), "exports")
os.makedirs(dest_dir, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dest = os.path.join(dest_dir, f"lottosight_backup_{ts}.db")
shutil.copy2(DB_PATH, dest)
logger.info("[DB] Backup created: %s", dest)
return dest
def restore_db(source_path: str) -> None:
"""Overwrite the live DB with a backup file."""
if not os.path.isfile(source_path):
raise FileNotFoundError(f"Backup file not found: {source_path}")
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
shutil.copy2(source_path, DB_PATH)
logger.info("[DB] Database restored from: %s", source_path)
def get_db_stats():
"""
Return a dict of basic DB stats for display in Settings screen.
+104
View File
@@ -0,0 +1,104 @@
"""
tests/test_backup.py
---------------------
Tests for db.database backup_db() and restore_db().
"""
import os
import pytest
from db.database import backup_db, restore_db, DB_PATH, get_connection
from db.models import insert_draw, get_draws_with_game, get_game_by_name
# ── backup_db ─────────────────────────────────────────────────────────────────
def test_backup_creates_file(tmp_db, tmp_path):
dest = backup_db(dest_dir=str(tmp_path))
assert os.path.isfile(dest)
def test_backup_filename_contains_timestamp(tmp_db, tmp_path):
dest = backup_db(dest_dir=str(tmp_path))
basename = os.path.basename(dest)
assert basename.startswith("lottosight_backup_")
assert basename.endswith(".db")
def test_backup_returns_path_string(tmp_db, tmp_path):
result = backup_db(dest_dir=str(tmp_path))
assert isinstance(result, str)
assert len(result) > 0
def test_backup_file_is_valid_sqlite(tmp_db, tmp_path):
dest = backup_db(dest_dir=str(tmp_path))
import sqlite3
conn = sqlite3.connect(dest)
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = {r[0] for r in cursor.fetchall()}
conn.close()
assert "games" in tables
assert "draws" in tables
def test_backup_preserves_draw_data(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
insert_draw(game["id"], "2024-06-01", [5, 14, 22, 36, 69], bonus=7)
dest = backup_db(dest_dir=str(tmp_path))
import sqlite3
conn = sqlite3.connect(dest)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT numbers FROM draws WHERE draw_date='2024-06-01'")
row = cursor.fetchone()
conn.close()
assert row is not None
assert "5" in row["numbers"]
def test_backup_creates_dest_dir_if_missing(tmp_db, tmp_path):
nested = str(tmp_path / "a" / "b" / "c")
dest = backup_db(dest_dir=nested)
assert os.path.isfile(dest)
def test_backup_default_dest_dir(tmp_db, monkeypatch, tmp_path):
from core.paths import user_data_dir as _udd
monkeypatch.setattr("db.database.user_data_dir", lambda: str(tmp_path))
dest = backup_db() # no dest_dir — should use exports/ sub-dir
assert os.path.isfile(dest)
assert "exports" in dest
# ── restore_db ────────────────────────────────────────────────────────────────
def test_restore_missing_file_raises(tmp_db):
with pytest.raises(FileNotFoundError):
restore_db("/nonexistent/backup.db")
def test_restore_replaces_live_db(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
insert_draw(game["id"], "2024-07-04", [3, 17, 28, 45, 62], bonus=12)
backup_path = backup_db(dest_dir=str(tmp_path))
# Delete the draw from the live DB
conn = get_connection()
conn.execute("DELETE FROM draws WHERE draw_date='2024-07-04'")
conn.commit()
conn.close()
draws_before = get_draws_with_game(game_id=game["id"])
assert not any(d["draw_date"] == "2024-07-04" for d in draws_before)
restore_db(backup_path)
draws_after = get_draws_with_game(game_id=game["id"])
assert any(d["draw_date"] == "2024-07-04" for d in draws_after)
def test_restore_returns_none(tmp_db, tmp_path):
backup_path = backup_db(dest_dir=str(tmp_path))
result = restore_db(backup_path)
assert result is None
+228
View File
@@ -0,0 +1,228 @@
"""
tests/test_importer.py
----------------------
Tests for core/importer.py — CSV draw import.
Powerball config: 5 main from 169, 1 bonus from 126.
"""
import os
import pytest
from db.models import get_game_by_name, add_game, get_draws_with_game
from core.importer import import_draws_csv, _parse_date
# ── _parse_date ───────────────────────────────────────────────────────────────
def test_parse_date_iso():
assert _parse_date("2024-01-15") == "2024-01-15"
def test_parse_date_us_slash():
assert _parse_date("01/15/2024") == "2024-01-15"
def test_parse_date_us_dash():
assert _parse_date("01-15-2024") == "2024-01-15"
def test_parse_date_invalid():
assert _parse_date("not-a-date") is None
def test_parse_date_header_text():
assert _parse_date("Date") is None
assert _parse_date("draw_date") is None
# ── Wide format (Date, N1, N2, ...) ──────────────────────────────────────────
def test_wide_no_header(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"2024-01-03,2,7,22,45,61,15\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["skipped"] == 0
assert result["errors"] == []
def test_wide_with_header(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"Date,Ball1,Ball2,Ball3,Ball4,Ball5,Bonus\n"
"2024-01-01,1,13,36,61,69,7\n"
"2024-01-03,2,7,22,45,61,15\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
def test_wide_no_bonus_column(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61,69\n")
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
draws = get_draws_with_game(game_id=game["id"])
assert draws[0]["bonus"] is None
def test_wide_us_date_format(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("01/15/2024,5,14,22,36,69,7\n")
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
draws = get_draws_with_game(game_id=game["id"])
assert draws[0]["draw_date"] == "2024-01-15"
# ── Packed format (Date, "N1,N2,...", Bonus) ──────────────────────────────────
def test_packed_format(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text('2024-01-01,"1,13,36,61,69",7\n')
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
assert result["errors"] == []
def test_packed_with_header(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
'Date,Numbers,Bonus\n'
'2024-01-01,"1,13,36,61,69",7\n'
'2024-01-03,"2,7,22,45,61",15\n'
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
# ── LottoSight export format ──────────────────────────────────────────────────
def test_lottosight_export_format(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "export.csv"
csv_file.write_text(
"Game,Date,Numbers,Bonus,Multiplier,Source\n"
'Powerball,2024-01-01,"1,13,36,61,69",7,2x,powerball_ny\n'
'Powerball,2024-01-03,"2,7,22,45,61",15,3x,powerball_ny\n'
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
# ── Duplicate detection ───────────────────────────────────────────────────────
def test_duplicate_skipped(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"2024-01-01,1,13,36,61,69,7\n" # same date → duplicate
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 1
assert result["skipped"] == 1
def test_reimport_all_skipped(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61,69,7\n")
import_draws_csv(game["id"], str(csv_file))
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert result["skipped"] == 1
# ── Validation errors ─────────────────────────────────────────────────────────
def test_wrong_number_count(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61\n") # only 4 numbers
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert len(result["errors"]) == 1 # rejected — too few numbers to parse
def test_number_out_of_range(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,13,36,61,99,7\n") # 99 > 69
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert len(result["errors"]) == 1
def test_mixed_valid_invalid(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"bad_date,1,2,3,4,5,6\n" # unparseable
"2024-01-03,2,7,22,45,61,15\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert len(result["errors"]) == 1
# ── Edge cases ────────────────────────────────────────────────────────────────
def test_file_not_found(tmp_db):
game = get_game_by_name("Powerball")
result = import_draws_csv(game["id"], "/nonexistent/path/file.csv")
assert result["added"] == 0
assert len(result["errors"]) == 1
assert "not found" in result["errors"][0].lower()
def test_empty_file(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "empty.csv"
csv_file.write_text("")
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 0
assert result["errors"] == ["File is empty"]
def test_blank_lines_ignored(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,1,13,36,61,69,7\n"
"\n"
"2024-01-03,2,7,22,45,61,15\n"
"\n"
)
result = import_draws_csv(game["id"], str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
def test_unknown_game_id(tmp_db, tmp_path):
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-01-01,1,2,3,4,5\n")
result = import_draws_csv(99999, str(csv_file))
assert result["added"] == 0
assert len(result["errors"]) == 1
def test_custom_game_import(tmp_db, tmp_path):
gid = add_game("My Lotto", 6, 49, bonus_count=0, bonus_max=0)
csv_file = tmp_path / "draws.csv"
csv_file.write_text(
"2024-01-01,5,14,22,33,41,48\n"
"2024-01-08,3,17,28,35,44,49\n"
)
result = import_draws_csv(gid, str(csv_file))
assert result["added"] == 2
assert result["errors"] == []
def test_data_persisted_correctly(tmp_db, tmp_path):
game = get_game_by_name("Powerball")
csv_file = tmp_path / "draws.csv"
csv_file.write_text("2024-03-15,5,14,22,36,69,7\n")
import_draws_csv(game["id"], str(csv_file))
draws = get_draws_with_game(game_id=game["id"])
assert len(draws) == 1
assert draws[0]["draw_date"] == "2024-03-15"
nums = [int(n) for n in draws[0]["numbers"].split(",")]
assert sorted(nums) == [5, 14, 22, 36, 69]
assert str(draws[0]["bonus"]) == "7"
+36 -1
View File
@@ -15,7 +15,7 @@ from datetime import date, timedelta
from db.database import get_db_stats
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
from core.analyzer import frequency_analysis
from core.analyzer import frequency_analysis, gap_analysis
from ui.widgets import BallsBar
# Weekday indices: Monday=0 … Sunday=6
@@ -97,6 +97,7 @@ class DashboardScreen(ttk.Frame):
self._last_draw_body = self._section_header("Last Draw Results")
self._db_body = self._section_header("Database Summary")
self._hot_body = self._section_header("Hot Numbers (last 100 draws)")
self._overdue_body = self._section_header("Most Overdue Numbers")
# ── Refresh ───────────────────────────────────────────────────────────────
@@ -104,6 +105,7 @@ class DashboardScreen(ttk.Frame):
self._refresh_last_draws()
self._refresh_db_summary()
self._refresh_hot_numbers()
self._refresh_overdue()
def _refresh_last_draws(self):
for w in self._last_draw_body.winfo_children():
@@ -194,3 +196,36 @@ class DashboardScreen(ttk.Frame):
counts_str = " ".join(f"({freq[n]}×)" for n in top5)
ttk.Label(row, text=counts_str, foreground="#aaaaaa",
font=_CARD_FONT).pack(side="left")
def _refresh_overdue(self):
for w in self._overdue_body.winfo_children():
w.destroy()
games = get_all_games(active_only=True)
if not games:
ttk.Label(self._overdue_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w")
return
_ORANGE = ("#e67e22", "#ffffff")
for game in games:
gaps = gap_analysis(game["id"]) # {number: gap}
row = ttk.Frame(self._overdue_body)
row.pack(fill="x", pady=3)
ttk.Label(row, text=f"{game['name']}:", width=18, anchor="w",
font=_CARD_FONT).pack(side="left")
if not gaps:
ttk.Label(row, text="No data yet.", foreground="#aaaaaa",
font=_CARD_FONT).pack(side="left")
continue
top5 = sorted(sorted(gaps, key=gaps.get, reverse=True)[:5])
highlights = {n: _ORANGE for n in top5}
BallsBar(row, numbers=top5, radius=11,
highlights=highlights).pack(side="left", padx=(0, 6))
gaps_str = " ".join(f"({gaps[n]} ago)" for n in top5)
ttk.Label(row, text=gaps_str, foreground="#aaaaaa",
font=_CARD_FONT).pack(side="left")
+65 -3
View File
@@ -7,15 +7,16 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread.
"""
import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import ttk, messagebox, filedialog
import logging
from db.database import get_db_stats
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__)
@@ -80,6 +81,7 @@ class SettingsScreen(ttk.Frame):
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")
@@ -132,11 +134,16 @@ class SettingsScreen(ttk.Frame):
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=(12, 0))
).pack(side="left", padx=(6, 0))
ttk.Button(
self._games_body, text="+ Add Custom Game",
@@ -187,6 +194,13 @@ class SettingsScreen(ttk.Frame):
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):
@@ -203,6 +217,54 @@ class SettingsScreen(ttk.Frame):
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)