Manage the password in the app; close remaining audit findings
Password management moves out of the CLI entirely. The credential is now a salted scrypt hash in the database (so it survives rebuilds, living in the /data volume) rather than an environment variable; PARTS_PASSWORD is demoted to a bootstrap value that stops working the moment a password is set in the UI. Every token carries a session epoch, so changing the password — or "sign out other devices" — invalidates outstanding cookies while keeping the browser that made the change signed in. A banner nags until the handed-over password is replaced. app/admin.py remains for the one case the UI cannot cover, a forgotten password. Audit findings: - Taxonomy update and delete scanned affected parts before taking the write lock, so a concurrent rename could leave the search index matching a name the UI no longer showed. All four routes now lock first; removing the lock again makes the new test fail exactly that way. - History of a missing part returned 200 with an empty list; now 404. - Infinity and NaN passed ge=0 and failed at the database. They are rejected as 422 now, and the validation error handler no longer chokes trying to echo a non-finite value back. Checks go from 134 to 181. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""Recovery CLI.
|
||||
|
||||
Everyday password changes happen in the UI. This exists for the one case the UI
|
||||
cannot help with — a forgotten password — and for the initial handover.
|
||||
|
||||
docker exec -it parts python -m app.admin set-password
|
||||
docker exec parts python -m app.admin show-status
|
||||
"""
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from . import auth, db
|
||||
|
||||
|
||||
def set_password(argv):
|
||||
password = argv[0] if argv else None
|
||||
if password is None:
|
||||
password = getpass.getpass("New password: ")
|
||||
if password != getpass.getpass("Repeat: "):
|
||||
print("Passwords did not match.", file=sys.stderr)
|
||||
return 1
|
||||
problem = auth.password_problem(password)
|
||||
if problem:
|
||||
print(problem, file=sys.stderr)
|
||||
return 1
|
||||
with db.session() as conn:
|
||||
auth.set_password(conn, password)
|
||||
print("Password set. All existing sessions have been signed out.")
|
||||
return 0
|
||||
|
||||
|
||||
def clear_password(_argv):
|
||||
"""Fall back to the PARTS_PASSWORD environment variable again."""
|
||||
with db.session() as conn:
|
||||
conn.execute("DELETE FROM settings WHERE key = ?", (auth.PASSWORD_KEY,))
|
||||
auth.bump_epoch(conn)
|
||||
print("Stored password cleared; PARTS_PASSWORD from the environment is live again.")
|
||||
print("All existing sessions have been signed out.")
|
||||
return 0
|
||||
|
||||
|
||||
def show_status(_argv):
|
||||
with db.session() as conn:
|
||||
stored = auth.stored_hash(conn)
|
||||
print("password source :", "database (set in the app)" if stored else "PARTS_PASSWORD env var")
|
||||
print("session epoch :", auth.current_epoch(conn))
|
||||
print("auth enabled :", auth.auth_enabled())
|
||||
return 0
|
||||
|
||||
|
||||
COMMANDS = {"set-password": set_password, "clear-password": clear_password, "show-status": show_status}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
if not argv or argv[0] not in COMMANDS:
|
||||
print("usage: python -m app.admin {" + "|".join(COMMANDS) + "}", file=sys.stderr)
|
||||
return 2
|
||||
db.init()
|
||||
return COMMANDS[argv[0]](argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+112
-9
@@ -9,10 +9,13 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import Cookie, HTTPException, Request
|
||||
from fastapi import Cookie, Depends, HTTPException, Request
|
||||
|
||||
from . import db
|
||||
|
||||
COOKIE_NAME = "parts_session"
|
||||
DIGEST_SIZE = hashlib.sha256().digest_size
|
||||
@@ -46,13 +49,103 @@ def session_days() -> int:
|
||||
return _env_int("PARTS_SESSION_DAYS", 30)
|
||||
|
||||
|
||||
def check_password(candidate: str) -> bool:
|
||||
# --- stored password --------------------------------------------------------
|
||||
#
|
||||
# The password lives in the database, not the environment, so it can be changed
|
||||
# from the UI and survives a container rebuild (it is in the /data volume).
|
||||
# PARTS_PASSWORD is only the bootstrap credential: it works until a password has
|
||||
# been set through the app, and is ignored from then on — otherwise "changing"
|
||||
# the password would leave the old one working.
|
||||
|
||||
PASSWORD_KEY = "password_hash"
|
||||
EPOCH_KEY = "session_epoch"
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
_SCRYPT_N = 1 << 14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_SCRYPT_MAXMEM = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
|
||||
dklen=32, maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
return "scrypt${}${}${}${}${}".format(
|
||||
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P,
|
||||
base64.b64encode(salt).decode(), base64.b64encode(digest).decode(),
|
||||
)
|
||||
|
||||
|
||||
def verify_hash(password: str, stored: str) -> bool:
|
||||
try:
|
||||
scheme, n, r, pp, salt_b64, digest_b64 = stored.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
digest = hashlib.scrypt(
|
||||
password.encode(), salt=base64.b64decode(salt_b64),
|
||||
n=int(n), r=int(r), p=int(pp), dklen=len(base64.b64decode(digest_b64)),
|
||||
maxmem=_SCRYPT_MAXMEM,
|
||||
)
|
||||
return hmac.compare_digest(digest, base64.b64decode(digest_b64))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def stored_hash(conn) -> str | None:
|
||||
return db.get_setting(conn, PASSWORD_KEY)
|
||||
|
||||
|
||||
def using_bootstrap_password(conn) -> bool:
|
||||
"""True while the env-provided password is still the live one."""
|
||||
return stored_hash(conn) is None
|
||||
|
||||
|
||||
def check_password(conn, candidate: str) -> bool:
|
||||
stored = stored_hash(conn)
|
||||
if stored:
|
||||
return verify_hash(candidate, stored)
|
||||
expected = _env("PARTS_PASSWORD")
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(candidate.encode(), expected.encode())
|
||||
|
||||
|
||||
def set_password(conn, new_password: str):
|
||||
"""Store a new password and invalidate every outstanding session."""
|
||||
db.set_setting(conn, PASSWORD_KEY, hash_password(new_password))
|
||||
bump_epoch(conn)
|
||||
|
||||
|
||||
def password_problem(new_password: str) -> str | None:
|
||||
if len(new_password) < MIN_PASSWORD_LENGTH:
|
||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
|
||||
if not new_password.strip():
|
||||
return "Password must not be blank"
|
||||
return None
|
||||
|
||||
|
||||
# --- session epoch ----------------------------------------------------------
|
||||
#
|
||||
# Baked into every token. Bumping it invalidates all outstanding cookies at
|
||||
# once, which is what makes "change my password" and "sign out everywhere"
|
||||
# work without having to rotate PARTS_SECRET by hand on the host.
|
||||
|
||||
def current_epoch(conn) -> int:
|
||||
try:
|
||||
return int(db.get_setting(conn, EPOCH_KEY, "0") or "0")
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def bump_epoch(conn) -> int:
|
||||
epoch = current_epoch(conn) + 1
|
||||
db.set_setting(conn, EPOCH_KEY, epoch)
|
||||
return epoch
|
||||
|
||||
|
||||
# --- session tokens ---------------------------------------------------------
|
||||
#
|
||||
# The signature is raw HMAC bytes, which can contain any byte value including
|
||||
@@ -61,14 +154,14 @@ def check_password(candidate: str) -> bool:
|
||||
# split in the wrong place and failed their own validator. The digest is a fixed
|
||||
# 32 bytes, so slice by length instead of looking for a delimiter.
|
||||
|
||||
def issue_token() -> str:
|
||||
def issue_token(conn) -> str:
|
||||
expires = int(time.time()) + session_days() * 86400
|
||||
payload = str(expires).encode()
|
||||
payload = f"{expires}:{current_epoch(conn)}".encode()
|
||||
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(payload + sig).decode()
|
||||
|
||||
|
||||
def token_valid(token: str) -> bool:
|
||||
def token_valid(token: str, conn=None) -> bool:
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(token.encode())
|
||||
if len(raw) <= DIGEST_SIZE:
|
||||
@@ -77,7 +170,12 @@ def token_valid(token: str) -> bool:
|
||||
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return False
|
||||
return int(payload) > time.time()
|
||||
expires_s, _, epoch_s = payload.decode().partition(":")
|
||||
if int(expires_s) <= time.time():
|
||||
return False
|
||||
if conn is not None and int(epoch_s or 0) != current_epoch(conn):
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -149,10 +247,15 @@ def is_https(request: Request) -> bool:
|
||||
return request.url.scheme == "https"
|
||||
|
||||
|
||||
def require_auth(parts_session: str | None = Cookie(default=None)):
|
||||
"""FastAPI dependency guarding every data route."""
|
||||
def require_auth(parts_session: str | None = Cookie(default=None), conn=Depends(db.get_db)):
|
||||
"""FastAPI dependency guarding every data route.
|
||||
|
||||
Takes the same request-scoped connection the route uses (FastAPI caches
|
||||
dependency results per request), so checking the session epoch costs one
|
||||
indexed lookup rather than a second connection.
|
||||
"""
|
||||
if not auth_enabled():
|
||||
return True
|
||||
if parts_session and token_valid(parts_session):
|
||||
if parts_session and token_valid(parts_session, conn):
|
||||
return True
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
@@ -94,6 +94,14 @@ CREATE TABLE IF NOT EXISTS stock_log (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS stock_log_part ON stock_log(part_id, id DESC);
|
||||
|
||||
-- Small key/value store for things that must outlive a container rebuild and
|
||||
-- be changeable without editing a file on the host: the password hash and the
|
||||
-- session epoch live here, in the /data volume.
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS parts_fts USING fts5(
|
||||
name, description, manufacturer, mpn, specs, tags, category, location,
|
||||
tokenize='unicode61 remove_diacritics 2'
|
||||
@@ -131,6 +139,19 @@ def init():
|
||||
_initialised = True
|
||||
|
||||
|
||||
def get_setting(conn, key: str, default: str | None = None) -> str | None:
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(conn, key: str, value: str):
|
||||
conn.execute(
|
||||
"INSERT INTO settings(key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session():
|
||||
conn = connect()
|
||||
|
||||
+125
-22
@@ -9,12 +9,14 @@ consumer of the same endpoints rather than a fork of them.
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
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.exceptions import RequestValidationError
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import AfterValidator, BaseModel, Field
|
||||
@@ -69,10 +71,10 @@ class PartIn(BaseModel):
|
||||
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)
|
||||
quantity: float = Field(default=0, ge=0, allow_inf_nan=False)
|
||||
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)
|
||||
min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||
cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||
datasheet_url: Text = Field(default="", max_length=1000)
|
||||
product_url: Text = Field(default="", max_length=1000)
|
||||
notes: Text = Field(default="", max_length=4000)
|
||||
@@ -87,10 +89,10 @@ class PartPatch(BaseModel):
|
||||
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)
|
||||
quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||
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)
|
||||
min_quantity: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||
cost_each: float | None = Field(default=None, ge=0, allow_inf_nan=False)
|
||||
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)
|
||||
@@ -109,7 +111,7 @@ PART_COLUMNS = [
|
||||
|
||||
|
||||
class AdjustIn(BaseModel):
|
||||
delta: float
|
||||
delta: float = Field(allow_inf_nan=False)
|
||||
reason: Text = Field(default="", max_length=300)
|
||||
|
||||
|
||||
@@ -126,6 +128,11 @@ class LoginIn(BaseModel):
|
||||
password: str = Field(max_length=500)
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str = Field(max_length=500)
|
||||
new_password: str = Field(max_length=500)
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
def _specs_for(conn, part_ids: list[int]) -> dict[int, list[dict]]:
|
||||
@@ -240,6 +247,32 @@ def _check_parent(conn, table: str, node_id: int, parent_id: int | None, label:
|
||||
raise HTTPException(400, f"A {label} cannot be moved beneath itself")
|
||||
|
||||
|
||||
# --- error handling ---------------------------------------------------------
|
||||
|
||||
def _json_safe(value):
|
||||
"""Make a validation-error payload encodable.
|
||||
|
||||
FastAPI echoes the rejected value back in the error detail. When that value
|
||||
is Infinity or NaN — the very thing being rejected — the JSON encoder
|
||||
refuses it and a clean 422 turns into a serialisation failure. Stringify
|
||||
anything the encoder can't represent.
|
||||
"""
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else str(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, (str, int, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
def validation_error(request: Request, exc: RequestValidationError):
|
||||
return JSONResponse({"detail": _json_safe(exc.errors())}, status_code=422)
|
||||
|
||||
|
||||
# --- middleware -------------------------------------------------------------
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -268,19 +301,35 @@ async def security_headers(request: Request, call_next):
|
||||
|
||||
# --- auth routes ------------------------------------------------------------
|
||||
|
||||
def _set_session_cookie(response: Response, request: Request, conn):
|
||||
response.set_cookie(
|
||||
auth.COOKIE_NAME,
|
||||
auth.issue_token(conn),
|
||||
max_age=auth.session_days() * 86400,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=auth.is_https(request),
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/me")
|
||||
def me(request: Request):
|
||||
def me(request: Request, conn=Depends(db.get_db)):
|
||||
if not auth.auth_enabled():
|
||||
return {"authenticated": True, "auth_required": False}
|
||||
return {"authenticated": True, "auth_required": False, "using_bootstrap_password": False}
|
||||
token = request.cookies.get(auth.COOKIE_NAME)
|
||||
authenticated = bool(token and auth.token_valid(token, conn))
|
||||
return {
|
||||
"authenticated": bool(token and auth.token_valid(token)),
|
||||
"authenticated": authenticated,
|
||||
"auth_required": True,
|
||||
# Surfaced so the UI can nag until the handed-over password is replaced.
|
||||
"using_bootstrap_password": authenticated and auth.using_bootstrap_password(conn),
|
||||
"min_password_length": auth.MIN_PASSWORD_LENGTH,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
async def login(body: LoginIn, response: Response, request: Request):
|
||||
async def login(body: LoginIn, response: Response, request: Request, conn=Depends(db.get_db)):
|
||||
if not auth.auth_enabled():
|
||||
return {"authenticated": True, "auth_required": False}
|
||||
|
||||
@@ -290,7 +339,7 @@ async def login(body: LoginIn, response: Response, request: Request):
|
||||
429, "Too many failed attempts", headers={"Retry-After": str(retry_after)}
|
||||
)
|
||||
|
||||
if not auth.check_password(body.password):
|
||||
if not auth.check_password(conn, body.password):
|
||||
auth.record_failure()
|
||||
# Async sleep, so a burst of guesses can't tie up the worker threadpool
|
||||
# that real requests need.
|
||||
@@ -298,16 +347,52 @@ async def login(body: LoginIn, response: Response, request: Request):
|
||||
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}
|
||||
_set_session_cookie(response, request, conn)
|
||||
return {
|
||||
"authenticated": True,
|
||||
"auth_required": True,
|
||||
"using_bootstrap_password": auth.using_bootstrap_password(conn),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/password", dependencies=[Depends(auth.require_auth)])
|
||||
async def change_password(
|
||||
body: PasswordChangeIn, response: Response, request: Request, conn=Depends(db.get_db)
|
||||
):
|
||||
"""Change the password from inside the app.
|
||||
|
||||
Requires the current password even though the caller already holds a valid
|
||||
session: a borrowed browser tab should not be enough to lock the owner out.
|
||||
"""
|
||||
if not auth.auth_enabled():
|
||||
raise HTTPException(400, "Authentication is disabled, so there is no password to change")
|
||||
|
||||
if not auth.check_password(conn, body.current_password):
|
||||
auth.record_failure()
|
||||
await asyncio.sleep(0.5)
|
||||
raise HTTPException(403, "Current password is incorrect")
|
||||
|
||||
problem = auth.password_problem(body.new_password)
|
||||
if problem:
|
||||
raise HTTPException(422, problem)
|
||||
if body.new_password == body.current_password:
|
||||
raise HTTPException(422, "New password must be different from the current one")
|
||||
|
||||
auth.clear_failures()
|
||||
# Bumps the session epoch, so every cookie issued before now stops working.
|
||||
auth.set_password(conn, body.new_password)
|
||||
# Re-issue for the caller, so changing the password doesn't log you out of
|
||||
# the tab you changed it in.
|
||||
_set_session_cookie(response, request, conn)
|
||||
return {"changed": True, "other_sessions_signed_out": True}
|
||||
|
||||
|
||||
@app.post("/api/sessions/revoke", dependencies=[Depends(auth.require_auth)])
|
||||
def revoke_sessions(response: Response, request: Request, conn=Depends(db.get_db)):
|
||||
"""Sign out every other device, keeping this one signed in."""
|
||||
auth.bump_epoch(conn)
|
||||
_set_session_cookie(response, request, conn)
|
||||
return {"revoked": True}
|
||||
|
||||
|
||||
@app.post("/api/logout")
|
||||
@@ -503,6 +588,8 @@ def adjust_part(part_id: int, body: AdjustIn, conn=Depends(db.get_db)):
|
||||
|
||||
@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)):
|
||||
if conn.execute("SELECT 1 FROM parts WHERE id = ?", (part_id,)).fetchone() is None:
|
||||
raise HTTPException(404, "Part not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM stock_log WHERE part_id = ? ORDER BY id DESC LIMIT ?", (part_id, limit)
|
||||
).fetchall()
|
||||
@@ -578,6 +665,10 @@ def create_category(body: NodeIn, conn=Depends(db.get_db)):
|
||||
|
||||
@app.patch("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||
# Before the first read: the affected-parts scan and the reindex that
|
||||
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||
# between them and leave the search index describing the old taxonomy.
|
||||
db.begin_immediate(conn)
|
||||
_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)
|
||||
@@ -596,6 +687,10 @@ def update_category(cat_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||
|
||||
@app.delete("/api/categories/{cat_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def delete_category(cat_id: int, conn=Depends(db.get_db)):
|
||||
# Before the first read: the affected-parts scan and the reindex that
|
||||
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||
# between them and leave the search index describing the old taxonomy.
|
||||
db.begin_immediate(conn)
|
||||
_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.
|
||||
@@ -626,6 +721,10 @@ def create_location(body: NodeIn, conn=Depends(db.get_db)):
|
||||
|
||||
@app.patch("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||
# Before the first read: the affected-parts scan and the reindex that
|
||||
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||
# between them and leave the search index describing the old taxonomy.
|
||||
db.begin_immediate(conn)
|
||||
_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)
|
||||
@@ -642,6 +741,10 @@ def update_location(loc_id: int, body: NodeIn, conn=Depends(db.get_db)):
|
||||
|
||||
@app.delete("/api/locations/{loc_id}", dependencies=[Depends(auth.require_auth)])
|
||||
def delete_location(loc_id: int, conn=Depends(db.get_db)):
|
||||
# Before the first read: the affected-parts scan and the reindex that
|
||||
# follows it have to see one consistent tree, or a concurrent write can slip
|
||||
# between them and leave the search index describing the old taxonomy.
|
||||
db.begin_immediate(conn)
|
||||
_require_node(conn, "locations", loc_id, "Location")
|
||||
affected = db.parts_under(conn, "locations", loc_id)
|
||||
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
||||
|
||||
Reference in New Issue
Block a user