USB kiosk: take and return a drive without a ShopDB login
The people who actually take USB drives are standing at the cabinet, and most of them have no account here. Check-out and check-in were gated behind a login that was not doing much anyway: both modes already attribute the action to a scanned BADGE rather than to the signed-in user, so the login was gating the screen, not the record. /usb-kiosk is a full-screen touch page modelled on the parts kiosk, off unless a site sets usb_kiosk_enabled - every route 404s until then, rather than 403, so a disabled kiosk does not advertise that there is a door here. It works against the self-hosted tables or an existing cmmc_usb database. THE LIST IS THE TRADE. These drives carry no barcode, so unlike the parts kiosk there is nothing to present and the screen has to offer a list - which exposes more than a scan-first design, where you had to already hold a drive to learn anything. So the list is not anonymous: an SSO comes first and must resolve to a real person before any drive is named, which is the same bar taking a drive already had to clear. It is a POST so the badge stays out of proxy access logs. The main screen shows the caller's own drives, what is free, and a COUNT of everything else; holders of other people's drives appear only behind a second deliberate tap, so nobody reads the whole cabinet off a wall panel in passing. A badge that resolves to no employee is REFUSED, unlike the signed-in route which creates the user row. A checkout nobody can trace to a person defeats the tracking, and at a kiosk there is no operator standing there to vouch for it. Returning somebody else's drive asks first and then records the badge that was SCANNED, so the log says who actually handed it back. That is the honest record and it needs no change to a schema this app does not own. Refusing a mismatch outright is a setting but not the default: a drive left behind by somebody on leave would otherwise be unreturnable at the kiosk, which just moves the problem to a person with a login. The authz sweep caught these writes, twice - once for the two mutations and again for the two list endpoints. Both are exempted with the reasoning written out beside the parts-kiosk entry rather than silenced. The list endpoints are POSTs that change nothing. Verified by driving the real page: SSO on the keypad, tap Take, database moves.
This commit is contained in:
@@ -721,3 +721,350 @@ def list_active_checkouts():
|
||||
conn.close()
|
||||
|
||||
return success_response(data)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Kiosk: UNAUTHENTICATED check-out / check-in
|
||||
# =============================================================================
|
||||
# The bar these have to meet, same as the parts kiosk: they can move a device
|
||||
# between checked-out and checked-in and NOTHING else. No create, no edit, no
|
||||
# retire, no browsing the fleet, no reading who holds what without presenting
|
||||
# the device. Identity is the scanned badge, resolved server-side; the client
|
||||
# never asserts who it is.
|
||||
#
|
||||
# Off unless a site turns it on. An unauthenticated write is a decision about
|
||||
# the door, not about the person walking through it.
|
||||
|
||||
def _kiosk_setting(key, default=False):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
if row is None or row.value is None or row.value == '':
|
||||
return default
|
||||
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
||||
|
||||
|
||||
def _kiosk_enabled():
|
||||
return _kiosk_setting('usb_kiosk_enabled', False)
|
||||
|
||||
|
||||
def _kiosk_disabled_response():
|
||||
# 404, not 403: when the kiosk is off these routes should not read as
|
||||
# "there is a door here and you may not use it".
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||
|
||||
|
||||
def _kiosk_badge(data):
|
||||
"""The scanned badge, or an error response. Never trusts a client name."""
|
||||
badge = (data.get('badge') or '').strip()
|
||||
if not badge:
|
||||
return None, error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Scan your badge')
|
||||
return badge, None
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
|
||||
def kiosk_device(device_id):
|
||||
"""What the kiosk shows after a device is scanned.
|
||||
|
||||
Deliberately narrow: the description, whether it is out, and the NAME of
|
||||
whoever holds it - which the return-for-someone-else confirm has to show, or
|
||||
the person standing there cannot tell whether they are doing the right
|
||||
thing. No badge number, no history, no contact details.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return _kiosk_disabled_response()
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_device(device_id)
|
||||
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
device = _fetch_device(cur, device_id)
|
||||
if not device:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No device matches that code',
|
||||
http_code=404)
|
||||
holder = ''
|
||||
if device.get('status') == _STATUS_CHECKED_OUT:
|
||||
holder = _kiosk_holder_name(cur, device_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return success_response({
|
||||
'device_id': device.get('device_id'),
|
||||
'device_desc': device.get('device_desc'),
|
||||
'status': device.get('status'),
|
||||
'locker_location': device.get('locker_location'),
|
||||
'holdername': holder,
|
||||
})
|
||||
|
||||
|
||||
def _kiosk_holder_badge(cur, device_id):
|
||||
"""Badge on the most recent check-out for this device, or ''."""
|
||||
cur.execute(
|
||||
'SELECT badge_number FROM checkinoutlog '
|
||||
"WHERE device_id = %s AND action = 'check-out' "
|
||||
'ORDER BY timestamp DESC, log_id DESC LIMIT 1',
|
||||
(device_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return (row.get('badge_number') or '').strip() if row else ''
|
||||
|
||||
|
||||
def _kiosk_holder_name(cur, device_id):
|
||||
badge = _kiosk_holder_badge(cur, device_id)
|
||||
return _resolve_badge_name(cur, badge) if badge else ''
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/session', methods=['POST'])
|
||||
def kiosk_session():
|
||||
"""What one badge may do right now. Body: {badge}.
|
||||
|
||||
The drives here carry no barcode, so the kiosk cannot ask somebody to
|
||||
present one - it has to offer a LIST. That is a real widening: before, you
|
||||
had to already hold a drive to learn anything. So the list is not anonymous.
|
||||
It costs a badge that resolves to a real person, which is the same bar
|
||||
check-out itself has to clear, and it is a POST so the badge does not end up
|
||||
in a proxy access log the way a query string would.
|
||||
|
||||
Returns the caller's own drives (to return), what is free (to take), and a
|
||||
COUNT of everything else - names for those are a separate, deliberate step,
|
||||
so a passer-by does not read the whole cabinet's holders off a wall screen.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_session(data)
|
||||
|
||||
badge, failure = _kiosk_badge(data)
|
||||
if failure:
|
||||
return failure
|
||||
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT',
|
||||
http_code=422)
|
||||
|
||||
cur.execute(
|
||||
'SELECT ' + _DEVICE_COLS + ' FROM devices '
|
||||
'WHERE status IN (%s, %s) ORDER BY device_id',
|
||||
(_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT),
|
||||
)
|
||||
rows = cur.fetchall() or []
|
||||
|
||||
yours, available, others = [], [], 0
|
||||
for row in rows:
|
||||
entry = {
|
||||
'device_id': row.get('device_id'),
|
||||
'device_desc': row.get('device_desc'),
|
||||
'locker_location': row.get('locker_location'),
|
||||
}
|
||||
if row.get('status') == _STATUS_CHECKED_IN:
|
||||
available.append(entry)
|
||||
elif _kiosk_holder_badge(cur, row.get('device_id')) == badge:
|
||||
yours.append(entry)
|
||||
else:
|
||||
others += 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return success_response({'name': name, 'yours': yours,
|
||||
'available': available, 'otherscount': others})
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/others', methods=['POST'])
|
||||
def kiosk_others():
|
||||
"""Drives checked out to OTHER people, with holder names. Body: {badge}.
|
||||
|
||||
Behind its own tap, and only when returning for somebody else is allowed at
|
||||
all. Somebody has to be able to put back a drive left on a bench by a person
|
||||
who is on leave - but the names that makes visible should not be the first
|
||||
thing on a shopfloor screen.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return _kiosk_disabled_response()
|
||||
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_others(data)
|
||||
|
||||
badge, failure = _kiosk_badge(data)
|
||||
if failure:
|
||||
return failure
|
||||
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if not _resolve_badge_name(cur, badge):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT',
|
||||
http_code=422)
|
||||
cur.execute(
|
||||
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s '
|
||||
'ORDER BY device_id',
|
||||
(_STATUS_CHECKED_OUT,),
|
||||
)
|
||||
out = []
|
||||
for row in cur.fetchall() or []:
|
||||
holderbadge = _kiosk_holder_badge(cur, row.get('device_id'))
|
||||
if holderbadge == badge:
|
||||
continue
|
||||
out.append({
|
||||
'device_id': row.get('device_id'),
|
||||
'device_desc': row.get('device_desc'),
|
||||
'locker_location': row.get('locker_location'),
|
||||
'holdername': _resolve_badge_name(cur, holderbadge),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return success_response(out)
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/checkout', methods=['POST'])
|
||||
def kiosk_checkout():
|
||||
"""Check a device out at the kiosk. Body: {device_id, badge}."""
|
||||
if not _kiosk_enabled():
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_checkout(data)
|
||||
|
||||
device_id = (data.get('device_id') or '').strip()
|
||||
badge, failure = _kiosk_badge(data)
|
||||
if failure:
|
||||
return failure
|
||||
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
device = _fetch_device(cur, device_id)
|
||||
if not device:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No device matches that code',
|
||||
http_code=404)
|
||||
if device.get('status') == _STATUS_RETIRED:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'That device is retired - see IT')
|
||||
if device.get('status') == _STATUS_CHECKED_OUT:
|
||||
holder = _kiosk_holder_name(cur, device_id)
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'That device is already checked out{}'.format(
|
||||
' to {}'.format(holder) if holder else ''),
|
||||
http_code=409)
|
||||
|
||||
# An unresolvable badge is REFUSED here, unlike the signed-in route
|
||||
# which creates the user row. A checkout nobody can trace to a
|
||||
# person is the one thing this tracking exists to prevent, and at a
|
||||
# kiosk there is no signed-in operator to vouch for it.
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to check this device out',
|
||||
http_code=422)
|
||||
|
||||
cur.execute(
|
||||
'INSERT INTO checkinoutlog '
|
||||
'(badge_number, device_id, action, timestamp, locker_location) '
|
||||
"VALUES (%s, %s, 'check-out', %s, %s)",
|
||||
(badge, device_id, _now(), device.get('locker_location')),
|
||||
)
|
||||
cur.execute(
|
||||
'UPDATE devices SET status = %s WHERE device_id = %s',
|
||||
(_STATUS_CHECKED_OUT, device_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return success_response({'device_id': device_id, 'holdername': name},
|
||||
message='{} checked out to {}'.format(
|
||||
device_id, name))
|
||||
|
||||
|
||||
@usb_bp.route('/kiosk/checkin', methods=['POST'])
|
||||
def kiosk_checkin():
|
||||
"""Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}.
|
||||
|
||||
Holder by default. A mismatch is refused with the holder's name and a flag
|
||||
the screen uses to offer "returning it for them", because the alternative -
|
||||
a drive whose owner is on leave that literally cannot be returned at the
|
||||
kiosk - just moves the problem to somebody with a login.
|
||||
|
||||
The badge RECORDED is the one that was scanned: the log says who actually
|
||||
handed the device back, which is the honest record and needs no change to a
|
||||
schema this app does not own.
|
||||
"""
|
||||
if not _kiosk_enabled():
|
||||
return _kiosk_disabled_response()
|
||||
data = request.get_json() or {}
|
||||
if _usb_selfhosted():
|
||||
return selfhosted.kiosk_checkin(data)
|
||||
|
||||
device_id = (data.get('device_id') or '').strip()
|
||||
badge, failure = _kiosk_badge(data)
|
||||
if failure:
|
||||
return failure
|
||||
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
||||
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
device = _fetch_device(cur, device_id)
|
||||
if not device:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No device matches that code',
|
||||
http_code=404)
|
||||
if device.get('status') != _STATUS_CHECKED_OUT:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'That device is not checked out')
|
||||
|
||||
name = _resolve_badge_name(cur, badge)
|
||||
if not name:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT to return this device',
|
||||
http_code=422)
|
||||
|
||||
holderbadge = _kiosk_holder_badge(cur, device_id)
|
||||
if holderbadge and holderbadge != badge:
|
||||
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'That device is checked out to somebody else - they '
|
||||
'need to return it, or see IT',
|
||||
http_code=403)
|
||||
if not forsomeoneelse:
|
||||
holdername = _resolve_badge_name(cur, holderbadge)
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'That device is checked out to {}. Confirm you are '
|
||||
'returning it for them.'.format(holdername or 'somebody else'),
|
||||
details={'needsconfirm': True,
|
||||
'holdername': holdername},
|
||||
http_code=409)
|
||||
|
||||
cur.execute(
|
||||
'INSERT INTO checkinoutlog '
|
||||
'(badge_number, device_id, action, timestamp, locker_location) '
|
||||
"VALUES (%s, %s, 'check-in', %s, %s)",
|
||||
(badge, device_id, _now(), device.get('locker_location')),
|
||||
)
|
||||
cur.execute(
|
||||
'UPDATE devices SET status = %s WHERE device_id = %s',
|
||||
(_STATUS_CHECKED_IN, device_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return success_response({'device_id': device_id},
|
||||
message='{} checked in'.format(device_id))
|
||||
|
||||
Reference in New Issue
Block a user