Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

@@ -75,6 +75,7 @@ def get_item(item_id: int):
import glob
import os
import re
from flask import current_app
from werkzeug.utils import secure_filename
@@ -257,6 +258,52 @@ from ..models import PrintedItemTransaction
from ..services.badges import BadgeError, resolve_badge
class InsufficientStock(Exception):
"""A decrement was refused because the row no longer holds that many.
Carries the quantity the DATABASE holds, not the stale one the caller read,
so the message the operator sees is the truth at the moment of the write.
"""
def __init__(self, available):
super().__init__('insufficient stock')
self.available = available
def _move_quantity(item, quantitychange):
"""Move quantityonhand by quantitychange in ONE statement, and let the row
itself refuse a decrement it cannot cover.
This is the guard, not the caller's earlier `quantity > item.quantityonhand`
check. That check reads, decides in Python, and writes - so two kiosk takes
that interleave both read the same on-hand figure and both pass it. Five
concurrent takes of 5 from a bin holding 5 all succeeded, each wrote its own
5 - 5 = 0, and quantityonhand ended up permanently out of step with the
ledger that recorded all five.
Expressing it as `UPDATE ... WHERE quantityonhand >= :n` makes the database
do the comparing at write time, where it is atomic. A row that no longer
holds enough matches nothing, rowcount is 0, and the take is refused.
Returns the resulting quantity.
"""
query = PrintedItem.query.filter(
PrintedItem.printeditemid == item.printeditemid)
if quantitychange < 0:
query = query.filter(PrintedItem.quantityonhand >= -quantitychange)
moved = query.update(
{PrintedItem.quantityonhand:
PrintedItem.quantityonhand + quantitychange},
synchronize_session=False)
if not moved:
# Read back what is actually there so the refusal can say so.
db.session.rollback()
available = db.session.query(PrintedItem.quantityonhand).filter(
PrintedItem.printeditemid == item.printeditemid).scalar() or 0
raise InsufficientStock(available)
db.session.refresh(item)
return item.quantityonhand
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
revision=None):
"""Append a ledger row and move the cached quantity in ONE commit.
@@ -266,9 +313,17 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
alert when this write CROSSES the item's threshold downward - crossing
(not being below) is the natural debounce: one alert per depletion, and
restocking above the threshold rearms it.
Raises InsufficientStock when the row cannot cover the decrement. The
caller has already refused the obvious case; this is the one that only
shows up under concurrency.
"""
quantitybefore = item.quantityonhand
item.quantityonhand += quantitychange
# Derived from what the write ACTUALLY produced, not from what this request
# read earlier. Under concurrency the two differ, and the threshold
# crossing has to be judged on the real numbers or the alert fires for the
# wrong take (or not at all).
quantityafter = _move_quantity(item, quantitychange)
quantitybefore = quantityafter - quantitychange
db.session.add(PrintedItemTransaction(
printeditemid=item.printeditemid,
transactiontype=transactiontype,
@@ -427,7 +482,13 @@ def adjust_item(item_id: int):
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422)
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
try:
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
except InsufficientStock as shortfall:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Adjustment would drive stock below zero '
f'(on hand: {shortfall.available})')
return success_response(item.to_dict(), message='Stock adjusted')
@@ -500,8 +561,14 @@ def _kiosk_find_item(itemcode):
matches = []
for candidate in PrintedItem.query.filter_by(isactive=True).all():
for value in (candidate.itemcode, candidate.gagelabtag):
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
if tail and int(tail) == wanted:
# The TRAILING digit run, not every digit in the string. A
# prefix can contain digits - the shipped default is '3DP' -
# and collecting all of them turned 3DP0004 into 30004, so the
# operator typing the 4 printed on the bin got "no part matches
# that barcode" and had to work out that the 3 belonged to the
# prefix. The number on the label is the number at the end.
digits = re.search(r'(\d+)$', (value or '').strip())
if digits and int(digits.group(1)) == wanted:
matches.append(candidate)
break
if len(matches) == 1:
@@ -579,7 +646,16 @@ def kiosk_take():
except (ValueError, TypeError):
revision = None
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
try:
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
except InsufficientStock as shortfall:
# Another kiosk took the last of it between this request's read and its
# write. Same message as the pre-check, with the number the database
# actually holds now.
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Only {shortfall.available} on hand - take fewer or see the '
f'parts team')
return success_response(item.to_dict(),
message=f'Took {quantity}, {item.quantityonhand} left')

