from __future__ import annotations import sqlite3 from contextlib import contextmanager from dataclasses import dataclass from datetime import date from pathlib import Path from .config import get_settings SCHEMA = """ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TEXT NOT NULL, expires_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS login_attempts ( ip TEXT NOT NULL, ts TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS bikes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, service_interval_miles INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, bike_id INTEGER NOT NULL REFERENCES bikes(id) ON DELETE CASCADE, type TEXT NOT NULL, event_date TEXT NOT NULL, mileage INTEGER, cost INTEGER, notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_events_bike_date ON events(bike_id, event_date DESC); """ @dataclass class Bike: id: int name: str service_interval_miles: int | None @dataclass class BikeEvent: id: int bike_id: int type: str event_date: date mileage: int | None cost: int | None notes: str | None def connect(db_path: Path) -> sqlite3.Connection: conn = sqlite3.connect(db_path, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA foreign_keys=ON;") conn.commit() return conn def init_schema(db_path: Path) -> None: db_path.parent.mkdir(parents=True, exist_ok=True) conn = connect(db_path) try: conn.executescript(SCHEMA) conn.commit() finally: conn.close() def ensure_user(db_path: Path, username: str, password: str, password_hash: str) -> None: conn = connect(db_path) try: cur = conn.execute("SELECT id FROM users WHERE username = ? LIMIT 1", (username,)) if cur.fetchone() is None: conn.execute( "INSERT INTO users (username, password_hash) VALUES (?, ?)", (username, password_hash), ) conn.commit() finally: conn.close() # --- bikes --- def list_bikes(conn: sqlite3.Connection) -> list[Bike]: rows = conn.execute( "SELECT id, name, service_interval_miles FROM bikes ORDER BY name" ).fetchall() return [Bike(r["id"], r["name"], r["service_interval_miles"]) for r in rows] def create_bike(conn: sqlite3.Connection, name: str, service_interval_miles: int | None) -> int: cur = conn.execute( "INSERT INTO bikes (name, service_interval_miles) VALUES (?, ?)", (name, service_interval_miles), ) conn.commit() return int(cur.lastrowid) def update_bike(conn: sqlite3.Connection, bike_id: int, name: str, interval: int | None) -> None: conn.execute( "UPDATE bikes SET name = ?, service_interval_miles = ? WHERE id = ?", (name, interval, bike_id), ) conn.commit() def delete_bike(conn: sqlite3.Connection, bike_id: int) -> None: conn.execute("DELETE FROM bikes WHERE id = ?", (bike_id,)) conn.commit() def get_bike(conn: sqlite3.Connection, bike_id: int) -> Bike | None: r = conn.execute( "SELECT id, name, service_interval_miles FROM bikes WHERE id = ?", (bike_id,) ).fetchone() return Bike(r["id"], r["name"], r["service_interval_miles"]) if r else None # --- events --- def create_event( conn: sqlite3.Connection, bike_id: int, type: str, event_date: date, mileage: int | None, cost: int | None, notes: str | None, ) -> int: cur = conn.execute( "INSERT INTO events (bike_id, type, event_date, mileage, cost, notes) " "VALUES (?, ?, ?, ?, ?, ?)", (bike_id, type, event_date.isoformat(), mileage, cost, notes), ) conn.commit() return int(cur.lastrowid) def update_event( conn: sqlite3.Connection, event_id: int, type: str, event_date: date, mileage: int | None, cost: int | None, notes: str | None, ) -> None: conn.execute( "UPDATE events SET type = ?, event_date = ?, mileage = ?, cost = ?, notes = ? WHERE id = ?", (type, event_date.isoformat(), mileage, cost, notes, event_id), ) conn.commit() def delete_event(conn: sqlite3.Connection, event_id: int) -> None: conn.execute("DELETE FROM events WHERE id = ?", (event_id,)) conn.commit() def _row_to_event(r: sqlite3.Row) -> BikeEvent: return BikeEvent( id=r["id"], bike_id=r["bike_id"], type=r["type"], event_date=date.fromisoformat(r["event_date"]), mileage=r["mileage"], cost=r["cost"], notes=r["notes"], ) def list_events(conn: sqlite3.Connection, bike_id: int) -> list[BikeEvent]: rows = conn.execute( "SELECT * FROM events WHERE bike_id = ? ORDER BY event_date DESC, id DESC", (bike_id,) ).fetchall() return [_row_to_event(r) for r in rows] def latest_event_with_mileage(conn: sqlite3.Connection, bike_id: int) -> BikeEvent | None: r = conn.execute( "SELECT * FROM events WHERE bike_id = ? AND mileage IS NOT NULL " "ORDER BY event_date DESC, id DESC LIMIT 1", (bike_id,), ).fetchone() return _row_to_event(r) if r else None def latest_event_of_type(conn: sqlite3.Connection, bike_id: int, type: str) -> BikeEvent | None: r = conn.execute( "SELECT * FROM events WHERE bike_id = ? AND type = ? " "ORDER BY event_date DESC, id DESC LIMIT 1", (bike_id, type), ).fetchone() return _row_to_event(r) if r else None def sum_cost(conn: sqlite3.Connection, bike_id: int) -> int: r = conn.execute( "SELECT COALESCE(SUM(cost), 0) AS s FROM events WHERE bike_id = ?", (bike_id,) ).fetchone() return int(r["s"]) def count_events(conn: sqlite3.Connection, bike_id: int) -> int: r = conn.execute("SELECT COUNT(*) AS n FROM events WHERE bike_id = ?", (bike_id,)).fetchone() return int(r["n"]) @contextmanager def get_conn(): db_path = get_settings().resolved_db_path conn = connect(db_path) try: yield conn finally: conn.close()