ab82b5e9a9
Password management moves out of the CLI entirely. The credential is now a salted scrypt hash in the database (so it survives rebuilds, living in the /data volume) rather than an environment variable; PARTS_PASSWORD is demoted to a bootstrap value that stops working the moment a password is set in the UI. Every token carries a session epoch, so changing the password — or "sign out other devices" — invalidates outstanding cookies while keeping the browser that made the change signed in. A banner nags until the handed-over password is replaced. app/admin.py remains for the one case the UI cannot cover, a forgotten password. Audit findings: - Taxonomy update and delete scanned affected parts before taking the write lock, so a concurrent rename could leave the search index matching a name the UI no longer showed. All four routes now lock first; removing the lock again makes the new test fail exactly that way. - History of a missing part returned 200 with an empty list; now 404. - Infinity and NaN passed ge=0 and failed at the database. They are rejected as 422 now, and the validation error handler no longer chokes trying to echo a non-finite value back. Checks go from 134 to 181. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""SQLite storage for the parts inventory.
|
|
|
|
One flexible `parts` table carries the fields every kind of stock shares
|
|
(name, quantity, unit, location, cost). Anything type-specific — resistance,
|
|
filament diameter, thread pitch — lives in `part_specs` as key/value rows, so a
|
|
0603 resistor, a spool of PLA and a box of M3 screws coexist without a schema
|
|
fork. Categories carry a `spec_template` naming the keys worth filling in for
|
|
that kind of part, which is what keeps manual entry from being a blank page.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
from contextlib import contextmanager
|
|
|
|
DB_PATH = os.environ.get("PARTS_DB", os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "parts.db"))
|
|
|
|
_init_lock = threading.Lock()
|
|
_initialised = False
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
|
unit TEXT NOT NULL DEFAULT 'pcs',
|
|
spec_template TEXT NOT NULL DEFAULT '[]',
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS categories_unique
|
|
ON categories(name COLLATE NOCASE, COALESCE(parent_id, 0));
|
|
|
|
CREATE TABLE IF NOT EXISTS locations (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
parent_id INTEGER REFERENCES locations(id) ON DELETE SET NULL,
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
sort_order INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS locations_unique
|
|
ON locations(name COLLATE NOCASE, COALESCE(parent_id, 0));
|
|
|
|
CREATE TABLE IF NOT EXISTS parts (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
|
location_id INTEGER REFERENCES locations(id) ON DELETE SET NULL,
|
|
manufacturer TEXT NOT NULL DEFAULT '',
|
|
mpn TEXT NOT NULL DEFAULT '',
|
|
quantity REAL NOT NULL DEFAULT 0,
|
|
unit TEXT NOT NULL DEFAULT 'pcs',
|
|
min_quantity REAL,
|
|
cost_each REAL,
|
|
datasheet_url TEXT NOT NULL DEFAULT '',
|
|
product_url TEXT NOT NULL DEFAULT '',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS parts_category ON parts(category_id);
|
|
CREATE INDEX IF NOT EXISTS parts_location ON parts(location_id);
|
|
CREATE INDEX IF NOT EXISTS parts_name ON parts(name COLLATE NOCASE);
|
|
|
|
CREATE TABLE IF NOT EXISTS part_specs (
|
|
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL DEFAULT '',
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (part_id, key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS part_tags (
|
|
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (part_id, tag_id)
|
|
);
|
|
|
|
-- Every quantity change is recorded, so "where did those 40 headers go" has an
|
|
-- answer instead of just a smaller number than you remembered.
|
|
CREATE TABLE IF NOT EXISTS stock_log (
|
|
id INTEGER PRIMARY KEY,
|
|
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
|
delta REAL NOT NULL,
|
|
quantity_after REAL NOT NULL,
|
|
reason TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
|
|
|
|
-- Small key/value store for things that must outlive a container rebuild and
|
|
-- be changeable without editing a file on the host: the password hash and the
|
|
-- session epoch live here, in the /data volume.
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
|
|
name, description, manufacturer, mpn, specs, tags, category, location,
|
|
tokenize='unicode61 remove_diacritics 2'
|
|
);
|
|
"""
|
|
|
|
|
|
def connect():
|
|
# FastAPI runs a sync `yield` dependency and its endpoint on different
|
|
# threadpool threads, so the connection must not be thread-pinned. Each
|
|
# request still gets its own short-lived connection.
|
|
conn = sqlite3.connect(DB_PATH, timeout=15.0, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
conn.execute("PRAGMA busy_timeout = 15000")
|
|
return conn
|
|
|
|
|
|
def init():
|
|
"""Create the schema and seed starter categories/locations once."""
|
|
global _initialised
|
|
with _init_lock:
|
|
if _initialised:
|
|
return
|
|
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
|
conn = connect()
|
|
try:
|
|
conn.executescript(SCHEMA)
|
|
conn.commit()
|
|
from .seed import seed
|
|
seed(conn)
|
|
finally:
|
|
conn.close()
|
|
_initialised = True
|
|
|
|
|
|
def get_setting(conn, key: str, default: str | None = None) -> str | None:
|
|
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
|
return row["value"] if row else default
|
|
|
|
|
|
def set_setting(conn, key: str, value: str):
|
|
conn.execute(
|
|
"INSERT INTO settings(key, value) VALUES (?, ?) "
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
(key, str(value)),
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def session():
|
|
conn = connect()
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def get_db():
|
|
"""FastAPI dependency yielding a per-request connection."""
|
|
with session() as conn:
|
|
yield conn
|
|
|
|
|
|
# --- tree helpers -----------------------------------------------------------
|
|
|
|
def tree_paths(conn, table: str) -> dict[int, str]:
|
|
"""Map id -> 'Parent / Child' display path for a self-referencing table."""
|
|
rows = conn.execute(f"SELECT id, name, parent_id FROM {table}").fetchall()
|
|
by_id = {r["id"]: (r["name"], r["parent_id"]) for r in rows}
|
|
paths: dict[int, str] = {}
|
|
|
|
def resolve(node_id: int, seen: set[int]) -> str:
|
|
if node_id in paths:
|
|
return paths[node_id]
|
|
name, parent = by_id[node_id]
|
|
# `seen` guards against a cycle introduced by a bad re-parent.
|
|
if parent and parent in by_id and parent not in seen:
|
|
path = resolve(parent, seen | {node_id}) + " / " + name
|
|
else:
|
|
path = name
|
|
paths[node_id] = path
|
|
return path
|
|
|
|
for node_id in by_id:
|
|
resolve(node_id, set())
|
|
return paths
|
|
|
|
|
|
def descendants(conn, table: str, root_id: int) -> list[int]:
|
|
"""A node id plus every id beneath it, so filtering by 'Electronics'
|
|
catches parts filed under 'Electronics / Resistors'."""
|
|
rows = conn.execute(f"SELECT id, parent_id FROM {table}").fetchall()
|
|
children: dict[int, list[int]] = {}
|
|
for r in rows:
|
|
children.setdefault(r["parent_id"], []).append(r["id"])
|
|
out, stack, seen = [], [root_id], set()
|
|
while stack:
|
|
node = stack.pop()
|
|
if node in seen:
|
|
continue
|
|
seen.add(node)
|
|
out.append(node)
|
|
stack.extend(children.get(node, []))
|
|
return out
|
|
|
|
|
|
def begin_immediate(conn):
|
|
"""Take SQLite's write lock up front.
|
|
|
|
Anything that reads a value, computes from it and writes it back must hold
|
|
the write lock across all three steps. Python's sqlite3 only begins its
|
|
implicit transaction at the first *write*, which leaves the preceding read
|
|
outside the transaction — two concurrent stock adjustments would then read
|
|
the same starting quantity and one would overwrite the other.
|
|
"""
|
|
if not conn.in_transaction:
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
|
|
|
|
# --- search index -----------------------------------------------------------
|
|
|
|
def reindex_part(conn, part_id: int, cat_paths=None, loc_paths=None):
|
|
"""Rebuild one part's row in the FTS index.
|
|
|
|
The index is a plain (self-contained) FTS5 table rather than an
|
|
external-content one: it costs a duplicate copy of some short text, and in
|
|
exchange a delete is just `DELETE ... WHERE rowid = ?` instead of the
|
|
contentless table's delete-with-original-values dance.
|
|
|
|
Full category and location *paths* are indexed, not just the leaf names, so
|
|
searching "Workshop" finds what is sitting in "Workshop / Bin A3". That is
|
|
also why renaming a node has to reindex everything beneath it.
|
|
"""
|
|
conn.execute("DELETE FROM parts_fts WHERE rowid = ?", (part_id,))
|
|
row = conn.execute(
|
|
"SELECT name, description, manufacturer, mpn, category_id, location_id "
|
|
"FROM parts WHERE id = ?",
|
|
(part_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return
|
|
if cat_paths is None:
|
|
cat_paths = tree_paths(conn, "categories")
|
|
if loc_paths is None:
|
|
loc_paths = tree_paths(conn, "locations")
|
|
specs = conn.execute(
|
|
"SELECT key, value FROM part_specs WHERE part_id = ?", (part_id,)
|
|
).fetchall()
|
|
spec_text = " ".join(f"{s['key']} {s['value']}" for s in specs)
|
|
tags = conn.execute(
|
|
"SELECT t.name FROM tags t JOIN part_tags pt ON pt.tag_id = t.id WHERE pt.part_id = ?",
|
|
(part_id,),
|
|
).fetchall()
|
|
tag_text = " ".join(t["name"] for t in tags)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO parts_fts(rowid, name, description, manufacturer, mpn, specs, tags, category, location)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
part_id, row["name"], row["description"], row["manufacturer"], row["mpn"],
|
|
spec_text, tag_text,
|
|
cat_paths.get(row["category_id"], "") if row["category_id"] else "",
|
|
loc_paths.get(row["location_id"], "") if row["location_id"] else "",
|
|
),
|
|
)
|
|
|
|
|
|
def parts_under(conn, table: str, node_id: int) -> list[int]:
|
|
"""Ids of every part filed at a node or anywhere beneath it."""
|
|
column = "category_id" if table == "categories" else "location_id"
|
|
ids = descendants(conn, table, node_id)
|
|
marks = ",".join("?" * len(ids))
|
|
rows = conn.execute(
|
|
f"SELECT id FROM parts WHERE {column} IN ({marks})", ids
|
|
).fetchall()
|
|
return [r["id"] for r in rows]
|
|
|
|
|
|
def reindex_parts(conn, part_ids):
|
|
"""Reindex a batch, resolving the path tables only once."""
|
|
part_ids = list(part_ids)
|
|
if not part_ids:
|
|
return
|
|
cat_paths = tree_paths(conn, "categories")
|
|
loc_paths = tree_paths(conn, "locations")
|
|
for part_id in part_ids:
|
|
reindex_part(conn, part_id, cat_paths, loc_paths)
|
|
|
|
|
|
def reindex_all(conn):
|
|
conn.execute("DELETE FROM parts_fts")
|
|
reindex_parts(conn, [r[0] for r in conn.execute("SELECT id FROM parts").fetchall()])
|
|
|
|
|
|
def fts_query(text: str) -> str:
|
|
"""Turn free-typed text into a safe FTS5 prefix query.
|
|
|
|
Each whitespace-separated term is quoted (so `10k`, `0.1uF` and `M3x8` can't
|
|
be read as operators) and given a `*` so search feels live while typing.
|
|
"""
|
|
terms = [t for t in text.replace('"', " ").split() if t]
|
|
return " ".join(f'"{t}"*' for t in terms)
|