printedparts: badge resolution honors the employee directory mode
Some checks failed
CI / backend (push) Successful in 1m44s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 8s

The resolver only read the self-hosted directory table, which is empty
at sites running the external HR directory - every kiosk badge fell to
the deny policy. It now branches on employee_directory_mode like the
usb plugin: selfhosted looks up DirectoryEmployee by SSO; external
queries the HR directory via employee_connection, resolving PayNo
badges by their real PayNo column and recovering the employee's SSO.
This commit is contained in:
cproudlock
2026-07-17 10:39:11 -04:00
parent d297c5b75d
commit bb5308bae0
2 changed files with 81 additions and 25 deletions

View File

@@ -385,6 +385,18 @@ cannot carry a JWT header), and the reports (product-wide jwt-optional
convention). Grant `printedparts.view` to the roles that should see the
catalog - admins bypass as always.
## Stage 16b (extension) - badge resolution honors the directory mode
Prod runs the employee directory in EXTERNAL mode (live HR database), where
the self-hosted directoryemployees table is empty - so the original
resolver's every lookup missed and the deny policy blocked the kiosk. The
resolver now branches on the same employee_directory_mode setting the
usb/employees plugins use: selfhosted reads DirectoryEmployee by SSO;
external queries the HR directory via employee_connection() - and resolves
PayNo badges by their actual PayNo column, recovering the real SSO, which
the self-hosted table cannot do. Dual-backend lesson in miniature: a plugin
that resolves people must honor the site's directory mode.
## Stage 16 (extension) - kiosk touch fixes from first hands-on use
First real touchscreen session found two problems worth their own stage:

View File

@@ -8,18 +8,25 @@ cross-plugin imports break the shopdb.api-only contract):
scanners emit this shape)
- anything else -> unresolvable
Names come from the employees plugin's self-hosted directory, looked up by
SSO. The directory carries no PayNo column, so PayNo badges resolve only when
the wrapped digits are themselves the SSO (true at sites whose badges encode
the SSO); otherwise they fall to the unknown-badge policy.
Name lookup honors the site's employee directory mode (the same setting the
employees/usb plugins use):
- selfhosted: the employees plugin's directoryemployees table, by SSO. The
table has no PayNo column, so a PayNo badge only resolves when the wrapped
digits are themselves the SSO.
- external: the HR directory via employee_connection() - SSO badges by SSO,
PayNo badges by PayNo (which also recovers the real SSO to record).
Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
with no directory match; 'allow' records the SSO with an empty name.
"""
import logging
import re
from shopdb.api import Setting
from shopdb.api import Setting, employee_connection
logger = logging.getLogger(__name__)
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
@@ -28,15 +35,57 @@ class BadgeError(ValueError):
"""Raised when a badge cannot be accepted under the site policy."""
def _directory_name(sso):
"""Best-effort display name from the employees plugin directory."""
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')
def _selfhosted_lookup(digits):
"""(sso, name) from the employees plugin directory, or None."""
try:
from plugins.employees.models import DirectoryEmployee
from shopdb.api import db
if sso and str(sso).isdigit():
employee = db.session.get(DirectoryEmployee, int(sso))
employee = db.session.get(DirectoryEmployee, int(digits))
if employee:
return f'{employee.firstname} {employee.lastname}'.strip()
return digits, f'{employee.firstname} {employee.lastname}'.strip()
except Exception:
logger.exception('Self-hosted directory lookup failed for %s', digits)
return None
def _external_lookup(kind, digits):
"""(sso, name) from the HR directory, or None. PayNo badges resolve to
the employee's real SSO."""
try:
conn = employee_connection()
except Exception:
logger.exception('HR directory connection failed')
return None
try:
with conn.cursor() as cursor:
column = 'SSO' if kind == 'sso' else 'PayNo'
cursor.execute(
f'SELECT SSO, First_Name, Last_Name FROM employees '
f'WHERE {column} = %s', (digits,))
row = cursor.fetchone()
if row:
sso = str(row[0] if not isinstance(row, dict) else row['SSO'])
first = row[1] if not isinstance(row, dict) else row['First_Name']
last = row[2] if not isinstance(row, dict) else row['Last_Name']
return sso, f'{(first or "").strip()} {(last or "").strip()}'.strip()
except Exception:
logger.exception('HR directory lookup failed for %s %s', kind, digits)
finally:
try:
conn.close()
except Exception:
pass
return None
@@ -48,22 +97,17 @@ def resolve_badge(badge):
Raises BadgeError with a kiosk-displayable message when the badge shape is
unrecognized or the policy denies an unmatched badge.
"""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
kind, digits = _parse_badge(badge)
if badge.isdigit():
sso = badge
mode = (Setting.get('employee_directory_mode') or 'selfhosted').lower()
if mode == 'external':
resolved = _external_lookup(kind, digits)
else:
match = _PAYNO_BADGE.match(badge)
if not match:
raise BadgeError('Unrecognized badge format')
sso = match.group(1)
resolved = _selfhosted_lookup(digits)
name = _directory_name(sso)
if name is None:
if resolved is None:
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
if policy != 'allow':
raise BadgeError('Badge not recognized - see the parts team')
return sso, ''
return sso, name
return digits, ''
return resolved