Files
shopdb-flask/shopdb/core/api/employees.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

279 lines
10 KiB
Python

"""Read-only employee directory API, backed by the external HR database.
WHY this is core and not a plugin. ShopDB never owns the staff directory: the
rows live in a separate HR database a site points at with EMPLOYEE_DB_*, and
this app only reads them. Several core features already depend on that read -
global search serves an `employee` domain, the audit log resolves an SSO to a
name, the login store fills a display name - and a notification aimed at a
person needs a way to pick that person. A read this many core surfaces depend
on cannot be optional, so it lives here.
Read-only on purpose: no create, update, delete or photo upload. Those belong
to whatever system owns the directory. The only writeable thing in the
neighbourhood is the `employee_photo_base_url` setting, which says where a
relative Picture path resolves.
Every route is gated on `employees.view` (declared in Permission.CORE_PERMISSIONS)
and returns only the columns in _FIELDS.
"""
import logging
from flask import Blueprint, request, current_app
# Core imports core directly, NOT through shopdb.api: the contract surface
# re-exports the two resolvers below, so importing it back from here would be a
# cycle that only works by accident of line order.
from shopdb.core.models import Setting
from shopdb.utils.responses import (success_response, error_response,
ErrorCodes)
from shopdb.utils.authz import require_permission
from shopdb.utils.employee_db import employee_connection
from shopdb.utils.queryargs import intarg
logger = logging.getLogger(__name__)
employees_bp = Blueprint('employees', __name__)
# Columns safe to expose. Anything the HR table holds beyond these stays there.
_FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture'
# Where a bare relative Picture path resolves when employee_photo_base_url is
# unset. A site whose photos live on another host sets that setting to a full
# URL instead.
EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/'
def _external_photo_url(picture):
"""Turn an HR Picture value into a usable URL, or None.
Absolute URLs and already-rooted paths pass through untouched; a bare
relative path (e.g. 'Support/210009518.png') resolves under the configured
base.
"""
if not picture:
return None
text = str(picture).strip()
if not text:
return None
if text.startswith(('http://', 'https://', '/')):
return text
base = (Setting.get('employee_photo_base_url') or '').strip() \
or EMPLOYEE_PHOTO_STATIC_PREFIX
return base.rstrip('/') + '/' + text.lstrip('/')
def _hr_picture(sso):
"""Raw Picture value for an SSO. None on any miss."""
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute('SELECT Picture FROM employees WHERE SSO = %s', (int(sso),))
row = cur.fetchone()
conn.close()
return row.get('Picture') if row else None
except Exception:
return None
def resolve_employee_display_name(sso):
"""Display name ("First Last") for an SSO. None on any miss.
Used as a live fallback where only an SSO was stored - the shopfloor feed
does this for a notification imported without an employee name.
"""
if sso is None or not str(sso).isdigit():
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 ''
name = f'{first.strip()} {last.strip()}'.strip()
if name:
return name
except Exception:
# Logged, not swallowed. This is the branch that fails on a live site -
# an unreachable HR host, a rotated credential, a renamed column - and a
# bare pass made all of those look identical to "no such employee": a
# bare SSO where a name should be, with nothing written down anywhere.
current_app.logger.exception(
'Employee directory lookup failed for SSO %s', sso)
# Fallback: a shopdb login account for this SSO (somebody the directory does
# not carry). Their name lives on the User record; usernames are the SSO.
try:
from shopdb.api import User
user = User.query.filter_by(username=str(sso)).first()
if user:
name = f'{(user.firstname or "").strip()} {(user.lastname or "").strip()}'.strip()
if name:
return name
except Exception:
current_app.logger.exception(
'User-account name fallback failed for SSO %s', sso)
return None
def resolve_employee_photo_url(sso, external_picture=None):
"""Display photo URL for an SSO, or None.
Pass external_picture when the caller already has the row, to avoid a
second query.
"""
if sso is None or not str(sso).isdigit():
return None
picture = external_picture if external_picture is not None else _hr_picture(sso)
return _external_photo_url(picture)
def _with_photo_url(employee):
"""Add the resolved photourl to an employee row dict."""
employee['photourl'] = resolve_employee_photo_url(
employee.get('SSO'), employee.get('Picture'))
return employee
@employees_bp.route('/search', methods=['GET'])
@require_permission('employees.view')
def search_employees():
"""Search the directory by name or SSO.
Query parameters:
- q: search query, at least 2 characters
- limit: max results (default 10, capped at 50)
"""
query = request.args.get('q', '').strip()
limit = min(intarg('limit', 10), 50)
if len(query) < 2:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Search query must be at least 2 characters'
)
# A multi-word query is a FULL NAME. Each word used to be matched against
# first name or last name on its own, so "Dana Reyes" found nobody: no
# single column contains that string. Somebody typing the whole name - what
# a person does before pressing Enter, rather than picking from the list -
# got an empty result and their entry became free text linked to nobody.
words = query.split()
try:
conn = employee_connection()
with conn.cursor() as cur:
if len(words) > 1:
clauses = ' AND '.join(
['(First_Name LIKE %s OR Last_Name LIKE %s)'] * len(words))
params = []
for word in words:
params.extend([f'%{word}%', f'%{word}%'])
params.append(limit)
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE {clauses}
ORDER BY Last_Name, First_Name
LIMIT %s
''', tuple(params))
else:
cur.execute(f'''
SELECT {_FIELDS}
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
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
employees = cur.fetchall()
conn.close()
return success_response([_with_photo_url(e) for e in employees])
except Exception:
logger.exception('Employee search failed')
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup/<sso>', methods=['GET'])
@require_permission('employees.view')
def lookup_employee(sso):
"""Look up a single employee by SSO."""
if not sso.isdigit():
return error_response(ErrorCodes.VALIDATION_ERROR, 'SSO must be numeric')
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(
f'SELECT {_FIELDS} FROM employees WHERE SSO = %s', (int(sso),))
employee = cur.fetchone()
conn.close()
if not employee:
return error_response(
ErrorCodes.NOT_FOUND,
f'Employee with SSO {sso} not found',
http_code=404
)
return success_response(_with_photo_url(employee))
except Exception:
logger.exception('Employee lookup failed for SSO %s', sso)
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)
@employees_bp.route('/lookup', methods=['GET'])
@require_permission('employees.view')
def lookup_employees():
"""Look up several employees at once.
Query parameters:
- sso: comma-separated list of SSOs
"""
sso_list = request.args.get('sso', '')
ssos = [s.strip() for s in sso_list.split(',') if s.strip().isdigit()]
if not ssos:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'At least one valid SSO is required'
)
try:
conn = employee_connection()
with conn.cursor() as cur:
placeholders = ','.join(['%s'] * len(ssos))
cur.execute(
f'SELECT {_FIELDS} FROM employees WHERE SSO IN ({placeholders})',
[int(s) for s in ssos]
)
employees = cur.fetchall()
conn.close()
employees = [_with_photo_url(e) for e in employees]
names = ', '.join(
f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip()
for e in employees
)
return success_response({'employees': employees, 'names': names})
except Exception:
logger.exception('Employee multi-lookup failed')
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Employee lookup failed',
http_code=500
)