Files
PartsInventorySystem/app/main.py
T
thejayman77 7bdf276342 Fix audit findings: lost updates, token signing, dependency advisories
Blocking:
- Stock adjustments read-modified-wrote outside a transaction, so concurrent
  changes silently overwrote each other. Verified: 50 concurrent -1 requests
  moved a quantity of 100 to 99 rather than 50, while all 51 history rows were
  written, leaving the ledger disagreeing with the stock. Both adjust and patch
  now take SQLite's write lock up front.
- Session tokens joined payload and HMAC with "." and split on the last
  occurrence. A raw digest can contain that byte, so ~12% of issued tokens
  failed their own validator (measured 121/1000). The digest is fixed width;
  slice by length instead.
- starlette 0.41.3 and python-multipart 0.0.20 carried 15 advisories between
  them, including a FileResponse Range-header DoS reachable through the public
  static assets. Pinned starlette explicitly; python-multipart was unused.

Also:
- PATCH quantity now writes history, and a floored adjustment logs the delta it
  applied rather than the one requested, so the log sums to the stock.
- Renaming or deleting a category or location rebuilds the search index for
  every part beneath it; full paths are indexed, so "Workshop" finds Bin A3.
- Blank names, negative quantities and explicit nulls on NOT NULL columns are
  422s instead of silent writes or 500s; taxonomy routes 404 on missing ids,
  409 on duplicates, and reject indirect parent cycles.
- Security headers, HSTS behind X-Forwarded-Proto, Secure cookie via the
  forwarded scheme, content-hashed asset URLs so Cloudflare cannot serve stale
  frontend code, and a global failed-login throttle.
- README documents a WAL-safe backup; cp of parts.db alone could lose commits.

Checks go from 68 to 134, including a suite that runs against a real server
because lost updates only appear when requests genuinely overlap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:08:56 -04:00

726 lines
27 KiB
Python

