Batman
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user