View File

@@ -7,10 +7,31 @@
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<div v-if="error" class="kiosk-error">
{{ error }}
<p v-if="misses >= 3" class="kiosk-hint">
Several scans in a row have found nothing. The kiosk may have been
switched off, or this screen's setup link may have been replaced - ask
an administrator to check Settings &gt; 3D Printed Parts.
</p>
</div>
<!-- This screen was never provisioned. The SERVER cannot say so - the
routes answer a bare 404 on purpose, so a caller without the token
does not learn the door exists - but the browser knows whether it
holds one, and that is not a disclosure. Without this the kiosk drew
its normal scan prompt and answered "Not found" to every scan, which
reads as "we do not stock that part". -->
<section v-if="!provisioned" class="kiosk-step kiosk-unprovisioned">
<p class="kiosk-prompt">This screen is not set up yet</p>
<p class="kiosk-hint">
An administrator opens it once using the kiosk link from
Settings &gt; 3D Printed Parts. The screen remembers it after that.
</p>
</section>
<!-- step 1: the bin label -->
<section v-if="step === 'item'" class="kiosk-step">
<section v-if="provisioned && step === 'item'" class="kiosk-step">
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
numpad, and the on-screen keypad all type into it. -->
@@ -108,6 +129,13 @@ const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
// Whether THIS browser holds the kiosk's device token. The server will not say
// (see the template), and it does not need to: an unprovisioned screen should
// say so rather than pretending to be a working kiosk with an empty catalog.
const provisioned = ref(true)
// Consecutive lookups that found nothing. Every scan missing usually means the
// kiosk was switched off, not that no bin is known.
const misses = ref(0)
const doneMessage = ref('')
const submitting = ref(false)
const manualCode = ref('')
@@ -118,6 +146,14 @@ let resetTimer = null
onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
try {
provisioned.value = Boolean(
localStorage.getItem(KIOSK_TOKEN_KEYS['/printedparts/kiosk/']))
} catch {
// A browser refusing localStorage cannot be provisioned at all, and
// saying so is more use than a scan box that answers nothing.
provisioned.value = false
}
// A kiosk left running for weeks reads this once at load, which is fine: the
// label format is a property of the site's physical tags, not something that
// changes during a shift.
@@ -153,11 +189,17 @@ async function lookupItem(itemcode) {
try {
const response = await printedpartsApi.kioskItem(code)
item.value = response.data.data
misses.value = 0
step.value = 'badge'
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
// The gate and a genuine miss both answer 404, deliberately, so the client
// cannot tell them apart from one response. What it CAN say is that
// repeated misses may mean the kiosk was switched off rather than that
// every bin is unknown.
misses.value += 1
}
focusEntry()
}
@@ -215,6 +257,14 @@ function reset() {
</script>
<style scoped>
.kiosk-unprovisioned {
text-align: center;
}
.kiosk-hint {
color: var(--text-light);
font-size: 1rem;
margin-top: 0.6rem;
}
.parts-kiosk {
min-height: 100vh;
background: var(--bg);

View File

@@ -36,16 +36,24 @@ class BadgeError(ValueError):
def _parse_badge(badge):
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
if badge.isdigit():
return 'sso', badge
match = _PAYNO_BADGE.match(badge)
if match:
return 'payno', match.group(1)
raise BadgeError('Unrecognized badge format')
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.
The shapes themselves live in shopdb.api (normalise_badge), because the USB
kiosk reads the same physical badges and used to disagree: it took the
string raw, so the PayNo wrapper resolved to nobody and an operator whose
scanner emits it could take a part and not check out a drive.
That also closed a subtler hole. This used str.isdigit(), which is true for
Arabic-Indic and other decimal forms, so a badge written in them was
accepted, resolved against int() to a REAL employee, and then stored as an
SSO string that joins to nothing.
"""
from shopdb.api import BadgeShapeError, badge_kind, normalise_badge
try:
return badge_kind(badge), normalise_badge(badge)
except BadgeShapeError as bad:
raise BadgeError(str(bad))
def _selfhosted_lookup(digits):