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:
Jay
2026-08-24 09:19:58 -04:00
commit ffe508b7f3
15 changed files with 2330 additions and 0 deletions
+105
View File
@@ -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.