"""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/', 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 )