Files
shopdb-flask/plugins/employees
cproudlock afce24379c
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m42s
Reading anything needs a permission
A sweep of the URL map found 165 GET routes that answered anybody, with no
token at all: the whole asset inventory, every machine, PC, printer and network
device, the USB checkout history and who is holding which drive, the staff
directory with its photographs, every core report. Guest access had been argued
about one feature-flag at a time while the front door stood open.

Each of those now carries the read permission it belongs to. That also makes
the guest role mean something: a site can open exactly the reads it chooses,
and nothing follows by accident.

Thirty-three endpoints stay public, and each is named with its reason rather
than left to be inferred: the app shell and its bootstrap, the settings
allowlist (the handler already filters an anonymous caller down to branding and
a few keys), images fetched by <img src> which carries no Authorization header,
what a display kiosk renders before anybody signs in, the parts and USB kiosks,
the client scripts that run as SYSTEM before a user session exists, and a
secure-send link whose unguessable token IS the authorisation.

tests/test_core/test_public_read_surface.py is the standing gate, the companion
to the mutating-route sweep that has guarded writes for months. A new public
read fails CI until somebody adds it to that list in front of a reviewer. Four
further reads need a login but no permission, because the handler decides from
who you are - your own user record, your own API tokens, the role catalogue the
grid renders - and the same test proves each still refuses an anonymous caller.
Verified not vacuous by ungating one route and watching the sweep name it.

The employees plugin gained employees.view; the directory is names, SSOs,
photographs and reporting lines, and it had no permission of any kind.

Around ninety tests asserted payload shapes against endpoints that answered
anybody. They are about what the answer says, not about who may ask, so they
are signed in now rather than the gate being loosened. The ones that assert an
anonymous caller is refused were left exactly as they were.
2026-08-27 14:26:06 -04:00
..
2026-08-27 14:26:06 -04:00
2026-08-27 14:26:06 -04:00

Employees plugin - directory database contract

Read-only lookups against a separate employee/HR directory database. Powers:

  • Employee search (add people to a notification, browse the directory)
  • Single + batch SSO lookup
  • Recognition notifications (name + photo on the shopfloor / lobby displays)

The plugin never writes to this database. Use a read-only account.

This is the integration most likely to differ per site. HR / directory systems vary widely, so expect to map a site's schema to the contract below - the CREATE VIEW recipe at the end is the normal way to do it.

Connection

Credentials resolve settings-first, then environment, except the password which is env-only (never stored in the app database).

Field Setting key (editable in the setup wizard) Env var (fallback)
Host employee_db_host EMPLOYEE_DB_HOST
Database employee_db_name EMPLOYEE_DB_NAME
User employee_db_user EMPLOYEE_DB_USER
Password (not stored) EMPLOYEE_DB_PASSWORD

Set the password in .env; the setup wizard's Features step shows the exact line to paste. Engine is MySQL/MariaDB (pymysql).

Required schema

The plugin runs these queries verbatim, so a site's database must expose a table (or view - see below) named employees with these columns:

Column Type Notes
SSO INT Unique person id. Lookups require it to be numeric
First_Name VARCHAR Displayed as the given name
Last_Name VARCHAR Displayed as the surname; sort key
Team VARCHAR Team / group label
Role VARCHAR Job title / role
Picture VARCHAR Image filename (see Photos)

Queries actually executed:

-- search
SELECT SSO, First_Name, Last_Name, Team, Role, Picture
FROM employees
WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name LIMIT %s;

-- single / batch
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO = %s;
SELECT SSO, First_Name, Last_Name, Team, Role, Picture FROM employees WHERE SSO IN (...);

Rows are returned to the API with these exact column names. The frontend (EmployeeSearch, NotificationForm, EmployeeDetail, ShopfloorDashboard) reads SSO, First_Name, Last_Name, Team, Role, Picture as-is - do not rename them in the response.

Photos

Picture holds an image filename (e.g. 123456.jpg), not a path or blob. The app renders it as /static/employees/<Picture>, so the image files must live in the app's static/employees/ directory. Leave Picture empty/NULL for people with no photo; the UI falls back to initials.

Two ways to provide the directory

Option A - map an existing HR/directory database (see the view recipe below). Use this when the site already has a system of record for people.

Option B - self-hosted directory (managed in-app) for sites with no HR database. Set employee_directory_mode to selfhosted and manage people under Settings > Employee Directory (add / edit / delete + CSV import) - no SQL needed. The app owns a directoryemployees table (migration 7d16); the lookup APIs read it automatically in this mode.

If you would rather load it with SQL, the canonical table is equivalent to:

CREATE DATABASE shopdb_directory CHARACTER SET utf8mb4;
USE shopdb_directory;

CREATE TABLE employees (
  SSO         INT          NOT NULL PRIMARY KEY,
  First_Name  VARCHAR(100) NOT NULL,
  Last_Name   VARCHAR(100) NOT NULL,
  Team        VARCHAR(100) NULL,
  Role        VARCHAR(100) NULL,
  Picture     VARCHAR(255) NULL
);

-- add people (or bulk-load from CSV with LOAD DATA INFILE)
INSERT INTO employees (SSO, First_Name, Last_Name, Team, Role, Picture)
VALUES (123456, 'Jane', 'Doe', 'Inspection', 'Quality Tech', '123456.jpg');

Put photo files (named as in Picture) under the app's static/employees/. In self-hosted mode the directoryemployees table lives in the main app DB, so no separate database is required.

Sites whose HR/directory database uses different table or column names should not be forced to rename anything. Instead, create a read-only view named employees that maps local columns to the names above:

CREATE VIEW employees AS
SELECT
  person_id        AS SSO,
  given_name       AS First_Name,
  surname          AS Last_Name,
  department       AS Team,
  job_title        AS Role,
  photo_filename   AS Picture
FROM hr_people
WHERE active = 1;

Grant the app's read-only user SELECT on the view. No app code changes - point employee_db_* at that database and the plugin works.

Notes:

  • SSO must be numeric (single/batch lookup validate isdigit()).
  • Column-name case follows your database's identifier casing; match the names above exactly on case-sensitive platforms.
  • If the directory is unreachable or the employees object is missing, lookups return a 500 and the rest of the app keeps working (the feature degrades, it does not crash the app).