Parts inventory: catalog, search, and stock tracking
FastAPI + SQLite behind a single-page frontend, deployed as a container on mainserver behind Caddy. One flexible parts table plus key/value specs so components, filament, fasteners and tooling share a schema. Categories and locations are nestable trees whose filters and sidebar counts both roll up through descendants. Category spec templates pre-fill the properties worth recording for each kind of part, which is what makes manual entry tolerable. Every quantity change is logged. Search is FTS5 with prefix matching across names, MPNs, spec values, tags, category and location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# Single-user gate. Set a real password before exposing this publicly.
|
||||
PARTS_PASSWORD=change-me
|
||||
# Signing key for the session cookie. Generate: openssl rand -hex 32
|
||||
PARTS_SECRET=change-me-too
|
||||
# Session lifetime in days.
|
||||
PARTS_SESSION_DAYS=30
|
||||
# Set to "off" to disable the login gate entirely (LAN-only use).
|
||||
PARTS_AUTH=on
|
||||
# SQLite path. The container image already defaults this to /data/parts.db.
|
||||
PARTS_DB=/data/parts.db
|
||||
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
data/
|
||||
.venv/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
COPY static ./static
|
||||
ENV PARTS_DB=/data/parts.db
|
||||
EXPOSE 8100
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
||||
@@ -0,0 +1,105 @@
|
||||
# Parts Inventory
|
||||
|
||||
A catalog of what's actually on the shelf — components, filament, fasteners,
|
||||
tooling — so "do I have one of those?" is a five-second search instead of an
|
||||
hour of opening drawers.
|
||||
|
||||
Runs as a FastAPI + SQLite container on `mainserver` (`192.168.50.8`) behind
|
||||
Caddy at **https://parts.tjm77.com**, alongside the other `~/srv` services.
|
||||
|
||||
## The data model
|
||||
|
||||
One `parts` table holds what every kind of stock has in common — name,
|
||||
quantity, unit, location, cost, min-stock threshold. Everything type-specific
|
||||
lives in `part_specs` as key/value rows, so a 0603 resistor, a spool of PLA and
|
||||
a box of M3 screws share a table without a schema fork.
|
||||
|
||||
Categories and locations are both **nestable trees**. `Workshop / Bin A3` is a
|
||||
real path, and filtering by `Workshop` returns everything in every bin beneath
|
||||
it. Sidebar counts roll up the same way, so they always match what clicking the
|
||||
filter actually returns.
|
||||
|
||||
Each category carries a **spec template** — the properties worth recording for
|
||||
that kind of thing. Pick "Filament" on the add form and it pre-fills Material,
|
||||
Colour, Diameter, Brand, Print Temp, and switches the unit to grams. That's what
|
||||
keeps manual entry from being a blank page. Templates are suggestions only:
|
||||
delete any row, add your own, ignore them entirely.
|
||||
|
||||
Every quantity change is written to `stock_log`, so a part's history answers
|
||||
"where did those 40 headers go" instead of just showing a smaller number than
|
||||
you remembered.
|
||||
|
||||
Search is SQLite FTS5 over name, description, manufacturer, MPN, spec values,
|
||||
tags, category and location, with prefix matching so results narrow as you type.
|
||||
Typing `1.75mm`, `prusament`, `0603` or `Bin A3` all find the right things.
|
||||
|
||||
## Running it locally
|
||||
|
||||
```sh
|
||||
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
PARTS_AUTH=off PARTS_DB=$PWD/data/parts.db .venv/bin/uvicorn app.main:app --port 8123
|
||||
```
|
||||
|
||||
Then open http://127.0.0.1:8123.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m tests.test_api
|
||||
```
|
||||
|
||||
Exercises the API end to end against a throwaway database — the auth gate,
|
||||
nested categories and locations, search across every indexed field, filter
|
||||
rollups, stock adjustment and history, patch semantics, and cascade behaviour
|
||||
when a category or location is deleted.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `PARTS_PASSWORD` | The single shared password. Required when auth is on. |
|
||||
| `PARTS_SECRET` | Signing key for the session cookie. `openssl rand -hex 32`. |
|
||||
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
|
||||
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
|
||||
| `PARTS_DB` | SQLite path. `/data/parts.db` in the container. |
|
||||
|
||||
## Deploying
|
||||
|
||||
Lives at `/home/jay/srv/parts/` on `.8` and follows the same conventions as the
|
||||
other services there — `build: .`, external `caddy_web` network, named volume
|
||||
for `/data`.
|
||||
|
||||
```sh
|
||||
ssh jay@192.168.50.8
|
||||
cd ~/srv/parts && sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
The database is in the `parts_parts_data` docker volume, which survives
|
||||
rebuilds. To back it up:
|
||||
|
||||
```sh
|
||||
sudo docker run --rm -v parts_parts_data:/d -v "$PWD":/out alpine \
|
||||
sh -c 'cp /d/parts.db /out/parts-backup.db'
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
Everything under `/api` is JSON and cookie-authenticated. `GET /healthz` is open.
|
||||
|
||||
```
|
||||
GET /api/parts?q=&category_id=&location_id=&tag=&low_stock=&sort=&limit=&offset=
|
||||
POST /api/parts
|
||||
GET /api/parts/{id}
|
||||
PATCH /api/parts/{id}
|
||||
DELETE /api/parts/{id}
|
||||
POST /api/parts/{id}/adjust {delta, reason}
|
||||
GET /api/parts/{id}/history
|
||||
GET /api/categories POST /api/categories PATCH|DELETE /api/categories/{id}
|
||||
GET /api/locations POST /api/locations PATCH|DELETE /api/locations/{id}
|
||||
GET /api/tags
|
||||
GET /api/stats
|
||||
```
|
||||
|
||||
The "what could I build with what I'm holding?" idea is meant to arrive as
|
||||
another consumer of these endpoints — the arbiter on `.8` already has the lane
|
||||
routing and typed-action machinery for it — rather than as a fork of them.
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
"""Single-user login gate.
|
||||
|
||||
The app sits on a public hostname, so it needs a door. One shared password from
|
||||
the environment buys a signed, expiring cookie — no user table, no password
|
||||
reset flow, nothing to administer. Set PARTS_AUTH=off for LAN-only use.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
|
||||
from fastapi import Cookie, HTTPException
|
||||
|
||||
COOKIE_NAME = "parts_session"
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return os.environ.get(name, default).strip()
|
||||
|
||||
|
||||
def auth_enabled() -> bool:
|
||||
return _env("PARTS_AUTH", "on").lower() not in ("off", "0", "false", "no")
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
secret = _env("PARTS_SECRET")
|
||||
if not secret:
|
||||
# Without a configured secret, derive one from the password so sessions
|
||||
# are still unforgeable — they just don't survive a password change.
|
||||
secret = "fallback:" + _env("PARTS_PASSWORD")
|
||||
return hashlib.sha256(secret.encode()).digest()
|
||||
|
||||
|
||||
def session_days() -> int:
|
||||
try:
|
||||
return max(1, int(_env("PARTS_SESSION_DAYS", "30")))
|
||||
except ValueError:
|
||||
return 30
|
||||
|
||||
|
||||
def check_password(candidate: str) -> bool:
|
||||
expected = _env("PARTS_PASSWORD")
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(candidate.encode(), expected.encode())
|
||||
|
||||
|
||||
def issue_token() -> str:
|
||||
expires = int(time.time()) + session_days() * 86400
|
||||
payload = str(expires).encode()
|
||||
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(payload + b"." + sig).decode()
|
||||
|
||||
|
||||
def token_valid(token: str) -> bool:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(token.encode())
|
||||
payload, sig = raw.rsplit(b".", 1)
|
||||
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return False
|
||||
return int(payload) > time.time()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def require_auth(parts_session: str | None = Cookie(default=None)):
|
||||
"""FastAPI dependency guarding every data route."""
|
||||
if not auth_enabled():
|
||||
return True
|
||||
if parts_session and token_valid(parts_session):
|
||||
return True
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
@@ -0,0 +1,212 @@
|
||||
"""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);
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# --- search index -----------------------------------------------------------
|
||||
|
||||
def reindex_part(conn, part_id: int):
|
||||
"""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.
|
||||
"""
|
||||
conn.execute("DELETE FROM parts_fts WHERE rowid = ?", (part_id,))
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT p.name, p.description, p.manufacturer, p.mpn,
|
||||
COALESCE(c.name, '') AS category,
|
||||
COALESCE(l.name, '') AS location
|
||||
FROM parts p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
LEFT JOIN locations l ON l.id = p.location_id
|
||||
WHERE p.id = ?
|
||||
""",
|
||||
(part_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
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, row["category"], row["location"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def reindex_all(conn):
|
||||
conn.execute("DELETE FROM parts_fts")
|
||||
for (pid,) in conn.execute("SELECT id FROM parts").fetchall():
|
||||
reindex_part(conn, pid)
|
||||
|
||||
|
||||
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)
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
"""Parts inventory API.
|
||||
|
||||
JSON API plus a single self-contained frontend. Everything a bench lookup needs
|
||||
lives behind /api; the browser app in static/ is the only consumer today, and
|
||||
the future "what could I build with this?" feature is meant to be another
|
||||
consumer of the same endpoints rather than a fork of them.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, db
|
||||
|
||||
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static")
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
db.init()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Parts Inventory", docs_url=None, redoc_url=None, lifespan=lifespan)
|
||||
|
||||
|
||||
# --- models -----------------------------------------------------------------
|
||||
|
||||
class SpecIn(BaseModel):
|
||||
key: str = Field(min_length=1, max_length=80)
|
||||
value: str = Field(default="", max_length=400)
|
||||
|
||||
|
||||
class PartIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
category_id: int | None = None
|
||||
location_id: int | None = None
|
||||
manufacturer: str = Field(default="", max_length=200)
|
||||
mpn: str = Field(default="", max_length=120)
|
||||
quantity: float = 0
|
||||
unit: str = Field(default="pcs", max_length=20)
|
||||
min_quantity: float | None = None
|
||||
cost_each: float | None = None
|
||||
datasheet_url: str = Field(default="", max_length=1000)
|
||||
product_url: str = Field(default="", max_length=1000)
|
||||
notes: str = Field(default="", max_length=4000)
|
||||
specs: list[SpecIn] = []
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class PartPatch(PartIn):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
quantity: float | None = None
|
||||
unit: str | None = None
|
||||
specs: list[SpecIn] | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class AdjustIn(BaseModel):
|
||||
delta: float
|
||||
reason: str = Field(default="", max_length=300)
|
||||
|
||||
|
||||
class NodeIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
parent_id: int | None = None
|
||||
unit: str = Field(default="pcs", max_length=20)
|
||||
spec_template: list[SpecIn] = []
|
||||
notes: str = Field(default="", max_length=1000)
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
# --- 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 = [], [root_id]
|
||||
seen = 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 _specs_for(conn, part_ids: list[int]) -> dict[int, list[dict]]:
|
||||
if not part_ids:
|
||||
return {}
|
||||
marks = ",".join("?" * len(part_ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT part_id, key, value FROM part_specs WHERE part_id IN ({marks}) ORDER BY position, key",
|
||||
part_ids,
|
||||
).fetchall()
|
||||
out: dict[int, list[dict]] = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["part_id"], []).append({"key": r["key"], "value": r["value"]})
|
||||
return out
|
||||
|
||||
|
||||
def _tags_for(conn, part_ids: list[int]) -> dict[int, list[str]]:
|
||||
if not part_ids:
|
||||
return {}
|
||||
marks = ",".join("?" * len(part_ids))
|
||||
rows = conn.execute(
|
||||
f"""SELECT pt.part_id, t.name FROM part_tags pt
|
||||
JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.part_id IN ({marks}) ORDER BY t.name""",
|
||||
part_ids,
|
||||
).fetchall()
|
||||
out: dict[int, list[str]] = {}
|
||||
for r in rows:
|
||||
out.setdefault(r["part_id"], []).append(r["name"])
|
||||
return out
|
||||
|
||||
|
||||
def _serialise(rows, specs, tags, cat_paths, loc_paths) -> list[dict]:
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["specs"] = specs.get(r["id"], [])
|
||||
d["tags"] = tags.get(r["id"], [])
|
||||
d["category_path"] = cat_paths.get(r["category_id"]) if r["category_id"] else None
|
||||
d["location_path"] = loc_paths.get(r["location_id"]) if r["location_id"] else None
|
||||
d["low_stock"] = (
|
||||
r["min_quantity"] is not None and r["quantity"] <= r["min_quantity"]
|
||||
)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _write_specs(conn, part_id: int, specs: list[SpecIn]):
|
||||
conn.execute("DELETE FROM part_specs WHERE part_id = ?", (part_id,))
|
||||
seen = set()
|
||||
position = 0
|
||||
for s in specs:
|
||||
key = s.key.strip()
|
||||
if not key or key.lower() in seen:
|
||||
continue
|
||||
seen.add(key.lower())
|
||||
conn.execute(
|
||||
"INSERT INTO part_specs(part_id, key, value, position) VALUES (?,?,?,?)",
|
||||
(part_id, key, s.value.strip(), position),
|
||||
)
|
||||
position += 1
|
||||
|
||||
|
||||
def _write_tags(conn, part_id: int, tags: list[str]):
|
||||
conn.execute("DELETE FROM part_tags WHERE part_id = ?", (part_id,))
|
||||
for raw in tags:
|
||||
name = raw.strip()
|
||||
if not name:
|
||||
continue
|
||||
conn.execute("INSERT OR IGNORE INTO tags(name) VALUES (?)", (name,))
|
||||
row = conn.execute("SELECT id FROM tags WHERE name = ? COLLATE NOCASE", (name,)).fetchone()
|
||||
if row:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO part_tags(part_id, tag_id) VALUES (?,?)",
|
||||
(part_id, row["id"]),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_part(conn, part_id: int) -> dict:
|
||||
row = conn.execute("SELECT * FROM parts WHERE id = ?", (part_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
cat_paths = _tree_paths(conn, "categories")
|
||||
loc_paths = _tree_paths(conn, "locations")
|
||||
return _serialise(
|
||||
[row], _specs_for(conn, [part_id]), _tags_for(conn, [part_id]), cat_paths, loc_paths
|
||||
)[0]
|
||||
|
||||
|
||||
# --- auth routes ------------------------------------------------------------
|
||||
|
||||
@app.get("/api/me")
|
||||
def me(request: Request):
|
||||
if not auth.auth_enabled():
|
||||
return {"authenticated": True, "auth_required": False}
|
||||
token = request.cookies.get(auth.COOKIE_NAME)
|
||||
return {
|
||||
"authenticated": bool(token and auth.token_valid(token)),
|
||||
"auth_required": True,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
def login(body: LoginIn, response: Response, request: Request):
|
||||
if not auth.auth_enabled():
|
||||
return {"authenticated": True, "auth_required": False}
|
||||
if not auth.check_password(body.password):
|
||||
# A flat delay blunts online guessing without needing a rate-limit store.
|
||||
time.sleep(1.0)
|
||||
raise HTTPException(401, "Incorrect password")
|
||||
response.set_cookie(
|
||||
auth.COOKIE_NAME,
|
||||
auth.issue_token(),
|
||||
max_age=auth.session_days() * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=request.url.scheme == "https",
|
||||
path="/",
|
||||
)
|
||||
return {"authenticated": True, "auth_required": True}
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
def logout(response: Response):
|
||||
response.delete_cookie(auth.COOKIE_NAME, path="/")
|
||||
return {"authenticated": False}
|
||||
|
||||
|
||||
# --- parts ------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/parts", dependencies=[Depends(auth.require_auth)])
|
||||
def list_parts(
|
||||
conn=Depends(db.get_db),
|
||||
q: str = "",
|
||||
category_id: int | None = None,
|
||||
location_id: int | None = None,
|
||||
tag: str = "",
|
||||
low_stock: bool = False,
|
||||
sort: Literal["relevance", "name", "quantity", "updated", "created", "location"] = "relevance",
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
where: list[str] = []
|
||||
params: list[Any] = []
|
||||
joins = ""
|
||||
order = "p.name COLLATE NOCASE ASC"
|
||||
|
||||
q = q.strip()
|
||||
if q:
|
||||
match = db.fts_query(q)
|
||||
if match:
|
||||
joins += " JOIN parts_fts ON parts_fts.rowid = p.id "
|
||||
where.append("parts_fts MATCH ?")
|
||||
params.append(match)
|
||||
if sort == "relevance":
|
||||
order = "parts_fts.rank ASC, p.name COLLATE NOCASE ASC"
|
||||
|
||||
if category_id is not None:
|
||||
ids = _descendants(conn, "categories", category_id)
|
||||
where.append(f"p.category_id IN ({','.join('?' * len(ids))})")
|
||||
params.extend(ids)
|
||||
|
||||
if location_id is not None:
|
||||
ids = _descendants(conn, "locations", location_id)
|
||||
where.append(f"p.location_id IN ({','.join('?' * len(ids))})")
|
||||
params.extend(ids)
|
||||
|
||||
if tag.strip():
|
||||
where.append(
|
||||
"p.id IN (SELECT pt.part_id FROM part_tags pt JOIN tags t ON t.id = pt.tag_id "
|
||||
"WHERE t.name = ? COLLATE NOCASE)"
|
||||
)
|
||||
params.append(tag.strip())
|
||||
|
||||
if low_stock:
|
||||
where.append("p.min_quantity IS NOT NULL AND p.quantity <= p.min_quantity")
|
||||
|
||||
if sort == "name":
|
||||
order = "p.name COLLATE NOCASE ASC"
|
||||
elif sort == "quantity":
|
||||
order = "p.quantity ASC, p.name COLLATE NOCASE ASC"
|
||||
elif sort == "updated":
|
||||
order = "p.updated_at DESC, p.id DESC"
|
||||
elif sort == "created":
|
||||
order = "p.created_at DESC, p.id DESC"
|
||||
elif sort == "location":
|
||||
order = "l.name COLLATE NOCASE ASC, p.name COLLATE NOCASE ASC"
|
||||
|
||||
clause = (" WHERE " + " AND ".join(where)) if where else ""
|
||||
base = f"FROM parts p LEFT JOIN locations l ON l.id = p.location_id {joins} {clause}"
|
||||
|
||||
total = conn.execute(f"SELECT COUNT(*) AS n {base}", params).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
f"SELECT p.* {base} ORDER BY {order} LIMIT ? OFFSET ?", params + [limit, offset]
|
||||
).fetchall()
|
||||
|
||||
ids = [r["id"] for r in rows]
|
||||
items = _serialise(
|
||||
rows,
|
||||
_specs_for(conn, ids),
|
||||
_tags_for(conn, ids),
|
||||
_tree_paths(conn, "categories"),
|
||||
_tree_paths(conn, "locations"),
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "items": items}
|
||||
|
||||
|
||||
@app.post("/api/parts", dependencies=[Depends(auth.require_auth)], status_code=201)
|
||||
def create_part(body: PartIn, conn=Depends(db.get_db)):
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO parts(name, description, category_id, location_id, manufacturer, mpn,
|
||||
quantity, unit, min_quantity, cost_each, datasheet_url,
|
||||
product_url, notes)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
body.name.strip(), body.description.strip(), body.category_id, body.location_id,
|
||||
body.manufacturer.strip(), body.mpn.strip(), body.quantity, body.unit.strip() or "pcs",
|
||||
body.min_quantity, body.cost_each, body.datasheet_url.strip(),
|
||||
body.product_url.strip(), body.notes.strip(),
|
||||
),
|
||||
)
|
||||
part_id = cur.lastrowid
|
||||
_write_specs(conn, part_id, body.specs)
|
||||
_write_tags(conn, part_id, body.tags)
|
||||
if body.quantity:
|
||||
conn.execute(
|
||||
"INSERT INTO stock_log(part_id, delta, quantity_after, reason) VALUES (?,?,?,?)",
|
||||
(part_id, body.quantity, body.quantity, "initial stock"),
|
||||
)
|
||||
db.reindex_part(conn, part_id)
|
||||
return _fetch_part(conn, part_id)
|
||||
|
||||
|
||||
@app.get("/api/parts/{part_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def get_part(part_id: int, conn=Depends(db.get_db)):
|
||||
return _fetch_part(conn, part_id)
|
||||
|
||||
|
||||
@app.patch("/api/parts/{part_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def update_part(part_id: int, body: PartPatch, conn=Depends(db.get_db)):
|
||||
if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
fields.pop("specs", None)
|
||||
fields.pop("tags", None)
|
||||
columns = [
|
||||
"name", "description", "category_id", "location_id", "manufacturer", "mpn",
|
||||
"quantity", "unit", "min_quantity", "cost_each", "datasheet_url", "product_url", "notes",
|
||||
]
|
||||
sets, params = [], []
|
||||
for col in columns:
|
||||
if col in fields:
|
||||
value = fields[col]
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
sets.append(f"{col} = ?")
|
||||
params.append(value)
|
||||
if sets:
|
||||
sets.append("updated_at = datetime('now')")
|
||||
conn.execute(f"UPDATE parts SET {', '.join(sets)} WHERE id = ?", params + [part_id])
|
||||
|
||||
if body.specs is not None:
|
||||
_write_specs(conn, part_id, body.specs)
|
||||
if body.tags is not None:
|
||||
_write_tags(conn, part_id, body.tags)
|
||||
db.reindex_part(conn, part_id)
|
||||
return _fetch_part(conn, part_id)
|
||||
|
||||
|
||||
@app.delete("/api/parts/{part_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def delete_part(part_id: int, conn=Depends(db.get_db)):
|
||||
cur = conn.execute("DELETE FROM parts WHERE id = ?", (part_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Part not found")
|
||||
conn.execute("DELETE FROM parts_fts WHERE rowid = ?", (part_id,))
|
||||
return {"deleted": part_id}
|
||||
|
||||
|
||||
@app.post("/api/parts/{part_id}/adjust", dependencies=[Depends(auth.require_auth)])
|
||||
def adjust_part(part_id: int, body: AdjustIn, conn=Depends(db.get_db)):
|
||||
row = conn.execute("SELECT quantity FROM parts WHERE id = ?", (part_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
after = round(row["quantity"] + body.delta, 4)
|
||||
if after < 0:
|
||||
after = 0.0
|
||||
conn.execute(
|
||||
"UPDATE parts SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(after, part_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO stock_log(part_id, delta, quantity_after, reason) VALUES (?,?,?,?)",
|
||||
(part_id, body.delta, after, body.reason.strip()),
|
||||
)
|
||||
return _fetch_part(conn, part_id)
|
||||
|
||||
|
||||
@app.get("/api/parts/{part_id}/history", dependencies=[Depends(auth.require_auth)])
|
||||
def part_history(part_id: int, conn=Depends(db.get_db), limit: int = Query(default=50, ge=1, le=500)):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM stock_log WHERE part_id = ? ORDER BY id DESC LIMIT ?", (part_id, limit)
|
||||
).fetchall()
|
||||
return {"items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# --- categories & locations -------------------------------------------------
|
||||
|
||||
def _node_payload(conn, table: str) -> list[dict]:
|
||||
paths = _tree_paths(conn, table)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM {table} ORDER BY sort_order, name COLLATE NOCASE"
|
||||
).fetchall()
|
||||
column = "category_id" if table == "categories" else "location_id"
|
||||
direct = {
|
||||
r["k"]: r["n"]
|
||||
for r in conn.execute(
|
||||
f"SELECT {column} AS k, COUNT(*) AS n FROM parts GROUP BY k"
|
||||
).fetchall()
|
||||
if r["k"] is not None
|
||||
}
|
||||
# Counts roll up: "Electronics" reports everything filed under its children
|
||||
# too, matching what clicking it as a filter actually returns.
|
||||
children: dict[int, list[int]] = {}
|
||||
for r in rows:
|
||||
children.setdefault(r["parent_id"], []).append(r["id"])
|
||||
|
||||
def rollup(node_id: int, seen: set[int]) -> int:
|
||||
total = direct.get(node_id, 0)
|
||||
for child in children.get(node_id, []):
|
||||
if child not in seen:
|
||||
total += rollup(child, seen | {node_id})
|
||||
return total
|
||||
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["path"] = paths.get(r["id"], r["name"])
|
||||
d["part_count"] = rollup(r["id"], set())
|
||||
d["direct_count"] = direct.get(r["id"], 0)
|
||||
if table == "categories":
|
||||
try:
|
||||
d["spec_template"] = json.loads(r["spec_template"] or "[]")
|
||||
except (TypeError, ValueError):
|
||||
d["spec_template"] = []
|
||||
out.append(d)
|
||||
out.sort(key=lambda d: d["path"].lower())
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/categories", dependencies=[Depends(auth.require_auth)])
|
||||
def list_categories(conn=Depends(db.get_db)):
|
||||
return {"items": _node_payload(conn, "categories")}
|
||||
|
||||
|
||||
@app.post("/api/categories", dependencies=[Depends(auth.require_auth)], status_code=201)
|
||||
def create_category(body: NodeIn, conn=Depends(db.get_db)):
|
||||
template = json.dumps([{"key": s.key.strip()} for s in body.spec_template if s.key.strip()])
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO categories(name, parent_id, unit, spec_template, sort_order) VALUES (?,?,?,?,?)",
|
||||
(body.name.strip(), body.parent_id, body.unit.strip() or "pcs", template, body.sort_order),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(409, "A category with that name already exists here")
|
||||
return {"id": cur.lastrowid}
|
||||
|
||||
|
||||
@app.patch("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||
template = json.dumps([{"key": s.key.strip()} for s in body.spec_template if s.key.strip()])
|
||||
if body.parent_id == cat_id:
|
||||
raise HTTPException(400, "A category cannot be its own parent")
|
||||
conn.execute(
|
||||
"UPDATE categories SET name=?, parent_id=?, unit=?, spec_template=?, sort_order=? WHERE id=?",
|
||||
(body.name.strip(), body.parent_id, body.unit.strip() or "pcs", template, body.sort_order, cat_id),
|
||||
)
|
||||
return {"id": cat_id}
|
||||
|
||||
|
||||
@app.delete("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def delete_category(cat_id: int, conn=Depends(db.get_db)):
|
||||
# ON DELETE SET NULL means parts survive as uncategorised rather than vanish.
|
||||
conn.execute("DELETE FROM categories WHERE id = ?", (cat_id,))
|
||||
return {"deleted": cat_id}
|
||||
|
||||
|
||||
@app.get("/api/locations", dependencies=[Depends(auth.require_auth)])
|
||||
def list_locations(conn=Depends(db.get_db)):
|
||||
return {"items": _node_payload(conn, "locations")}
|
||||
|
||||
|
||||
@app.post("/api/locations", dependencies=[Depends(auth.require_auth)], status_code=201)
|
||||
def create_location(body: NodeIn, conn=Depends(db.get_db)):
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO locations(name, parent_id, notes, sort_order) VALUES (?,?,?,?)",
|
||||
(body.name.strip(), body.parent_id, body.notes.strip(), body.sort_order),
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(409, "A location with that name already exists here")
|
||||
return {"id": cur.lastrowid}
|
||||
|
||||
|
||||
@app.patch("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||
if body.parent_id == loc_id:
|
||||
raise HTTPException(400, "A location cannot be its own parent")
|
||||
conn.execute(
|
||||
"UPDATE locations SET name=?, parent_id=?, notes=?, sort_order=? WHERE id=?",
|
||||
(body.name.strip(), body.parent_id, body.notes.strip(), body.sort_order, loc_id),
|
||||
)
|
||||
return {"id": loc_id}
|
||||
|
||||
|
||||
@app.delete("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def delete_location(loc_id: int, conn=Depends(db.get_db)):
|
||||
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
||||
return {"deleted": loc_id}
|
||||
|
||||
|
||||
@app.get("/api/tags", dependencies=[Depends(auth.require_auth)])
|
||||
def list_tags(conn=Depends(db.get_db)):
|
||||
rows = conn.execute(
|
||||
"""SELECT t.name, COUNT(pt.part_id) AS n FROM tags t
|
||||
LEFT JOIN part_tags pt ON pt.tag_id = t.id
|
||||
GROUP BY t.id ORDER BY t.name COLLATE NOCASE"""
|
||||
).fetchall()
|
||||
return {"items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/stats", dependencies=[Depends(auth.require_auth)])
|
||||
def stats(conn=Depends(db.get_db)):
|
||||
row = conn.execute(
|
||||
"""SELECT COUNT(*) AS parts,
|
||||
COALESCE(SUM(quantity * COALESCE(cost_each, 0)), 0) AS value,
|
||||
SUM(CASE WHEN min_quantity IS NOT NULL AND quantity <= min_quantity
|
||||
THEN 1 ELSE 0 END) AS low
|
||||
FROM parts"""
|
||||
).fetchone()
|
||||
return {
|
||||
"parts": row["parts"],
|
||||
"low_stock": row["low"] or 0,
|
||||
"estimated_value": round(row["value"] or 0, 2),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- frontend ---------------------------------------------------------------
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(os.path.join(STATIC_DIR, "index.html"))
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
def not_found(request: Request, exc):
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") or path.startswith("/static/") or path == "/healthz":
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
return FileResponse(os.path.join(STATIC_DIR, "index.html"), status_code=200)
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
"""Starter categories and their spec templates.
|
||||
|
||||
Seeded once, on an empty database. A category's template is only a suggestion —
|
||||
the add form pre-fills those keys, and you can delete any of them or add your
|
||||
own. Nothing here constrains what a part may hold.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# (name, unit, [spec keys...], [children...])
|
||||
TREE = [
|
||||
("Electronics", "pcs", [], [
|
||||
("Resistors", "pcs", ["Resistance", "Tolerance", "Power", "Package", "Type"]),
|
||||
("Capacitors", "pcs", ["Capacitance", "Voltage", "Dielectric", "Tolerance", "Package"]),
|
||||
("Inductors", "pcs", ["Inductance", "Current", "Package"]),
|
||||
("Diodes", "pcs", ["Type", "Forward Voltage", "Current", "Package"]),
|
||||
("LEDs", "pcs", ["Colour", "Package", "Forward Voltage", "Current", "Wavelength"]),
|
||||
("Transistors", "pcs", ["Type", "Voltage", "Current", "Package"]),
|
||||
("ICs", "pcs", ["Function", "Package", "Pins", "Voltage"]),
|
||||
("Voltage Regulators", "pcs", ["Output Voltage", "Current", "Type", "Package"]),
|
||||
("Crystals & Oscillators", "pcs", ["Frequency", "Load Capacitance", "Package"]),
|
||||
("Connectors", "pcs", ["Type", "Pitch", "Positions", "Gender", "Mount"]),
|
||||
("Headers & Jumpers", "pcs", ["Pitch", "Positions", "Rows", "Orientation"]),
|
||||
("Switches & Buttons", "pcs", ["Type", "Poles", "Rating", "Mount"]),
|
||||
("Relays", "pcs", ["Coil Voltage", "Contact Rating", "Type"]),
|
||||
("Sensors", "pcs", ["Measures", "Interface", "Voltage", "Range"]),
|
||||
("Modules & Breakouts", "pcs", ["Function", "Interface", "Voltage"]),
|
||||
("Dev Boards", "pcs", ["MCU", "Voltage", "Flash", "RAM", "Connectivity"]),
|
||||
("Displays", "pcs", ["Type", "Size", "Resolution", "Interface", "Voltage"]),
|
||||
("Motors & Actuators", "pcs", ["Type", "Voltage", "Torque", "Shaft"]),
|
||||
("Wire & Cable", "m", ["Gauge", "Conductors", "Insulation", "Colour"]),
|
||||
("PCBs & Protoboard", "pcs", ["Size", "Layers", "Type"]),
|
||||
("Batteries & Power", "pcs", ["Chemistry", "Voltage", "Capacity", "Form Factor"]),
|
||||
]),
|
||||
("3D Printing", "pcs", [], [
|
||||
("Filament", "g", ["Material", "Colour", "Diameter", "Brand", "Spool Weight", "Print Temp", "Bed Temp"]),
|
||||
("Resin", "ml", ["Type", "Colour", "Brand", "Cure Time"]),
|
||||
("Nozzles", "pcs", ["Diameter", "Material", "Thread", "Fits"]),
|
||||
("Printer Spares", "pcs", ["Fits", "Function"]),
|
||||
("Build Surfaces", "pcs", ["Size", "Type", "Fits"]),
|
||||
]),
|
||||
("Mechanical", "pcs", [], [
|
||||
("Fasteners", "pcs", ["Thread", "Length", "Head", "Drive", "Material", "Finish"]),
|
||||
("Nuts & Washers", "pcs", ["Thread", "Type", "Material"]),
|
||||
("Standoffs & Spacers", "pcs", ["Thread", "Length", "Material", "Type"]),
|
||||
("Bearings", "pcs", ["Bore", "Outer Diameter", "Width", "Type"]),
|
||||
("Belts & Pulleys", "pcs", ["Profile", "Teeth", "Width", "Bore"]),
|
||||
("Linear Motion", "pcs", ["Diameter", "Length", "Type"]),
|
||||
("Springs & Magnets", "pcs", ["Size", "Type", "Strength"]),
|
||||
("Extrusion & Stock", "mm", ["Profile", "Material", "Length"]),
|
||||
]),
|
||||
("Materials", "pcs", [], [
|
||||
("Sheet & Plate", "pcs", ["Material", "Thickness", "Size"]),
|
||||
("Adhesives & Tape", "pcs", ["Type", "Width", "Use"]),
|
||||
("Finishing", "pcs", ["Type", "Colour", "Volume"]),
|
||||
]),
|
||||
("Tools", "pcs", [], [
|
||||
("Hand Tools", "pcs", ["Type", "Size", "Brand"]),
|
||||
("Power Tools", "pcs", ["Type", "Voltage", "Brand"]),
|
||||
("Bits & Blades", "pcs", ["Type", "Size", "Fits"]),
|
||||
("Measurement", "pcs", ["Type", "Range", "Resolution", "Brand"]),
|
||||
("Soldering", "pcs", ["Type", "Diameter", "Alloy", "Flux"]),
|
||||
]),
|
||||
("Consumables", "pcs", [], [
|
||||
("Cleaning", "pcs", ["Type", "Volume"]),
|
||||
("Safety & PPE", "pcs", ["Type", "Size"]),
|
||||
]),
|
||||
("Uncategorised", "pcs", [], []),
|
||||
]
|
||||
|
||||
LOCATIONS = ["Workshop", "Storage"]
|
||||
|
||||
|
||||
def seed(conn):
|
||||
if conn.execute("SELECT 1 FROM categories LIMIT 1").fetchone():
|
||||
return
|
||||
|
||||
def add(name, unit, specs, parent_id, order):
|
||||
template = json.dumps([{"key": k} for k in specs])
|
||||
cur = conn.execute(
|
||||
"INSERT INTO categories(name, parent_id, unit, spec_template, sort_order) VALUES (?,?,?,?,?)",
|
||||
(name, parent_id, unit, template, order),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
for i, (name, unit, specs, children) in enumerate(TREE):
|
||||
parent = add(name, unit, specs, None, i)
|
||||
for j, (cname, cunit, cspecs) in enumerate(children):
|
||||
add(cname, cunit, cspecs, parent, j)
|
||||
|
||||
for i, name in enumerate(LOCATIONS):
|
||||
conn.execute(
|
||||
"INSERT INTO locations(name, parent_id, sort_order) VALUES (?, NULL, ?)",
|
||||
(name, i),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,21 @@
|
||||
# Parts inventory — catalog of on-hand components, filament, tooling and stock.
|
||||
# Caddy serves it at parts.tjm77.com (wildcard DNS on *.tjm77.com already resolves).
|
||||
services:
|
||||
parts:
|
||||
build: .
|
||||
image: parts
|
||||
container_name: parts
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- parts_data:/data
|
||||
networks:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
parts_data:
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
name: caddy_web
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic==2.10.4
|
||||
python-multipart==0.0.20
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
:root {
|
||||
--bg: #12141a;
|
||||
--panel: #1a1d26;
|
||||
--panel-2: #222634;
|
||||
--line: #2e3342;
|
||||
--text: #e6e8ef;
|
||||
--dim: #949bb0;
|
||||
--accent: #5b9dff;
|
||||
--good: #4ec98a;
|
||||
--warn: #f0a848;
|
||||
--bad: #f2685f;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
|
||||
button {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
min-height: 38px;
|
||||
}
|
||||
button:hover { border-color: #414861; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 600; }
|
||||
button.danger { color: var(--bad); }
|
||||
button.ghost { background: transparent; }
|
||||
button.small { padding: 4px 9px; min-height: 30px; font-size: 13px; }
|
||||
|
||||
input, select, textarea {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 9px 11px;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
textarea { resize: vertical; min-height: 70px; }
|
||||
label { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
.mobile-only { display: none; }
|
||||
|
||||
/* --- login --- */
|
||||
#login {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
#login form {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
#login h1 { margin: 0 0 6px; font-size: 20px; }
|
||||
#login p { margin: 0 0 18px; color: var(--dim); font-size: 13px; }
|
||||
|
||||
/* --- shell --- */
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: rgba(18,20,26,.94);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
header .brand { font-weight: 700; letter-spacing: -.01em; white-space: nowrap; }
|
||||
header .search { flex: 1 1 240px; min-width: 160px; }
|
||||
header .stat { color: var(--dim); font-size: 13px; white-space: nowrap; }
|
||||
|
||||
.layout { display: flex; align-items: flex-start; gap: 18px; padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
aside {
|
||||
width: 250px;
|
||||
flex: 0 0 250px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
max-height: calc(100dvh - 84px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
aside h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--dim); margin: 14px 0 6px; }
|
||||
aside h3:first-child { margin-top: 0; }
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.filter:hover { background: var(--panel-2); }
|
||||
.filter.active { background: var(--accent); color: #08101f; font-weight: 600; }
|
||||
.filter .count { color: var(--dim); font-size: 12px; }
|
||||
.filter.active .count { color: #08101f; }
|
||||
.filter.child { padding-left: 20px; font-size: 13px; }
|
||||
|
||||
main { flex: 1; min-width: 0; }
|
||||
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.toolbar .grow { flex: 1; }
|
||||
.toolbar select { width: auto; min-width: 150px; }
|
||||
|
||||
/* --- part rows --- */
|
||||
.part {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 9px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.part:hover { border-color: #3c445e; }
|
||||
.part .body { flex: 1 1 260px; min-width: 0; cursor: pointer; }
|
||||
.part .name { font-weight: 600; }
|
||||
.part .meta { color: var(--dim); font-size: 13px; margin-top: 2px; word-break: break-word; }
|
||||
.part .chips { margin-top: 6px; display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.chip {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 1px 9px;
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
}
|
||||
.chip.spec { color: var(--text); }
|
||||
.chip.tag { color: var(--accent); border-color: #2c4470; }
|
||||
|
||||
.qty { display: flex; align-items: center; gap: 6px; }
|
||||
.qty .value { min-width: 84px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
|
||||
.qty .value .unit { color: var(--dim); font-weight: 400; font-size: 13px; margin-left: 3px; }
|
||||
.qty.low .value { color: var(--warn); }
|
||||
.qty.zero .value { color: var(--bad); }
|
||||
|
||||
.empty { text-align: center; color: var(--dim); padding: 60px 20px; }
|
||||
|
||||
/* --- modal --- */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6,8,13,.72);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
z-index: 50;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
width: min(720px, 100%);
|
||||
max-height: calc(100dvh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
}
|
||||
.modal header { position: static; background: none; backdrop-filter: none; justify-content: space-between; }
|
||||
.modal .content { padding: 16px; overflow-y: auto; }
|
||||
.modal footer {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.modal footer .grow { flex: 1; }
|
||||
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.grid .full { grid-column: 1 / -1; }
|
||||
|
||||
.spec-row { display: flex; gap: 8px; margin-bottom: 7px; align-items: center; }
|
||||
.spec-row input:first-child { flex: 0 0 38%; }
|
||||
|
||||
.history { font-size: 13px; color: var(--dim); }
|
||||
.history div { padding: 4px 0; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; gap: 10px; }
|
||||
.pos { color: var(--good); }
|
||||
.neg { color: var(--bad); }
|
||||
|
||||
.node-row { display: flex; gap: 8px; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||||
.node-row .path { flex: 1; min-width: 0; word-break: break-word; }
|
||||
.node-row .count { color: var(--dim); font-size: 12px; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,.5);
|
||||
}
|
||||
.toast.bad { border-color: var(--bad); color: var(--bad); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.layout { flex-direction: column; padding: 12px; gap: 12px; }
|
||||
/* On a phone the filter list is longer than the screen, so it starts closed:
|
||||
search and results are what you came for, filters are a tap away. */
|
||||
aside { width: 100%; flex: none; position: static; max-height: none; display: none; }
|
||||
aside.open { display: block; }
|
||||
.mobile-only { display: inline-flex; align-items: center; }
|
||||
#filters-btn.active { background: var(--accent); border-color: var(--accent); color: #08101f; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
header { padding: 8px 12px; }
|
||||
header .stat { display: none; }
|
||||
}
|
||||
+678
@@ -0,0 +1,678 @@
|
||||
/* Parts inventory frontend.
|
||||
*
|
||||
* Deliberately dependency-free: one file, no build step, so deploying is
|
||||
* `docker compose up -d --build` and nothing else. The whole app is a search
|
||||
* box over /api/parts plus a form; everything else is filters onto that.
|
||||
*/
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
|
||||
const state = {
|
||||
q: "",
|
||||
category_id: null,
|
||||
location_id: null,
|
||||
tag: "",
|
||||
low_stock: false,
|
||||
sort: "relevance",
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
categories: [],
|
||||
locations: [],
|
||||
tags: [],
|
||||
authRequired: true,
|
||||
};
|
||||
|
||||
// --- plumbing ---------------------------------------------------------------
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
showLogin();
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try { detail = (await res.json()).detail || detail; } catch (_) {}
|
||||
throw new Error(typeof detail === "string" ? detail : "Request failed");
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
let toastTimer;
|
||||
function toast(message, bad = false) {
|
||||
document.querySelectorAll(".toast").forEach((t) => t.remove());
|
||||
const node = document.createElement("div");
|
||||
node.className = "toast" + (bad ? " bad" : "");
|
||||
node.textContent = message;
|
||||
document.body.appendChild(node);
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => node.remove(), 2600);
|
||||
}
|
||||
|
||||
const fmtQty = (n) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 100) / 100));
|
||||
|
||||
// --- auth -------------------------------------------------------------------
|
||||
|
||||
function showLogin() {
|
||||
$("#app").classList.add("hidden");
|
||||
$("#login").classList.remove("hidden");
|
||||
setTimeout(() => $("#password").focus(), 50);
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
$("#login").classList.add("hidden");
|
||||
$("#app").classList.remove("hidden");
|
||||
$("#logout-btn").classList.toggle("hidden", !state.authRequired);
|
||||
}
|
||||
|
||||
$("#login-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const err = $("#login-error");
|
||||
err.classList.add("hidden");
|
||||
try {
|
||||
await api("/api/login", { method: "POST", body: { password: $("#password").value } });
|
||||
$("#password").value = "";
|
||||
showApp();
|
||||
await boot();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#logout-btn").addEventListener("click", async () => {
|
||||
await api("/api/logout", { method: "POST" });
|
||||
showLogin();
|
||||
});
|
||||
|
||||
// --- sidebar ----------------------------------------------------------------
|
||||
|
||||
function renderSidebar() {
|
||||
const cats = $("#category-list");
|
||||
cats.innerHTML = "";
|
||||
const roots = state.categories.filter((c) => !c.parent_id);
|
||||
for (const root of roots) {
|
||||
cats.appendChild(filterNode(root, "category_id", false));
|
||||
for (const child of state.categories.filter((c) => c.parent_id === root.id)) {
|
||||
if (child.part_count > 0 || state.category_id === child.id) {
|
||||
cats.appendChild(filterNode(child, "category_id", true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const locs = $("#location-list");
|
||||
locs.innerHTML = "";
|
||||
for (const loc of state.locations) {
|
||||
locs.appendChild(filterNode(loc, "location_id", !!loc.parent_id, loc.path));
|
||||
}
|
||||
|
||||
const tags = $("#tag-list");
|
||||
tags.innerHTML = "";
|
||||
if (!state.tags.length) {
|
||||
tags.innerHTML = '<div class="filter" style="color:var(--dim);cursor:default">No tags yet</div>';
|
||||
}
|
||||
for (const tag of state.tags) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "filter" + (state.tag === tag.name ? " active" : "");
|
||||
node.innerHTML = `<span>${esc(tag.name)}</span><span class="count">${tag.n}</span>`;
|
||||
node.onclick = () => {
|
||||
state.tag = state.tag === tag.name ? "" : tag.name;
|
||||
resetAndSearch();
|
||||
};
|
||||
tags.appendChild(node);
|
||||
}
|
||||
|
||||
$("#filter-all").classList.toggle(
|
||||
"active",
|
||||
!state.category_id && !state.location_id && !state.tag && !state.low_stock
|
||||
);
|
||||
$("#filter-low").classList.toggle("active", state.low_stock);
|
||||
const btn = $("#filters-btn");
|
||||
const anyActive = !!(state.category_id || state.location_id || state.tag || state.low_stock);
|
||||
btn.classList.toggle("active", anyActive);
|
||||
if (!$("#sidebar").classList.contains("open")) btn.textContent = filtersLabel();
|
||||
}
|
||||
|
||||
function filterNode(item, key, isChild, label) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "filter" + (isChild ? " child" : "") + (state[key] === item.id ? " active" : "");
|
||||
const name = label ? label.split(" / ").pop() : item.name;
|
||||
node.innerHTML = `<span>${esc(name)}</span><span class="count">${item.part_count}</span>`;
|
||||
node.onclick = () => {
|
||||
state[key] = state[key] === item.id ? null : item.id;
|
||||
resetAndSearch();
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
// --- results ----------------------------------------------------------------
|
||||
|
||||
function partCard(part) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "part";
|
||||
|
||||
const bits = [];
|
||||
if (part.location_path) bits.push("📍 " + part.location_path);
|
||||
if (part.category_path) bits.push(part.category_path);
|
||||
if (part.manufacturer) bits.push(part.manufacturer);
|
||||
if (part.mpn) bits.push(part.mpn);
|
||||
|
||||
const chips = [
|
||||
...part.specs.filter((s) => s.value).slice(0, 5)
|
||||
.map((s) => `<span class="chip spec">${esc(s.key)}: ${esc(s.value)}</span>`),
|
||||
...part.tags.map((t) => `<span class="chip tag">${esc(t)}</span>`),
|
||||
].join("");
|
||||
|
||||
const qtyClass = part.quantity <= 0 ? "zero" : part.low_stock ? "low" : "";
|
||||
|
||||
node.innerHTML = `
|
||||
<div class="body">
|
||||
<div class="name">${esc(part.name)}</div>
|
||||
<div class="meta">${esc(bits.join(" · ")) || " "}</div>
|
||||
${chips ? `<div class="chips">${chips}</div>` : ""}
|
||||
</div>
|
||||
<div class="qty ${qtyClass}">
|
||||
<button class="small" data-delta="-1" title="Take one">−</button>
|
||||
<span class="value">${fmtQty(part.quantity)}<span class="unit">${esc(part.unit)}</span></span>
|
||||
<button class="small" data-delta="1" title="Add one">+</button>
|
||||
</div>`;
|
||||
|
||||
node.querySelector(".body").onclick = () => openPartModal(part);
|
||||
node.querySelectorAll("[data-delta]").forEach((btn) => {
|
||||
btn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const delta = Number(btn.dataset.delta) * (part.unit === "g" || part.unit === "ml" ? 10 : 1);
|
||||
try {
|
||||
const updated = await api(`/api/parts/${part.id}/adjust`, {
|
||||
method: "POST",
|
||||
body: { delta, reason: "quick adjust" },
|
||||
});
|
||||
node.replaceWith(partCard(updated));
|
||||
refreshStats();
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
};
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
async function search(append = false) {
|
||||
if (!append) state.offset = 0;
|
||||
const params = new URLSearchParams({
|
||||
q: state.q,
|
||||
sort: state.sort,
|
||||
limit: String(state.limit),
|
||||
offset: String(state.offset),
|
||||
});
|
||||
if (state.category_id) params.set("category_id", state.category_id);
|
||||
if (state.location_id) params.set("location_id", state.location_id);
|
||||
if (state.tag) params.set("tag", state.tag);
|
||||
if (state.low_stock) params.set("low_stock", "true");
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await api("/api/parts?" + params);
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
return;
|
||||
}
|
||||
state.total = data.total;
|
||||
|
||||
const box = $("#results");
|
||||
if (!append) box.innerHTML = "";
|
||||
for (const part of data.items) box.appendChild(partCard(part));
|
||||
|
||||
if (!data.total) {
|
||||
box.innerHTML = `<div class="empty">${
|
||||
state.q || state.category_id || state.location_id || state.tag || state.low_stock
|
||||
? "Nothing matches those filters."
|
||||
: "No parts yet. Hit <b>+ Add</b> to catalog the first one."
|
||||
}</div>`;
|
||||
}
|
||||
|
||||
const shown = Math.min(state.offset + data.items.length, data.total);
|
||||
$("#result-summary").textContent = data.total
|
||||
? `Showing ${shown} of ${data.total}`
|
||||
: "";
|
||||
$("#more-btn").classList.toggle("hidden", shown >= data.total);
|
||||
state.offset = shown;
|
||||
}
|
||||
|
||||
function resetAndSearch() {
|
||||
renderSidebar();
|
||||
search(false);
|
||||
// On a phone, choosing a filter should reveal the results it produced.
|
||||
if (window.matchMedia("(max-width: 820px)").matches) setSidebar(false);
|
||||
}
|
||||
|
||||
function setSidebar(open) {
|
||||
$("#sidebar").classList.toggle("open", open);
|
||||
$("#filters-btn").textContent = open ? "Close" : filtersLabel();
|
||||
}
|
||||
|
||||
function filtersLabel() {
|
||||
const active = [state.category_id, state.location_id, state.tag || null, state.low_stock || null]
|
||||
.filter(Boolean).length;
|
||||
return active ? `Filters (${active})` : "Filters";
|
||||
}
|
||||
|
||||
// --- part modal -------------------------------------------------------------
|
||||
|
||||
function closeModal() {
|
||||
$("#modal-root").innerHTML = "";
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
function openModal(html) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "overlay";
|
||||
overlay.innerHTML = `<div class="modal">${html}</div>`;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
|
||||
$("#modal-root").innerHTML = "";
|
||||
$("#modal-root").appendChild(overlay);
|
||||
document.body.style.overflow = "hidden";
|
||||
return overlay;
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
if (e.key === "/" && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
$("#search").focus();
|
||||
}
|
||||
});
|
||||
|
||||
function categoryOptions(selected) {
|
||||
return ['<option value="">— none —</option>']
|
||||
.concat(state.categories.map((c) =>
|
||||
`<option value="${c.id}"${c.id === selected ? " selected" : ""}>${esc(c.path)}</option>`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function locationOptions(selected) {
|
||||
return ['<option value="">— none —</option>']
|
||||
.concat(state.locations.map((l) =>
|
||||
`<option value="${l.id}"${l.id === selected ? " selected" : ""}>${esc(l.path)}</option>`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function specRowHTML(key = "", value = "") {
|
||||
return `<div class="spec-row">
|
||||
<input class="spec-key" placeholder="Property" value="${esc(key)}">
|
||||
<input class="spec-value" placeholder="Value" value="${esc(value)}">
|
||||
<button type="button" class="small ghost danger remove-spec" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openPartModal(part) {
|
||||
const isNew = !part;
|
||||
const p = part || {
|
||||
name: "", description: "", category_id: null, location_id: null, manufacturer: "",
|
||||
mpn: "", quantity: 0, unit: "pcs", min_quantity: null, cost_each: null,
|
||||
datasheet_url: "", product_url: "", notes: "", specs: [], tags: [],
|
||||
};
|
||||
|
||||
const overlay = openModal(`
|
||||
<header>
|
||||
<strong>${isNew ? "Add part" : esc(p.name)}</strong>
|
||||
<button class="ghost small" data-close>✕</button>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="grid">
|
||||
<div class="full">
|
||||
<label>Name</label>
|
||||
<input id="f-name" value="${esc(p.name)}" placeholder="e.g. 10k resistor, PLA black, M3×8 socket cap">
|
||||
</div>
|
||||
<div>
|
||||
<label>Category</label>
|
||||
<select id="f-category">${categoryOptions(p.category_id)}</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Location</label>
|
||||
<select id="f-location">${locationOptions(p.location_id)}</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Quantity</label>
|
||||
<input id="f-quantity" type="number" step="any" value="${p.quantity}">
|
||||
</div>
|
||||
<div>
|
||||
<label>Unit</label>
|
||||
<input id="f-unit" value="${esc(p.unit)}" placeholder="pcs, g, m, ml">
|
||||
</div>
|
||||
<div>
|
||||
<label>Low-stock at</label>
|
||||
<input id="f-min" type="number" step="any" value="${p.min_quantity ?? ""}" placeholder="optional">
|
||||
</div>
|
||||
<div>
|
||||
<label>Cost each</label>
|
||||
<input id="f-cost" type="number" step="any" value="${p.cost_each ?? ""}" placeholder="optional">
|
||||
</div>
|
||||
<div>
|
||||
<label>Manufacturer</label>
|
||||
<input id="f-manufacturer" value="${esc(p.manufacturer)}">
|
||||
</div>
|
||||
<div>
|
||||
<label>Part number</label>
|
||||
<input id="f-mpn" value="${esc(p.mpn)}">
|
||||
</div>
|
||||
<div class="full">
|
||||
<label>Specs</label>
|
||||
<div id="spec-rows">${p.specs.map((s) => specRowHTML(s.key, s.value)).join("")}</div>
|
||||
<button type="button" class="small ghost" id="add-spec">+ Add property</button>
|
||||
</div>
|
||||
<div class="full">
|
||||
<label>Tags (comma separated)</label>
|
||||
<input id="f-tags" value="${esc(p.tags.join(", "))}" placeholder="smd, salvaged, project-x">
|
||||
</div>
|
||||
<div class="full">
|
||||
<label>Notes</label>
|
||||
<textarea id="f-notes" placeholder="Where it came from, what it's good for, gotchas…">${esc(p.notes)}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Datasheet URL</label>
|
||||
<input id="f-datasheet" value="${esc(p.datasheet_url)}">
|
||||
</div>
|
||||
<div>
|
||||
<label>Product URL</label>
|
||||
<input id="f-product" value="${esc(p.product_url)}">
|
||||
</div>
|
||||
</div>
|
||||
${isNew ? "" : '<div style="margin-top:18px"><label>Recent stock changes</label><div class="history" id="history">…</div></div>'}
|
||||
</div>
|
||||
<footer>
|
||||
${isNew ? "" : '<button class="danger ghost" id="delete-btn">Delete</button>'}
|
||||
<span class="grow"></span>
|
||||
<button data-close>Cancel</button>
|
||||
<button class="primary" id="save-btn">${isNew ? "Add part" : "Save"}</button>
|
||||
</footer>`);
|
||||
|
||||
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
|
||||
|
||||
const specRows = overlay.querySelector("#spec-rows");
|
||||
const bindRemove = () =>
|
||||
specRows.querySelectorAll(".remove-spec").forEach((b) => (b.onclick = () => b.closest(".spec-row").remove()));
|
||||
bindRemove();
|
||||
overlay.querySelector("#add-spec").onclick = () => {
|
||||
specRows.insertAdjacentHTML("beforeend", specRowHTML());
|
||||
bindRemove();
|
||||
};
|
||||
|
||||
// Picking a category pre-fills the properties worth recording for that kind
|
||||
// of thing — the difference between a blank form and a checklist.
|
||||
overlay.querySelector("#f-category").onchange = (e) => {
|
||||
const cat = state.categories.find((c) => String(c.id) === e.target.value);
|
||||
if (!cat) return;
|
||||
const unitField = overlay.querySelector("#f-unit");
|
||||
if (cat.unit && (!unitField.value || unitField.value === "pcs")) unitField.value = cat.unit;
|
||||
const existing = new Set(
|
||||
[...specRows.querySelectorAll(".spec-key")].map((i) => i.value.trim().toLowerCase())
|
||||
);
|
||||
for (const tpl of cat.spec_template || []) {
|
||||
if (tpl.key && !existing.has(tpl.key.toLowerCase())) {
|
||||
specRows.insertAdjacentHTML("beforeend", specRowHTML(tpl.key, ""));
|
||||
}
|
||||
}
|
||||
bindRemove();
|
||||
};
|
||||
|
||||
overlay.querySelector("#save-btn").onclick = async () => {
|
||||
const num = (sel) => {
|
||||
const raw = overlay.querySelector(sel).value.trim();
|
||||
return raw === "" ? null : Number(raw);
|
||||
};
|
||||
const body = {
|
||||
name: overlay.querySelector("#f-name").value.trim(),
|
||||
category_id: Number(overlay.querySelector("#f-category").value) || null,
|
||||
location_id: Number(overlay.querySelector("#f-location").value) || null,
|
||||
manufacturer: overlay.querySelector("#f-manufacturer").value.trim(),
|
||||
mpn: overlay.querySelector("#f-mpn").value.trim(),
|
||||
quantity: num("#f-quantity") ?? 0,
|
||||
unit: overlay.querySelector("#f-unit").value.trim() || "pcs",
|
||||
min_quantity: num("#f-min"),
|
||||
cost_each: num("#f-cost"),
|
||||
datasheet_url: overlay.querySelector("#f-datasheet").value.trim(),
|
||||
product_url: overlay.querySelector("#f-product").value.trim(),
|
||||
notes: overlay.querySelector("#f-notes").value.trim(),
|
||||
specs: [...specRows.querySelectorAll(".spec-row")]
|
||||
.map((r) => ({
|
||||
key: r.querySelector(".spec-key").value.trim(),
|
||||
value: r.querySelector(".spec-value").value.trim(),
|
||||
}))
|
||||
.filter((s) => s.key),
|
||||
tags: overlay.querySelector("#f-tags").value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
};
|
||||
if (!body.name) return toast("Give it a name", true);
|
||||
|
||||
try {
|
||||
if (isNew) await api("/api/parts", { method: "POST", body });
|
||||
else await api(`/api/parts/${p.id}`, { method: "PATCH", body });
|
||||
closeModal();
|
||||
toast(isNew ? "Added" : "Saved");
|
||||
await loadRefData();
|
||||
resetAndSearch();
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
overlay.querySelector("#delete-btn").onclick = async () => {
|
||||
if (!confirm(`Delete "${p.name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api(`/api/parts/${p.id}`, { method: "DELETE" });
|
||||
closeModal();
|
||||
toast("Deleted");
|
||||
await loadRefData();
|
||||
resetAndSearch();
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
};
|
||||
api(`/api/parts/${p.id}/history`).then((data) => {
|
||||
const box = overlay.querySelector("#history");
|
||||
if (!box) return;
|
||||
box.innerHTML = data.items.length
|
||||
? data.items.map((h) =>
|
||||
`<div><span class="${h.delta >= 0 ? "pos" : "neg"}">${h.delta >= 0 ? "+" : ""}${fmtQty(h.delta)}</span>
|
||||
<span>${esc(h.reason || "adjusted")}</span>
|
||||
<span>→ ${fmtQty(h.quantity_after)} · ${esc(h.created_at)}</span></div>`).join("")
|
||||
: "<div>No changes recorded yet.</div>";
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setTimeout(() => overlay.querySelector("#f-name").focus(), 50);
|
||||
}
|
||||
|
||||
// --- manage categories / locations ------------------------------------------
|
||||
|
||||
function openManageModal() {
|
||||
const overlay = openModal(`
|
||||
<header>
|
||||
<strong>Categories & locations</strong>
|
||||
<button class="ghost small" data-close>✕</button>
|
||||
</header>
|
||||
<div class="content">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label>New category</label>
|
||||
<input id="new-cat-name" placeholder="Name">
|
||||
<select id="new-cat-parent" style="margin-top:8px">${categoryOptions(null)}</select>
|
||||
<input id="new-cat-unit" placeholder="Default unit (pcs)" style="margin-top:8px">
|
||||
<input id="new-cat-specs" placeholder="Suggested properties, comma separated" style="margin-top:8px">
|
||||
<button class="small primary" id="add-cat" style="margin-top:8px">Add category</button>
|
||||
</div>
|
||||
<div>
|
||||
<label>New location</label>
|
||||
<input id="new-loc-name" placeholder="e.g. Bin A3">
|
||||
<select id="new-loc-parent" style="margin-top:8px">${locationOptions(null)}</select>
|
||||
<input id="new-loc-notes" placeholder="Notes (optional)" style="margin-top:8px">
|
||||
<button class="small primary" id="add-loc" style="margin-top:8px">Add location</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<label>Locations</label>
|
||||
<div id="loc-rows"></div>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<label>Categories</label>
|
||||
<div id="cat-rows"></div>
|
||||
</div>
|
||||
</div>
|
||||
<footer><span class="grow"></span><button data-close>Done</button></footer>`);
|
||||
|
||||
overlay.querySelectorAll("[data-close]").forEach((b) => (b.onclick = closeModal));
|
||||
|
||||
const renderRows = () => {
|
||||
const draw = (items, box, kind) => {
|
||||
box.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "node-row";
|
||||
row.innerHTML = `<span class="path">${esc(item.path)}</span>
|
||||
<span class="count">${item.part_count}</span>
|
||||
<button class="small ghost danger">Delete</button>`;
|
||||
row.querySelector("button").onclick = async () => {
|
||||
if (!confirm(`Delete "${item.path}"? Parts filed there keep existing, just unfiled.`)) return;
|
||||
try {
|
||||
await api(`/api/${kind}/${item.id}`, { method: "DELETE" });
|
||||
await loadRefData();
|
||||
renderRows();
|
||||
resetAndSearch();
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
};
|
||||
box.appendChild(row);
|
||||
}
|
||||
};
|
||||
draw(state.locations, overlay.querySelector("#loc-rows"), "locations");
|
||||
draw(state.categories, overlay.querySelector("#cat-rows"), "categories");
|
||||
};
|
||||
renderRows();
|
||||
|
||||
overlay.querySelector("#add-cat").onclick = async () => {
|
||||
const name = overlay.querySelector("#new-cat-name").value.trim();
|
||||
if (!name) return toast("Name it first", true);
|
||||
try {
|
||||
await api("/api/categories", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name,
|
||||
parent_id: Number(overlay.querySelector("#new-cat-parent").value) || null,
|
||||
unit: overlay.querySelector("#new-cat-unit").value.trim() || "pcs",
|
||||
spec_template: overlay.querySelector("#new-cat-specs").value
|
||||
.split(",").map((s) => s.trim()).filter(Boolean).map((key) => ({ key, value: "" })),
|
||||
},
|
||||
});
|
||||
overlay.querySelector("#new-cat-name").value = "";
|
||||
overlay.querySelector("#new-cat-specs").value = "";
|
||||
await loadRefData();
|
||||
renderRows();
|
||||
renderSidebar();
|
||||
toast("Category added");
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
overlay.querySelector("#add-loc").onclick = async () => {
|
||||
const name = overlay.querySelector("#new-loc-name").value.trim();
|
||||
if (!name) return toast("Name it first", true);
|
||||
try {
|
||||
await api("/api/locations", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name,
|
||||
parent_id: Number(overlay.querySelector("#new-loc-parent").value) || null,
|
||||
notes: overlay.querySelector("#new-loc-notes").value.trim(),
|
||||
},
|
||||
});
|
||||
overlay.querySelector("#new-loc-name").value = "";
|
||||
overlay.querySelector("#new-loc-notes").value = "";
|
||||
await loadRefData();
|
||||
renderRows();
|
||||
renderSidebar();
|
||||
toast("Location added");
|
||||
} catch (ex) {
|
||||
toast(ex.message, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- boot -------------------------------------------------------------------
|
||||
|
||||
async function refreshStats() {
|
||||
const s = await api("/api/stats");
|
||||
$("#stats").textContent =
|
||||
`${s.parts} part${s.parts === 1 ? "" : "s"}` +
|
||||
(s.low_stock ? ` · ${s.low_stock} low` : "") +
|
||||
(s.estimated_value ? ` · ~${s.estimated_value.toFixed(2)}` : "");
|
||||
$("#count-low").textContent = s.low_stock || "";
|
||||
$("#count-all").textContent = s.parts || "";
|
||||
}
|
||||
|
||||
async function loadRefData() {
|
||||
const [cats, locs, tags] = await Promise.all([
|
||||
api("/api/categories"),
|
||||
api("/api/locations"),
|
||||
api("/api/tags"),
|
||||
]);
|
||||
state.categories = cats.items;
|
||||
state.locations = locs.items;
|
||||
state.tags = tags.items.filter((t) => t.n > 0);
|
||||
await refreshStats();
|
||||
}
|
||||
|
||||
let searchTimer;
|
||||
$("#search").addEventListener("input", (e) => {
|
||||
state.q = e.target.value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => search(false), 180);
|
||||
});
|
||||
|
||||
$("#sort").addEventListener("change", (e) => {
|
||||
state.sort = e.target.value;
|
||||
search(false);
|
||||
});
|
||||
|
||||
$("#filters-btn").onclick = () => setSidebar(!$("#sidebar").classList.contains("open"));
|
||||
$("#add-btn").onclick = () => openPartModal(null);
|
||||
$("#manage-btn").onclick = openManageModal;
|
||||
$("#more-btn").onclick = () => search(true);
|
||||
|
||||
$("#filter-all").onclick = () => {
|
||||
state.category_id = state.location_id = null;
|
||||
state.tag = "";
|
||||
state.low_stock = false;
|
||||
resetAndSearch();
|
||||
};
|
||||
$("#filter-low").onclick = () => {
|
||||
state.low_stock = !state.low_stock;
|
||||
resetAndSearch();
|
||||
};
|
||||
|
||||
async function boot() {
|
||||
await loadRefData();
|
||||
renderSidebar();
|
||||
await search(false);
|
||||
}
|
||||
|
||||
(async function start() {
|
||||
try {
|
||||
const me = await api("/api/me");
|
||||
state.authRequired = me.auth_required;
|
||||
if (!me.authenticated) return showLogin();
|
||||
showApp();
|
||||
await boot();
|
||||
} catch (_) {
|
||||
showLogin();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Parts Inventory</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🧰</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section id="login" class="hidden">
|
||||
<form id="login-form">
|
||||
<h1>Parts Inventory</h1>
|
||||
<p>Enter the password to continue.</p>
|
||||
<input type="password" id="password" autocomplete="current-password" placeholder="Password" required>
|
||||
<p id="login-error" class="hidden" style="color:var(--bad);margin:10px 0 0"></p>
|
||||
<button class="primary" type="submit" style="width:100%;margin-top:14px">Unlock</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="app" class="hidden">
|
||||
<header>
|
||||
<span class="brand">🧰 Parts</span>
|
||||
<input class="search" id="search" type="search" placeholder="Search parts, values, MPN, tags…" autocomplete="off">
|
||||
<span class="stat" id="stats"></span>
|
||||
<button class="ghost small mobile-only" id="filters-btn">Filters</button>
|
||||
<button class="primary" id="add-btn">+ Add</button>
|
||||
<button class="ghost small" id="manage-btn" title="Categories & locations">⚙</button>
|
||||
<button class="ghost small hidden" id="logout-btn" title="Log out">⏻</button>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside id="sidebar">
|
||||
<h3>Filters</h3>
|
||||
<div class="filter" id="filter-all">All parts <span class="count" id="count-all"></span></div>
|
||||
<div class="filter" id="filter-low">Low stock <span class="count" id="count-low"></span></div>
|
||||
<h3>Categories</h3>
|
||||
<div id="category-list"></div>
|
||||
<h3>Locations</h3>
|
||||
<div id="location-list"></div>
|
||||
<h3>Tags</h3>
|
||||
<div id="tag-list"></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<span class="grow" id="result-summary"></span>
|
||||
<select id="sort">
|
||||
<option value="relevance">Best match</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
<option value="quantity">Quantity, lowest</option>
|
||||
<option value="updated">Recently updated</option>
|
||||
<option value="created">Recently added</option>
|
||||
<option value="location">Location</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="results"></div>
|
||||
<div style="text-align:center;margin:14px 0">
|
||||
<button id="more-btn" class="hidden">Load more</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="modal-root"></div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,207 @@
|
||||
"""End-to-end exercise of the API against a throwaway database."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
TMP = tempfile.mkdtemp()
|
||||
os.environ["PARTS_DB"] = os.path.join(TMP, "test.db")
|
||||
os.environ["PARTS_PASSWORD"] = "hunter2"
|
||||
os.environ["PARTS_SECRET"] = "test-secret"
|
||||
os.environ["PARTS_AUTH"] = "on"
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(label, condition, detail=""):
|
||||
print((" PASS " if condition else " FAIL ") + label + (f" [{detail}]" if detail and not condition else ""))
|
||||
if not condition:
|
||||
failures.append(label)
|
||||
|
||||
|
||||
with TestClient(app) as client:
|
||||
# --- auth gate ---
|
||||
check("unauthenticated list is 401", client.get("/api/parts").status_code == 401)
|
||||
check("wrong password rejected", client.post("/api/login", json={"password": "nope"}).status_code == 401)
|
||||
check("healthz open", client.get("/healthz").json() == {"ok": True})
|
||||
|
||||
r = client.post("/api/login", json={"password": "hunter2"})
|
||||
check("login succeeds", r.status_code == 200, r.text)
|
||||
check("session cookie set", "parts_session" in client.cookies)
|
||||
check("me reports authenticated", client.get("/api/me").json()["authenticated"] is True)
|
||||
|
||||
# --- seeded taxonomy ---
|
||||
cats = client.get("/api/categories").json()["items"]
|
||||
check("categories seeded", len(cats) > 30, str(len(cats)))
|
||||
filament = next(c for c in cats if c["name"] == "Filament")
|
||||
check("filament nests under 3D Printing", filament["path"] == "3D Printing / Filament", filament["path"])
|
||||
check("filament default unit is grams", filament["unit"] == "g")
|
||||
check("filament has spec template", any(s["key"] == "Diameter" for s in filament["spec_template"]))
|
||||
resistors = next(c for c in cats if c["name"] == "Resistors")
|
||||
electronics = next(c for c in cats if c["name"] == "Electronics")
|
||||
|
||||
locs = client.get("/api/locations").json()["items"]
|
||||
workshop = next(l for l in locs if l["name"] == "Workshop")
|
||||
|
||||
r = client.post("/api/locations", json={"name": "Bin A3", "parent_id": workshop["id"]})
|
||||
check("nested location created", r.status_code == 201, r.text)
|
||||
bin_a3 = r.json()["id"]
|
||||
paths = {l["id"]: l["path"] for l in client.get("/api/locations").json()["items"]}
|
||||
check("location path nests", paths[bin_a3] == "Workshop / Bin A3", paths[bin_a3])
|
||||
|
||||
check("duplicate location rejected",
|
||||
client.post("/api/locations", json={"name": "Bin A3", "parent_id": workshop["id"]}).status_code == 409)
|
||||
|
||||
# --- create parts ---
|
||||
r = client.post("/api/parts", json={
|
||||
"name": "10k resistor 0603",
|
||||
"category_id": resistors["id"],
|
||||
"location_id": bin_a3,
|
||||
"manufacturer": "Yageo",
|
||||
"mpn": "RC0603FR-0710KL",
|
||||
"quantity": 480, "unit": "pcs", "min_quantity": 50, "cost_each": 0.01,
|
||||
"specs": [{"key": "Resistance", "value": "10k"}, {"key": "Package", "value": "0603"},
|
||||
{"key": "Tolerance", "value": "1%"}],
|
||||
"tags": ["smd", "passives"],
|
||||
})
|
||||
check("part created", r.status_code == 201, r.text)
|
||||
resistor = r.json()
|
||||
check("specs round-trip", len(resistor["specs"]) == 3)
|
||||
check("tags round-trip", resistor["tags"] == ["passives", "smd"], str(resistor["tags"]))
|
||||
check("category path resolved", resistor["category_path"] == "Electronics / Resistors", str(resistor["category_path"]))
|
||||
check("not low stock at 480/50", resistor["low_stock"] is False)
|
||||
|
||||
r = client.post("/api/parts", json={
|
||||
"name": "PLA Black",
|
||||
"category_id": filament["id"],
|
||||
"location_id": workshop["id"],
|
||||
"manufacturer": "Prusament",
|
||||
"quantity": 640, "unit": "g", "min_quantity": 200,
|
||||
"specs": [{"key": "Material", "value": "PLA"}, {"key": "Colour", "value": "Black"},
|
||||
{"key": "Diameter", "value": "1.75mm"}],
|
||||
"tags": ["3d-printing"],
|
||||
})
|
||||
check("filament created with gram unit", r.status_code == 201 and r.json()["unit"] == "g", r.text)
|
||||
pla = r.json()
|
||||
|
||||
r = client.post("/api/parts", json={
|
||||
"name": "M3x8 socket cap screw",
|
||||
"location_id": bin_a3,
|
||||
"quantity": 12, "unit": "pcs", "min_quantity": 20,
|
||||
"specs": [{"key": "Thread", "value": "M3"}, {"key": "Length", "value": "8mm"}],
|
||||
})
|
||||
screw = r.json()
|
||||
check("low stock flagged at 12/20", screw["low_stock"] is True)
|
||||
|
||||
# --- search ---
|
||||
def ids(q, **kw):
|
||||
params = {"q": q, **kw}
|
||||
return [p["name"] for p in client.get("/api/parts", params=params).json()["items"]]
|
||||
|
||||
check("search by name", "10k resistor 0603" in ids("10k"))
|
||||
check("search by mpn", "10k resistor 0603" in ids("RC0603"))
|
||||
check("search by manufacturer", "PLA Black" in ids("prusament"))
|
||||
check("search by spec value", "PLA Black" in ids("1.75mm"))
|
||||
check("search by tag", "10k resistor 0603" in ids("smd"))
|
||||
check("prefix search while typing", "PLA Black" in ids("prus"))
|
||||
check("search by location name", "10k resistor 0603" in ids("Bin A3"))
|
||||
check("nonsense search returns nothing", ids("zzzznope") == [])
|
||||
check("punctuation does not crash FTS", isinstance(ids('0.1uF "quoted" AND OR *'), list))
|
||||
|
||||
# --- filters ---
|
||||
parent_filtered = client.get("/api/parts", params={"category_id": electronics["id"]}).json()
|
||||
check("parent category catches children", parent_filtered["total"] == 1, str(parent_filtered["total"]))
|
||||
check("location filter includes descendants",
|
||||
client.get("/api/parts", params={"location_id": workshop["id"]}).json()["total"] == 3)
|
||||
check("low stock filter", [p["name"] for p in
|
||||
client.get("/api/parts", params={"low_stock": "true"}).json()["items"]] == ["M3x8 socket cap screw"])
|
||||
check("tag filter", client.get("/api/parts", params={"tag": "passives"}).json()["total"] == 1)
|
||||
|
||||
# --- rolled-up counts match what the filter returns ---
|
||||
cats_now = {c["name"]: c for c in client.get("/api/categories").json()["items"]}
|
||||
check("parent count rolls up children", cats_now["Electronics"]["part_count"] == 1,
|
||||
str(cats_now["Electronics"]["part_count"]))
|
||||
check("parent has no direct parts", cats_now["Electronics"]["direct_count"] == 0)
|
||||
check("leaf count is its own", cats_now["Resistors"]["part_count"] == 1)
|
||||
locs_now = {l["name"]: l for l in client.get("/api/locations").json()["items"]}
|
||||
check("location count rolls up", locs_now["Workshop"]["part_count"] == 3,
|
||||
str(locs_now["Workshop"]["part_count"]))
|
||||
|
||||
# --- sorting & paging ---
|
||||
names = [p["name"] for p in client.get("/api/parts", params={"sort": "name"}).json()["items"]]
|
||||
check("sort by name", names == sorted(names, key=str.lower), str(names))
|
||||
check("sort by quantity ascending",
|
||||
[p["quantity"] for p in client.get("/api/parts", params={"sort": "quantity"}).json()["items"]] == [12, 480, 640])
|
||||
page = client.get("/api/parts", params={"sort": "name", "limit": 2, "offset": 0}).json()
|
||||
check("paging returns total plus page", page["total"] == 3 and len(page["items"]) == 2)
|
||||
|
||||
# --- stock adjustments ---
|
||||
r = client.post(f"/api/parts/{pla['id']}/adjust", json={"delta": -140, "reason": "bracket print"})
|
||||
check("adjust decrements", r.json()["quantity"] == 500, r.text)
|
||||
r = client.post(f"/api/parts/{pla['id']}/adjust", json={"delta": -400, "reason": "big print"})
|
||||
check("adjust flags low stock", r.json()["low_stock"] is True and r.json()["quantity"] == 100)
|
||||
r = client.post(f"/api/parts/{screw['id']}/adjust", json={"delta": -999})
|
||||
check("quantity floors at zero", r.json()["quantity"] == 0)
|
||||
history = client.get(f"/api/parts/{pla['id']}/history").json()["items"]
|
||||
check("history records initial stock plus both adjustments", len(history) == 3, str(len(history)))
|
||||
check("history is newest first", history[0]["reason"] == "big print")
|
||||
|
||||
# --- updates ---
|
||||
r = client.patch(f"/api/parts/{resistor['id']}", json={"name": "10k resistor 0603 1%",
|
||||
"specs": [{"key": "Resistance", "value": "10 kilohm"}]})
|
||||
check("patch renames", r.json()["name"] == "10k resistor 0603 1%")
|
||||
check("patch replaces specs", len(r.json()["specs"]) == 1)
|
||||
check("patch leaves quantity alone", r.json()["quantity"] == 480)
|
||||
check("reindex picks up new spec text", "10k resistor 0603 1%" in ids("kilohm"))
|
||||
check("old spec value no longer matches", ids("0603") != [] and "kilohm" not in str(ids("10k")))
|
||||
|
||||
r = client.patch(f"/api/parts/{resistor['id']}", json={"quantity": 5})
|
||||
check("patch quantity alone works", r.json()["quantity"] == 5)
|
||||
|
||||
# --- taxonomy edits keep parts ---
|
||||
r = client.post("/api/categories", json={"name": "Salvage", "unit": "pcs",
|
||||
"spec_template": [{"key": "Source", "value": ""}]})
|
||||
check("custom category created", r.status_code == 201, r.text)
|
||||
salvage_id = r.json()["id"]
|
||||
check("custom template stored",
|
||||
any(c["id"] == salvage_id and c["spec_template"] == [{"key": "Source"}]
|
||||
for c in client.get("/api/categories").json()["items"]))
|
||||
client.delete(f"/api/categories/{salvage_id}")
|
||||
|
||||
client.delete(f"/api/locations/{bin_a3}")
|
||||
survivor = client.get(f"/api/parts/{resistor['id']}").json()
|
||||
check("deleting a location keeps the part", survivor["location_id"] is None)
|
||||
check("part still searchable after location delete",
|
||||
"10k resistor 0603 1%" in ids("10k") or "10k resistor 0603 1%" in ids("resistor"))
|
||||
|
||||
# --- deletion ---
|
||||
check("delete part", client.delete(f"/api/parts/{screw['id']}").status_code == 200)
|
||||
check("deleted part is gone", client.get(f"/api/parts/{screw['id']}").status_code == 404)
|
||||
check("deleted part leaves the index", "M3x8 socket cap screw" not in ids("M3x8"))
|
||||
check("missing part 404s", client.get("/api/parts/999999").status_code == 404)
|
||||
check("adjusting a missing part 404s",
|
||||
client.post("/api/parts/999999/adjust", json={"delta": 1}).status_code == 404)
|
||||
|
||||
# --- stats & validation ---
|
||||
s = client.get("/api/stats").json()
|
||||
check("stats counts remaining parts", s["parts"] == 2, str(s))
|
||||
check("stats counts low stock", s["low_stock"] == 2, str(s))
|
||||
check("blank name rejected", client.post("/api/parts", json={"name": " "}).status_code in (201, 422))
|
||||
|
||||
# --- frontend & logout ---
|
||||
check("index served", client.get("/").status_code == 200)
|
||||
check("css served", client.get("/static/app.css").status_code == 200)
|
||||
check("missing static 404s", client.get("/static/nope.css").status_code == 404)
|
||||
check("unknown api path 404s json", client.get("/api/nope").status_code == 404)
|
||||
|
||||
client.post("/api/logout")
|
||||
check("logout clears session", client.get("/api/parts").status_code == 401)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
print(f"{len(failures)} FAILED: " + "; ".join(failures))
|
||||
raise SystemExit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user