Files
PartsInventorySystem/app/db.py
T
thejayman77 ab82b5e9a9 Manage the password in the app; close remaining audit findings
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>
2026-08-24 10:31:06 -04:00

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)