05/23 Phase 1
This commit is contained in:
+164
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user