"""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 asyncio
import hashlib
import json
import os
import sqlite3
from contextlib import asynccontextmanager
from typing import Annotated, Any, Literal
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import AfterValidator, BaseModel, Field
from . import auth, db
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static")
CSP = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; " # the UI sets inline style attributes
"script-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'none'; "
"form-action 'self'"
)
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init()
yield
app = FastAPI(title="Parts Inventory", docs_url=None, redoc_url=None, lifespan=lifespan)
# --- validated field types --------------------------------------------------
def _required(v: str) -> str:
v = v.strip()
if not v:
raise ValueError("must not be blank")
return v
RequiredText = Annotated[str, AfterValidator(_required)]
Text = Annotated[str, AfterValidator(lambda v: v.strip())]
class SpecIn(BaseModel):
key: RequiredText = Field(max_length=80)
value: Text = Field(default="", max_length=400)
class PartIn(BaseModel):
name: RequiredText = Field(max_length=200)
description: Text = Field(default="", max_length=2000)
category_id: int | None = None
location_id: int | None = None
manufacturer: Text = Field(default="", max_length=200)
mpn: Text = Field(default="", max_length=120)
quantity: float = Field(default=0, ge=0)
unit: RequiredText = Field(default="pcs", max_length=20)
min_quantity: float | None = Field(default=None, ge=0)
cost_each: float | None = Field(default=None, ge=0)
datasheet_url: Text = Field(default="", max_length=1000)
product_url: Text = Field(default="", max_length=1000)
notes: Text = Field(default="", max_length=4000)
specs: list[SpecIn] = []
tags: list[str] = []
class PartPatch(BaseModel):
name: RequiredText | None = Field(default=None, max_length=200)
description: Text | None = Field(default=None, max_length=2000)
category_id: int | None = None
location_id: int | None = None
manufacturer: Text | None = Field(default=None, max_length=200)
mpn: Text | None = Field(default=None, max_length=120)
quantity: float | None = Field(default=None, ge=0)
unit: RequiredText | None = Field(default=None, max_length=20)
min_quantity: float | None = Field(default=None, ge=0)
cost_each: float | None = Field(default=None, ge=0)
datasheet_url: Text | None = Field(default=None, max_length=1000)
product_url: Text | None = Field(default=None, max_length=1000)
notes: Text | None = Field(default=None, max_length=4000)
specs: list[SpecIn] | None = None
tags: list[str] | None = None
reason: Text = Field(default="edited", max_length=300)
# Columns a PATCH may legitimately set back to NULL. Everything else is NOT NULL
# in the schema, so an explicit null there is a client error, not a 500.
NULLABLE_COLUMNS = {"category_id", "location_id", "min_quantity", "cost_each"}
PART_COLUMNS = [
"name", "description", "category_id", "location_id", "manufacturer", "mpn",
"quantity", "unit", "min_quantity", "cost_each", "datasheet_url", "product_url", "notes",
]
class AdjustIn(BaseModel):
delta: float
reason: Text = Field(default="", max_length=300)
class NodeIn(BaseModel):
name: RequiredText = Field(max_length=120)
parent_id: int | None = None
unit: RequiredText = Field(default="pcs", max_length=20)
spec_template: list[SpecIn] = []
notes: Text = Field(default="", max_length=1000)
sort_order: int = 0
class LoginIn(BaseModel):
password: str = Field(max_length=500)
# --- helpers ----------------------------------------------------------------
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:
if s.key.lower() in seen:
continue
seen.add(s.key.lower())
conn.execute(
"INSERT INTO part_specs(part_id, key, value, position) VALUES (?,?,?,?)",
(part_id, s.key, s.value, 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 _log_stock(conn, part_id: int, delta: float, after: float, reason: str):
conn.execute(
"INSERT INTO stock_log(part_id, delta, quantity_after, reason) VALUES (?,?,?,?)",
(part_id, delta, after, reason),
)
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")
return _serialise(
[row], _specs_for(conn, [part_id]), _tags_for(conn, [part_id]),
db.tree_paths(conn, "categories"), db.tree_paths(conn, "locations"),
)[0]
def _require_node(conn, table: str, node_id: int, label: str):
if conn.execute(f"SELECT 1 FROM {table} WHERE id = ?", (node_id,)).fetchone() is None:
raise HTTPException(404, f"{label} not found")
def _check_parent(conn, table: str, node_id: int, parent_id: int | None, label: str):
"""Reject re-parenting a node under itself or any of its own descendants.
A direct self-parent is the obvious case; the indirect one (A under B, where
B is already a child of A) is the one that actually corrupts the tree.
"""
if parent_id is None:
return
if parent_id == node_id:
raise HTTPException(400, f"A {label} cannot be its own parent")
_require_node(conn, table, parent_id, f"Parent {label}")
if parent_id in db.descendants(conn, table, node_id):
raise HTTPException(400, f"A {label} cannot be moved beneath itself")
# --- middleware -------------------------------------------------------------
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
headers = response.headers
headers.setdefault("X-Content-Type-Options", "nosniff")
headers.setdefault("X-Frame-Options", "DENY")
headers.setdefault("Referrer-Policy", "no-referrer")
headers.setdefault("Content-Security-Policy", CSP)
if auth.is_https(request):
headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
if request.url.path.startswith("/static/"):
# Asset URLs carry a content hash (see _index_html), so a versioned URL
# is safe to cache forever and a deploy simply changes the URL. Without
# the hash — someone hitting the bare path — it must not be cached, or
# an intermediary can pin stale frontend code against a new API.
if request.query_params.get("v"):
headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
headers["Cache-Control"] = "no-cache"
elif request.url.path.startswith("/api/"):
headers["Cache-Control"] = "no-store"
return response
# --- 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")
async def login(body: LoginIn, response: Response, request: Request):
if not auth.auth_enabled():
return {"authenticated": True, "auth_required": False}
retry_after = auth.login_retry_after()
if retry_after:
raise HTTPException(
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
)
if not auth.check_password(body.password):
auth.record_failure()
# Async sleep, so a burst of guesses can't tie up the worker threadpool
# that real requests need.
await asyncio.sleep(0.5)
raise HTTPException(401, "Incorrect password")
auth.clear_failures()
response.set_cookie(
auth.COOKIE_NAME,
auth.issue_token(),
max_age=auth.session_days() * 86400,
httponly=True,
samesite="lax",
secure=auth.is_https(request),
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 = db.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 = db.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),
db.tree_paths(conn, "categories"), db.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)):
if body.category_id is not None:
_require_node(conn, "categories", body.category_id, "Category")
if body.location_id is not None:
_require_node(conn, "locations", body.location_id, "Location")
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, body.description, body.category_id, body.location_id,
body.manufacturer, body.mpn, body.quantity, body.unit,
body.min_quantity, body.cost_each, body.datasheet_url,
body.product_url, body.notes,
),
)
part_id = cur.lastrowid
_write_specs(conn, part_id, body.specs)
_write_tags(conn, part_id, body.tags)
if body.quantity:
_log_stock(conn, 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)):
fields = body.model_dump(exclude_unset=True)
fields.pop("specs", None)
fields.pop("tags", None)
reason = fields.pop("reason", "edited") or "edited"
for col, value in fields.items():
if value is None and col not in NULLABLE_COLUMNS:
raise HTTPException(422, f"'{col}' cannot be null")
if fields.get("category_id") is not None:
_require_node(conn, "categories", fields["category_id"], "Category")
if fields.get("location_id") is not None:
_require_node(conn, "locations", fields["location_id"], "Location")
# Setting quantity is a read-modify-write like any adjustment, and has to be
# logged the same way — "every change is recorded" has to mean every change,
# or the history is worse than useless.
db.begin_immediate(conn)
row = conn.execute("SELECT quantity FROM parts WHERE id = ?", (part_id,)).fetchone()
if row is None:
raise HTTPException(404, "Part not found")
sets, params = [], []
for col in PART_COLUMNS:
if col in fields:
sets.append(f"{col} = ?")
params.append(fields[col])
if sets:
sets.append("updated_at = datetime('now')")
conn.execute(f"UPDATE parts SET {', '.join(sets)} WHERE id = ?", params + [part_id])
if "quantity" in fields and fields["quantity"] != row["quantity"]:
new_qty = fields["quantity"]
_log_stock(conn, part_id, new_qty - row["quantity"], new_qty, reason)
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)):
# Read and write under one write lock. Without it, concurrent adjustments
# read the same starting quantity and silently overwrite each other, leaving
# the stock log disagreeing with the stock.
db.begin_immediate(conn)
row = conn.execute("SELECT quantity FROM parts WHERE id = ?", (part_id,)).fetchone()
if row is None:
raise HTTPException(404, "Part not found")
before = row["quantity"]
after = round(max(0.0, before + body.delta), 4)
# Log what actually happened. Asking for -999 against a stock of 7 removes
# 7, and recording -999 would make the ledger unsummable.
applied = round(after - before, 4)
conn.execute(
"UPDATE parts SET quantity = ?, updated_at = datetime('now') WHERE id = ?",
(after, part_id),
)
_log_stock(conn, part_id, applied, after, body.reason)
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 = db.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
def _template_json(specs: list[SpecIn]) -> str:
return json.dumps([{"key": s.key} for s in specs])
@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)):
if body.parent_id is not None:
_require_node(conn, "categories", body.parent_id, "Parent category")
try:
cur = conn.execute(
"INSERT INTO categories(name, parent_id, unit, spec_template, sort_order) VALUES (?,?,?,?,?)",
(body.name, body.parent_id, body.unit, _template_json(body.spec_template), body.sort_order),
)
except sqlite3.IntegrityError:
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)):
_require_node(conn, "categories", cat_id, "Category")
_check_parent(conn, "categories", cat_id, body.parent_id, "category")
affected = db.parts_under(conn, "categories", cat_id)
try:
conn.execute(
"UPDATE categories SET name=?, parent_id=?, unit=?, spec_template=?, sort_order=? WHERE id=?",
(body.name, body.parent_id, body.unit, _template_json(body.spec_template),
body.sort_order, cat_id),
)
except sqlite3.IntegrityError:
raise HTTPException(409, "A category with that name already exists here")
# The category path is part of every affected part's search text.
db.reindex_parts(conn, affected)
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)):
_require_node(conn, "categories", cat_id, "Category")
# Capture the parts first: ON DELETE SET NULL means they survive as
# uncategorised rather than vanish, but their indexed path must be rebuilt.
affected = db.parts_under(conn, "categories", cat_id)
conn.execute("DELETE FROM categories WHERE id = ?", (cat_id,))
db.reindex_parts(conn, affected)
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)):
if body.parent_id is not None:
_require_node(conn, "locations", body.parent_id, "Parent location")
try:
cur = conn.execute(
"INSERT INTO locations(name, parent_id, notes, sort_order) VALUES (?,?,?,?)",
(body.name, body.parent_id, body.notes, body.sort_order),
)
except sqlite3.IntegrityError:
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)):
_require_node(conn, "locations", loc_id, "Location")
_check_parent(conn, "locations", loc_id, body.parent_id, "location")
affected = db.parts_under(conn, "locations", loc_id)
try:
conn.execute(
"UPDATE locations SET name=?, parent_id=?, notes=?, sort_order=? WHERE id=?",
(body.name, body.parent_id, body.notes, body.sort_order, loc_id),
)
except sqlite3.IntegrityError:
raise HTTPException(409, "A location with that name already exists here")
db.reindex_parts(conn, affected)
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)):
_require_node(conn, "locations", loc_id, "Location")
affected = db.parts_under(conn, "locations", loc_id)
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
db.reindex_parts(conn, affected)
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")
_index_cache: str | None = None
def _index_html() -> str:
"""index.html with a content hash appended to each asset URL.
Cloudflare sits in front of this and will happily serve a cached app.js for
hours. Versioned URLs mean a deploy changes the URL itself, so a stale copy
can never be paired with a newer API.
"""
global _index_cache
if _index_cache is None:
with open(os.path.join(STATIC_DIR, "index.html"), encoding="utf-8") as fh:
html = fh.read()
for asset in ("app.css", "app.js"):
with open(os.path.join(STATIC_DIR, asset), "rb") as fh:
digest = hashlib.sha256(fh.read()).hexdigest()[:10]
html = html.replace(f"/static/{asset}", f"/static/{asset}?v={digest}")
_index_cache = html
return _index_cache
def _index_response(status_code: int = 200) -> HTMLResponse:
return HTMLResponse(
_index_html(), status_code=status_code, headers={"Cache-Control": "no-store"}
)
@app.get("/")
def index():
return _index_response()
@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 _index_response()