05/23 Phase 1

This commit is contained in:
2026-05-23 10:46:51 -04:00
parent a308cfda2d
commit d782ae24b5
9 changed files with 1543 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
"""
db/database.py
--------------
SQLite initialization, schema creation, and default game seeding.
All table definitions live here. Call init_db() once on app startup.
"""
import sqlite3
import logging
import os
logger = logging.getLogger(__name__)
# DB file lives in lottosight/data/lottosight.db
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_PATH = os.path.join(BASE_DIR, "data", "lottosight.db")
def get_connection():
"""Return a sqlite3 connection with foreign keys enabled."""
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""
Initialize the database — create all tables if they don't exist,
then seed default game configs.
Safe to call on every app launch (uses IF NOT EXISTS).
"""
logger.info("[DB] Initializing database at %s", DB_PATH)
# Ensure data/ directory exists
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = get_connection()
try:
cursor = conn.cursor()
# ── games ──────────────────────────────────────────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
main_count INTEGER NOT NULL,
main_max INTEGER NOT NULL,
bonus_count INTEGER NOT NULL DEFAULT 0,
bonus_max INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1
)
""")
# ── draws ──────────────────────────────────────────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS draws (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL REFERENCES games(id),
draw_date TEXT NOT NULL,
numbers TEXT NOT NULL,
bonus TEXT,
multiplier TEXT,
source TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# Unique constraint: one record per game per draw date
cursor.execute("""
CREATE UNIQUE INDEX IF NOT EXISTS idx_draws_game_date
ON draws (game_id, draw_date)
""")
# Index for fast date-range queries
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_draws_date
ON draws (draw_date)
""")
# ── predictions ────────────────────────────────────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_id INTEGER NOT NULL REFERENCES games(id),
strategy TEXT NOT NULL,
numbers TEXT NOT NULL,
bonus TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
# ── fetch_log ──────────────────────────────────────────────────────
cursor.execute("""
CREATE TABLE IF NOT EXISTS fetch_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
added INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'success',
message TEXT
)
""")
conn.commit()
logger.info("[DB] Tables created/verified OK")
_seed_games(cursor, conn)
except Exception as e:
conn.rollback()
logger.error("[ERROR] DB init failed: %s", e, exc_info=True)
raise
finally:
conn.close()
def _seed_games(cursor, conn):
"""
Insert default game configs if they don't already exist.
Uses INSERT OR IGNORE to be idempotent on every launch.
"""
defaults = [
# name main_count main_max bonus_count bonus_max active
("Powerball", 5, 69, 1, 26, 1),
("Mega Millions", 5, 70, 1, 25, 1),
]
for row in defaults:
cursor.execute("""
INSERT OR IGNORE INTO games
(name, main_count, main_max, bonus_count, bonus_max, active)
VALUES (?, ?, ?, ?, ?, ?)
""", row)
inserted = conn.total_changes
conn.commit()
if inserted > 0:
logger.info("[DB] Seeded %d default game(s)", inserted)
else:
logger.info("[DB] Default games already seeded — skipped")
def get_db_stats():
"""
Return a dict of basic DB stats for display in Settings screen.
{game_name: draw_count, ...}
"""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT g.name, COUNT(d.id) as draw_count
FROM games g
LEFT JOIN draws d ON d.game_id = g.id
GROUP BY g.id
ORDER BY g.name
""")
rows = cursor.fetchall()
return {row["name"]: row["draw_count"] for row in rows}
finally:
conn.close()
+417
View File
@@ -0,0 +1,417 @@
"""
db/models.py
------------
All CRUD operations for LottoSight.
Functions:
Games — get_all_games(), get_game_by_name(), get_game_by_id(), set_game_active()
Draws — insert_draw(), draw_exists(), get_draws(), get_last_draw(), get_draw_count()
Predict — insert_prediction(), get_predictions()
Fetch — insert_fetch_log(), get_last_fetch_log(), get_fetch_logs()
"""
import logging
from db.database import get_connection
logger = logging.getLogger(__name__)
# ══════════════════════════════════════════════════════════════════════════════
# GAMES
# ══════════════════════════════════════════════════════════════════════════════
def get_all_games(active_only=False):
"""
Return list of all games as sqlite3.Row objects.
Pass active_only=True to filter to enabled games only.
"""
conn = get_connection()
try:
cursor = conn.cursor()
if active_only:
cursor.execute("SELECT * FROM games WHERE active = 1 ORDER BY name")
else:
cursor.execute("SELECT * FROM games ORDER BY name")
return cursor.fetchall()
finally:
conn.close()
def get_game_by_name(name):
"""Return single game row by name, or None if not found."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM games WHERE name = ?", (name,))
return cursor.fetchone()
finally:
conn.close()
def get_game_by_id(game_id):
"""Return single game row by id, or None if not found."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM games WHERE id = ?", (game_id,))
return cursor.fetchone()
finally:
conn.close()
def set_game_active(game_id, active: bool):
"""Enable or disable a game. active=True enables, False disables."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"UPDATE games SET active = ? WHERE id = ?",
(1 if active else 0, game_id)
)
conn.commit()
logger.info("[DB] Game id=%d set active=%s", game_id, active)
except Exception as e:
conn.rollback()
logger.error("[ERROR] set_game_active failed: %s", e, exc_info=True)
raise
finally:
conn.close()
def add_custom_game(name, main_count, main_max, bonus_count=0, bonus_max=0):
"""Insert a custom game config. Returns new game id."""
conn = get_connection()
try:
cursor = conn.cursor()
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] Custom game created: '%s' id=%d", name, new_id)
return new_id
except Exception as e:
conn.rollback()
logger.error("[ERROR] add_custom_game failed: %s", e, exc_info=True)
raise
finally:
conn.close()
# ══════════════════════════════════════════════════════════════════════════════
# DRAWS
# ══════════════════════════════════════════════════════════════════════════════
def draw_exists(game_id, draw_date):
"""
Check if a draw already exists for this game + date.
Returns True if duplicate, False if new.
"""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM draws WHERE game_id = ? AND draw_date = ?",
(game_id, draw_date)
)
return cursor.fetchone() is not None
finally:
conn.close()
def insert_draw(game_id, draw_date, numbers, bonus=None, multiplier=None, source=None):
"""
Insert a single draw record.
- numbers: list of ints or comma-separated string
- bonus: int, str, or None
- Returns: 'inserted' or 'skipped' (duplicate)
"""
# Normalize numbers to comma-separated string
if isinstance(numbers, (list, tuple)):
numbers_str = ",".join(str(n) for n in numbers)
else:
numbers_str = str(numbers).strip()
bonus_str = str(bonus) if bonus is not None else None
multiplier_str = str(multiplier) if multiplier is not None else None
# Duplicate check
if draw_exists(game_id, draw_date):
logger.debug("[DB] Skipped duplicate: game_id=%d date=%s", game_id, draw_date)
return "skipped"
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO draws (game_id, draw_date, numbers, bonus, multiplier, source)
VALUES (?, ?, ?, ?, ?, ?)
""", (game_id, draw_date, numbers_str, bonus_str, multiplier_str, source))
conn.commit()
logger.debug("[DB] Inserted draw: game_id=%d date=%s numbers=%s",
game_id, draw_date, numbers_str)
return "inserted"
except Exception as e:
conn.rollback()
logger.error("[ERROR] insert_draw failed: %s", e, exc_info=True)
raise
finally:
conn.close()
def get_draws(game_id, limit=None, date_from=None, date_to=None, order="DESC"):
"""
Fetch draw records for a game.
- date_from / date_to: ISO strings 'YYYY-MM-DD' (optional)
- order: 'DESC' (newest first) or 'ASC' (oldest first)
- limit: max rows to return (None = all)
Returns list of sqlite3.Row objects.
"""
conn = get_connection()
try:
cursor = conn.cursor()
query = "SELECT * FROM draws WHERE game_id = ?"
params = [game_id]
if date_from:
query += " AND draw_date >= ?"
params.append(date_from)
if date_to:
query += " AND draw_date <= ?"
params.append(date_to)
order = "DESC" if order.upper() == "DESC" else "ASC"
query += f" ORDER BY draw_date {order}"
if limit:
query += " LIMIT ?"
params.append(limit)
cursor.execute(query, params)
return cursor.fetchall()
finally:
conn.close()
def get_last_draw(game_id):
"""Return the most recent draw record for a game, or None."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM draws
WHERE game_id = ?
ORDER BY draw_date DESC
LIMIT 1
""", (game_id,))
return cursor.fetchone()
finally:
conn.close()
def get_draw_count(game_id):
"""Return total number of draw records for a game."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) as cnt FROM draws WHERE game_id = ?",
(game_id,)
)
row = cursor.fetchone()
return row["cnt"] if row else 0
finally:
conn.close()
def get_all_draws_numbers(game_id):
"""
Return all draws as list of dicts with parsed number lists.
Used by analyzer and predictor.
Format: [{"draw_date": str, "numbers": [int,...], "bonus": int|None}, ...]
"""
rows = get_draws(game_id, order="ASC")
result = []
for row in rows:
try:
numbers = [int(n.strip()) for n in row["numbers"].split(",")]
bonus = int(row["bonus"]) if row["bonus"] else None
result.append({
"draw_date": row["draw_date"],
"numbers": numbers,
"bonus": bonus,
"multiplier": row["multiplier"],
"source": row["source"],
})
except Exception as e:
logger.warning("[DB] Skipping malformed draw id=%d: %s", row["id"], e)
return result
# ══════════════════════════════════════════════════════════════════════════════
# PREDICTIONS
# ══════════════════════════════════════════════════════════════════════════════
def insert_prediction(game_id, strategy, numbers, bonus=None):
"""
Save a generated prediction to DB.
- numbers: list of ints or comma-separated string
Returns new prediction id.
"""
if isinstance(numbers, (list, tuple)):
numbers_str = ",".join(str(n) for n in numbers)
else:
numbers_str = str(numbers).strip()
bonus_str = str(bonus) if bonus is not None else None
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO predictions (game_id, strategy, numbers, bonus)
VALUES (?, ?, ?, ?)
""", (game_id, strategy, numbers_str, bonus_str))
conn.commit()
new_id = cursor.lastrowid
logger.info("[PREDICT] Saved prediction id=%d game_id=%d strategy='%s' numbers=%s bonus=%s",
new_id, game_id, strategy, numbers_str, bonus_str)
return new_id
except Exception as e:
conn.rollback()
logger.error("[ERROR] insert_prediction failed: %s", e, exc_info=True)
raise
finally:
conn.close()
def get_predictions(game_id=None, limit=50):
"""
Fetch saved predictions.
- game_id: filter by game (None = all games)
- limit: max rows
Returns list of sqlite3.Row objects, newest first.
"""
conn = get_connection()
try:
cursor = conn.cursor()
if game_id:
cursor.execute("""
SELECT p.*, g.name as game_name
FROM predictions p
JOIN games g ON g.id = p.game_id
WHERE p.game_id = ?
ORDER BY p.created_at DESC, p.id DESC
LIMIT ?
""", (game_id, limit))
else:
cursor.execute("""
SELECT p.*, g.name as game_name
FROM predictions p
JOIN games g ON g.id = p.game_id
ORDER BY p.created_at DESC, p.id DESC
LIMIT ?
""", (limit,))
return cursor.fetchall()
finally:
conn.close()
# ══════════════════════════════════════════════════════════════════════════════
# FETCH LOG
# ══════════════════════════════════════════════════════════════════════════════
def insert_fetch_log(source, added, skipped, status="success", message=None):
"""
Log a fetch operation result.
Called after every auto or manual fetch attempt.
Returns new log id.
"""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO fetch_log (source, added, skipped, status, message)
VALUES (?, ?, ?, ?, ?)
""", (source, added, skipped, status, message))
conn.commit()
new_id = cursor.lastrowid
logger.info("[FETCH] Log id=%d source='%s' added=%d skipped=%d status=%s",
new_id, source, added, skipped, status)
return new_id
except Exception as e:
conn.rollback()
logger.error("[ERROR] insert_fetch_log failed: %s", e, exc_info=True)
raise
finally:
conn.close()
def get_last_fetch_log(source=None):
"""
Return the most recent fetch log entry.
- source: filter by source name (None = any source)
Returns sqlite3.Row or None.
"""
conn = get_connection()
try:
cursor = conn.cursor()
if source:
cursor.execute("""
SELECT * FROM fetch_log
WHERE source = ?
ORDER BY fetched_at DESC, id DESC
LIMIT 1
""", (source,))
else:
cursor.execute("""
SELECT * FROM fetch_log
ORDER BY fetched_at DESC, id DESC
LIMIT 1
""")
return cursor.fetchone()
finally:
conn.close()
def get_fetch_logs(limit=50):
"""
Return recent fetch log entries, newest first.
Used in Settings screen to show fetch history.
"""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM fetch_log
ORDER BY fetched_at DESC, id DESC
LIMIT ?
""", (limit,))
return cursor.fetchall()
finally:
conn.close()
def get_last_fetch_per_source():
"""
Return the most recent fetch log entry for each source.
Returns dict: {source_name: Row, ...}
Used by status bar and settings screen.
"""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("""
SELECT f.*
FROM fetch_log f
INNER JOIN (
SELECT source, MAX(fetched_at) as max_at
FROM fetch_log
GROUP BY source
) latest ON f.source = latest.source AND f.fetched_at = latest.max_at
ORDER BY f.source
""")
rows = cursor.fetchall()
return {row["source"]: row for row in rows}
finally:
conn.close()