Files
lottosight/core/importer.py
T
2026-05-23 16:31:55 -04:00

184 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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}