From ee80d684d4357dfbdfeeaec068c415c14899f8bb Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 17 Jul 2026 13:29:34 -0400 Subject: [PATCH] Shopfloor feed resolves employee names live when none is stored Photos already resolved through the directory at read time, but names only came from the stored employeename column - empty after a shopdb-only import, so recertification/recognition cards showed bare SSOs. New resolve_employee_display_name in the employees plugin (mode-aware: self-hosted table or external HR) backs a fallback in both the single-card and split-per-employee paths; stored names still win when present. --- plugins/employees/api/routes.py | 30 +++++++++++++++++++++++ plugins/notifications/api/routes.py | 20 +++++++++++++-- tests/test_plugins/test_shopfloor_feed.py | 29 ++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/plugins/employees/api/routes.py b/plugins/employees/api/routes.py index c7d44c2..a65fe10 100644 --- a/plugins/employees/api/routes.py +++ b/plugins/employees/api/routes.py @@ -116,6 +116,36 @@ def _hr_picture(sso): return None +def resolve_employee_display_name(sso): + """Display name ("First Last") for an SSO in either directory mode. + + The shopfloor feed uses this as a live fallback when a notification has + no stored employeename (e.g. imported without the employee source). + None on any miss.""" + if sso is None or not str(sso).isdigit(): + return None + if _selfhosted(): + emp = db.session.get(DirectoryEmployee, int(sso)) + if emp: + return f'{emp.firstname} {emp.lastname}'.strip() or None + return None + try: + conn = employee_connection() + with conn.cursor() as cur: + cur.execute( + 'SELECT First_Name, Last_Name FROM employees WHERE SSO = %s', + (int(sso),)) + row = cur.fetchone() + conn.close() + if row: + first = row.get('First_Name') or '' + last = row.get('Last_Name') or '' + return f'{first.strip()} {last.strip()}'.strip() or None + except Exception: + pass + return None + + def resolve_employee_photo_url(sso, external_picture=None): """Single resolver both consumers share: the display photo URL for an SSO. diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index e0006d7..96166c0 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -149,6 +149,16 @@ def _config_version(): return hashlib.md5('||'.join(parts).encode()).hexdigest()[:12] +def _employee_name(sso): + """Live directory name for an SSO; None on any miss. Used as the fallback + when a notification has no stored employeename (see the shopfloor feed).""" + try: + from plugins.employees.api.routes import resolve_employee_display_name + return resolve_employee_display_name(sso) + except Exception: + return None + + def _employee_picture(sso): """Resolved display photo URL for an SSO, via the shared employees-plugin resolver so kiosk cards match EmployeeDetail in both directory modes @@ -723,7 +733,12 @@ def get_shopfloor_notifications(): result['employeepicture'] = employee_override.get('picture') else: result['employeesso'] = n.employeesso - result['employeename'] = n.employeename + # Stored name first (import/manual entry), else resolve live from + # the directory so shopdb-only imports still show names. + name = n.employeename + if not name and n.employeesso and ',' not in n.employeesso: + name = _employee_name(n.employeesso) + result['employeename'] = name result['employeepicture'] = _employee_picture(n.employeesso) if show_photo else None return result @@ -742,7 +757,8 @@ def get_shopfloor_notifications(): return [ notification_to_shopfloor(n, { 'sso': sso, - 'name': names[i] if i < len(names) else sso, + 'name': (names[i] if i < len(names) and names[i] else None) + or _employee_name(sso) or sso, 'picture': _employee_picture(sso) if show_photo else None, }) for i, sso in enumerate(ssos) diff --git a/tests/test_plugins/test_shopfloor_feed.py b/tests/test_plugins/test_shopfloor_feed.py index 21d8b4a..d9437c1 100644 --- a/tests/test_plugins/test_shopfloor_feed.py +++ b/tests/test_plugins/test_shopfloor_feed.py @@ -70,3 +70,32 @@ def test_single_employee_recognition_stays_single_card(client, db): current = resp.get_json()['data']['current'] assert len(current) == 1 assert current[0]['employeesso'] == '111' + + +def test_shopfloor_names_resolve_live_when_not_stored(client, app, db): + """A notification imported without employeename shows the directory name, + not the bare SSO - single and split-per-employee paths both.""" + from plugins.employees.models import DirectoryEmployee + from plugins.notifications.models import Notification, NotificationType + from shopdb.core.models import Setting + + with app.app_context(): + Setting.set('employee_directory_mode', 'selfhosted', + valuetype='string', category='employees') + db.session.add(DirectoryEmployee( + sso=502000777, firstname='Recert', lastname='Person')) + ntype = NotificationType(typename='Recertification', + typecolor='recertification', + splitperemployee=True) + db.session.add(ntype) + db.session.flush() + db.session.add(Notification( + notificationtypeid=ntype.notificationtypeid, + notification='Recert due', isshopfloor=True, + employeesso='502000777', employeename=None)) + db.session.commit() + + feed = client.get('/api/notifications/shopfloor').get_json()['data'] + cards = feed['current'] + feed['upcoming'] + card = next(c for c in cards if c['notification'] == 'Recert due') + assert card['employeename'] == 'Recert Person'