Files
shopdb-flask/shopdb/utils/badges.py
cproudlock cbdfcbabb7 One badge vocabulary, and images a browser can actually load
Two operator-visible defects and three doc corrections, closing the tail of the
2026-09-03 run.

THE BADGE. The two kiosks read the same physical badges and disagreed. The
parts kiosk understood both shapes a scanner emits - the bare SSO and the PayNo
wrapper 0<digits>BZ - while the USB kiosk compared the raw string to an SSO, so
the wrapper resolved to nobody. An operator whose keyboard-wedge scanner emits
it could take a 3D-printed part and could not check out a drive, with the same
badge, at two cabinets a few metres apart.

The shape now lives in shopdb/utils/badges.py on the contract surface, because
a plugin may not import another plugin and because a badge is a property of the
site rather than of a cabinet. Both kiosks use it, and both store the DIGITS
rather than the string that arrived: one row per spelling makes a custody
history that cannot be joined to itself.

That closed a subtler hole in the same place. str.isdigit() is true for
Arabic-Indic and other decimal forms, and int() parses them, so a badge written
in those characters was accepted, matched a REAL employee, and was then stored
as an SSO string nothing else matches. ASCII digits only. An empty scan and an
unreadable one now get different answers, because they are different problems
for the person at the cabinet.

THE IMAGES. /api/applications/image/<filename> and /api/models/image/<filename>
required a permission, and a browser fetching <img src="..."> sends no
Authorization header - so the gate did not protect the file, it guaranteed the
picture never appeared. Both are ungated now, like the four sibling image routes
(branding logo, map blueprint, employee photo, part picture), and registered in
PUBLIC_ENDPOINTS with that reasoning.

/api/printers/lookup?fqdn= never matched anything: both arguments were compared
against Communication.ipaddress, so a hostname lookup 404d on printers whose
hostname was on the record. An FQDN now matches the hostname, with a fallback to
the short name.

The slide manager gained the per-slide hold time. The PATCH route and the feed
have always carried `seconds`; nothing called it, so the one documented
per-slide control was curl-only.

Docs: API-REFERENCE listed the employee lookups and the whole notifications
surface as unauthenticated when they answer 401, and called both kiosks
"deliberately open" when they are off by default and refuse anything without the
site's device token. The installer download says applications.download, which is
what the code requires. api-inventory.json said printers/lookup was
jwt-optional.

Fourteen tests. The badge rule verified by planting the old per-kiosk behaviour:
three fail, one in each kiosk.

Scope: product
2026-09-03 16:25:08 -04:00

71 lines
2.6 KiB
Python

"""What a scanned badge looks like, in one place.
Two kiosks read the same physical badges and disagreed about them. The parts
kiosk understood both shapes a scanner emits; the USB kiosk took the string
raw and compared it to an SSO, so the wrapped form was simply "not recognized".
An operator whose keyboard-wedge scanner emits `0270015376BZ` could take a
3D-printed part and could not check out a drive - with the same badge, at two
cabinets a few metres apart.
The shapes:
270015376 the SSO as printed, what a keypad entry looks like
0270015376BZ the PayNo wrapper some scanners emit around it
Both mean the same person. Nothing else is a badge - and in particular a string
of NON-ASCII digits is not, however much it looks like one: Arabic-Indic digits
pass Python's str.isdigit(), and a badge written in them was accepted, stored as
the employee's SSO, and joined to nothing afterwards.
This lives in core rather than in either plugin because a plugin may not import
another plugin, and because a badge is a property of the site, not of a
cabinet.
"""
import re
# The PayNo wrapper: a leading zero, the digits, and a BZ suffix.
PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
# ASCII digits only. str.isdigit() is true for Arabic-Indic and other decimal
# forms, and int() happily parses them, so a badge in those characters resolved
# to a real employee and was then written back in a form nothing else matches.
ASCII_DIGITS = re.compile(r'^[0-9]+$')
class BadgeShapeError(ValueError):
"""The scanned string is not a badge this site recognises."""
def normalise_badge(raw):
"""Return the plain SSO digits for a scanned badge.
Accepts the bare number and the PayNo wrapper, and raises BadgeShapeError
for anything else. The caller decides what to do about a badge that is
well-formed but matches no employee - that is a directory question, not a
shape one.
"""
badge = (raw or '').strip()
if not badge:
raise BadgeShapeError('Scan or enter a badge')
if ASCII_DIGITS.match(badge):
return badge
match = PAYNO_BADGE.match(badge)
if match and ASCII_DIGITS.match(match.group(1)):
return match.group(1)
raise BadgeShapeError('Unrecognized badge format')
def badge_kind(raw):
"""'sso' or 'payno' for a recognised badge; raises for anything else.
The external HR lookup searches a different column for each, so the shape
survives normalisation for that one caller.
"""
badge = (raw or '').strip()
if ASCII_DIGITS.match(badge):
return 'sso'
if PAYNO_BADGE.match(badge):
return 'payno'
raise BadgeShapeError('Unrecognized badge format')