240 lines
6.4 KiB
Python
240 lines
6.4 KiB
Python
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()
|