"""Employee lookup API endpoints. These read from the separate employee directory DB (employee_connection is core infrastructure exposed via shopdb.api, shared with search + notifications). The endpoints are intentionally reachable by the unauthenticated shopfloor kiosk displays (recognition wall), so they are not JWT-gated; keep them read-only and never return more than the directory fields below. """ import csv import glob import io import logging import os from flask import Blueprint, request, current_app, send_from_directory from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename from shopdb.api import ( db, success_response, error_response, ErrorCodes, employee_connection, require_role, ) from shopdb.api import Setting from ..models import DirectoryEmployee logger = logging.getLogger(__name__) employees_bp = Blueprint('employees', __name__) # Columns safe to expose to the directory/recognition UI _FIELDS = 'SSO, First_Name, Last_Name, Team, Role, Picture' # Uploaded self-hosted employee photos live in the instance dir and are served # publicly (kiosk recognition/recertification cards read them without auth). EMPLOYEE_PHOTO_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} # URL prefix a served upload resolves to (self-hosted mode). EMPLOYEE_PHOTO_URL_PREFIX = '/api/employees/photo/' # Fallback URL prefix external HR relative picture paths resolve under when # the employee_photo_base_url setting is unset. Sites whose photos live on # another host (e.g. the classic EmployeeDBAPP) set the setting to a full URL # such as https://host/EmployeeDBAPP/images/ instead. EMPLOYEE_PHOTO_STATIC_PREFIX = '/static/employees/' def _employeephoto_dir(): return os.path.join(current_app.instance_path, 'employeephotos') def _selfhosted(): """True when the directory is the app-owned table, not an external HR DB.""" row = Setting.query.filter_by(key='employee_directory_mode').first() return (row.value if row and row.value else 'external').lower() == 'selfhosted' def _require_selfhosted(): """Guard for management endpoints - only valid in self-hosted mode.""" if not _selfhosted(): return error_response( ErrorCodes.VALIDATION_ERROR, 'Directory is in external mode; manage people in the source HR database.', http_code=400) return None def _require_selfhosted_photo(): """Guard for photo write endpoints - 409 when the directory is external. In external mode the photo is owned by the HR database (read-only pass-through), so upload/delete cannot apply here.""" if not _selfhosted(): return error_response( ErrorCodes.CONFLICT, 'Employee directory is external; photos are supplied by the HR ' 'database and cannot be uploaded or deleted here.', http_code=409) return None def _external_photo_url(picture): """Turn an external HR Picture value into a usable URL, or None. Absolute URLs and already-rooted paths pass through untouched (future full-URL HR feeds); a bare relative path is served under the static prefix (current WJ convention, e.g. 'Support/210009518.png').""" if not picture: return None text = str(picture).strip() if not text: return None if text.startswith(('http://', 'https://', '/')): return text from shopdb.api import Setting 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 from the external HR directory. None on 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 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 # 1. The employee directory (selfhosted table or external wjf_employees). if _selfhosted(): emp = db.session.get(DirectoryEmployee, int(sso)) if emp: name = f'{emp.firstname} {emp.lastname}'.strip() if name: return name else: 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 lowercase SSO where a name should be, and # no photo, with nothing written down anywhere. current_app.logger.exception( 'Employee directory lookup failed for SSO %s', sso) # 2. Fallback: a shopdb login account for this SSO (e.g. a user from another # location not carried in the directory). Their name lives on the User # record; GE usernames are the SSO. Photo still falls back to the GE mark. 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): """Single resolver both consumers share: the display photo URL for an SSO. Self-hosted: the served upload URL when the employee has an uploaded photo, else None (the legacy Picture text field does not drive the photo here). External: the HR-supplied Picture resolved to a URL (pass external_picture to avoid a re-query), else None. Returns None on any miss or bad SSO.""" if sso is None or not str(sso).isdigit(): return None if _selfhosted(): emp = db.session.get(DirectoryEmployee, int(sso)) if emp and emp.photofilename: return EMPLOYEE_PHOTO_URL_PREFIX + emp.photofilename 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 dict (self-hosted or external).""" employee['photourl'] = resolve_employee_photo_url( employee.get('SSO'), employee.get('Picture')) return employee @employees_bp.route('/resolve/', methods=['GET']) @jwt_required() def resolve_employee(sso): """Resolve one SSO to a name and photo, and say HOW. Built because the failure it diagnoses is invisible. The shopfloor board resolves names and photos through two functions that returned None on any problem, so an unreachable HR host, a rotated credential and a genuinely unknown SSO all produced the same thing: a lowercase SSO where a name should be, and no photo. Nothing distinguished them and nothing was logged. This returns the same answer the board gets, plus which source produced it, which mode the directory is in, and the error when a source failed. It is the difference between "the board is broken" and "the HR host refused the connection at 09:12". """ result = { 'sso': sso, 'mode': 'selfhosted' if _selfhosted() else 'external', 'name': None, 'photourl': None, 'source': None, 'error': None, } if sso is None or not str(sso).isdigit(): result['error'] = 'SSO must be digits' return success_response(result) if _selfhosted(): employee = db.session.get(DirectoryEmployee, int(sso)) if employee: name = '{} {}'.format(employee.firstname or '', employee.lastname or '').strip() if name: result['name'] = name result['source'] = 'directory' else: 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: name = '{} {}'.format((row.get('First_Name') or '').strip(), (row.get('Last_Name') or '').strip()).strip() if name: result['name'] = name result['source'] = 'hrdirectory' else: result['error'] = 'No row in the HR directory for this SSO' except Exception as exception: # Reported, not hidden. This string is the whole point of the # endpoint. result['error'] = '{}: {}'.format(type(exception).__name__, exception) current_app.logger.exception( 'Employee directory lookup failed for SSO %s', sso) if not result['name']: from shopdb.api import User user = User.query.filter_by(username=str(sso)).first() if user: name = '{} {}'.format((user.firstname or '').strip(), (user.lastname or '').strip()).strip() if name: result['name'] = name result['source'] = 'useraccount' result['photourl'] = resolve_employee_photo_url(sso) if result['name'] is None and result['error'] is None: result['error'] = 'No name found in the directory or in a user account' return success_response(result) @employees_bp.route('/search', methods=['GET']) def search_employees(): """ Search employees by name. Query parameters: - q: Search query (searches first and last name) - limit: Max results (default 10) """ query = request.args.get('q', '').strip() limit = min(int(request.args.get('limit', 10)), 50) if len(query) < 2: return error_response( ErrorCodes.VALIDATION_ERROR, 'Search query must be at least 2 characters' ) if _selfhosted(): term = f'%{query}%' rows = (DirectoryEmployee.query .filter(db.or_(DirectoryEmployee.firstname.ilike(term), DirectoryEmployee.lastname.ilike(term), db.cast(DirectoryEmployee.sso, db.String).ilike(term))) .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname) .limit(limit).all()) return success_response([_with_photo_url(e.to_dict()) for e in rows]) try: conn = employee_connection() with conn.cursor() as cur: 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']) 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' ) if _selfhosted(): emp = db.session.get(DirectoryEmployee, int(sso)) if not emp: return error_response(ErrorCodes.NOT_FOUND, f'Employee with SSO {sso} not found', http_code=404) return success_response(_with_photo_url(emp.to_dict())) 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']) def lookup_employees(): """ Look up multiple employees by SSO list. 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' ) if _selfhosted(): rows = DirectoryEmployee.query.filter( DirectoryEmployee.sso.in_([int(s) for s in ssos])).all() employees = [_with_photo_url(e.to_dict()) for e in rows] names = ', '.join(f"{e['First_Name'].strip()} {e['Last_Name'].strip()}" for e in employees) return success_response({'employees': employees, 'names': names}) 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'].strip()} {e['Last_Name'].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 ) # ============================================================================= # Self-hosted directory management (only when directory_mode=selfhosted) # ============================================================================= @employees_bp.route('/directory', methods=['GET']) @jwt_required(optional=True) def list_directory(): """Full self-hosted directory (for the management page).""" guard = _require_selfhosted() if guard: return guard rows = (DirectoryEmployee.query .order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).all()) return success_response([_with_photo_url(e.to_dict()) for e in rows]) def _employee_from_payload(data): """Build kwargs from a payload accepting either external-style (SSO, First_Name...) or plain (sso, firstname...) keys.""" def pick(*keys): for key in keys: if data.get(key) not in (None, ''): return data.get(key) return None return { 'sso': pick('sso', 'SSO'), 'firstname': pick('firstname', 'First_Name'), 'lastname': pick('lastname', 'Last_Name'), 'team': pick('team', 'Team'), 'role': pick('role', 'Role'), 'picture': pick('picture', 'Picture'), } @employees_bp.route('/directory', methods=['POST']) @jwt_required() @require_role('admin') def create_directory_employee(): guard = _require_selfhosted() if guard: return guard fields = _employee_from_payload(request.get_json() or {}) if not (fields['sso'] and fields['firstname'] and fields['lastname']): return error_response(ErrorCodes.VALIDATION_ERROR, 'sso, firstname and lastname are required') try: sso = int(fields['sso']) except (ValueError, TypeError): return error_response(ErrorCodes.VALIDATION_ERROR, 'sso must be numeric') if db.session.get(DirectoryEmployee, sso): return error_response(ErrorCodes.CONFLICT, f'SSO {sso} already exists', http_code=409) emp = DirectoryEmployee(sso=sso, firstname=fields['firstname'], lastname=fields['lastname'], team=fields['team'], role=fields['role'], picture=fields['picture']) db.session.add(emp) db.session.commit() return success_response(emp.to_dict(), message='Employee added', http_code=201) @employees_bp.route('/directory/', methods=['PUT']) @jwt_required() @require_role('admin') def update_directory_employee(sso): guard = _require_selfhosted() if guard: return guard emp = db.session.get(DirectoryEmployee, sso) if not emp: return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) fields = _employee_from_payload(request.get_json() or {}) if fields['firstname']: emp.firstname = fields['firstname'] if fields['lastname']: emp.lastname = fields['lastname'] for key in ('team', 'role', 'picture'): if key in (request.get_json() or {}) or fields[key] is not None: setattr(emp, key, fields[key]) db.session.commit() return success_response(emp.to_dict(), message='Employee updated') @employees_bp.route('/directory/', methods=['DELETE']) @jwt_required() @require_role('admin') def delete_directory_employee(sso): guard = _require_selfhosted() if guard: return guard emp = db.session.get(DirectoryEmployee, sso) if not emp: return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) db.session.delete(emp) db.session.commit() return success_response(message='Employee removed') @employees_bp.route('/directory/import', methods=['POST']) @jwt_required() @require_role('admin') def import_directory(): """Bulk upsert from CSV. Accepts headers SSO,First_Name,Last_Name,Team,Role, Picture (case-insensitive; sso/firstname/... also accepted).""" guard = _require_selfhosted() if guard: return guard text = '' if 'file' in request.files: text = request.files['file'].read().decode('utf-8-sig', errors='replace') else: data = request.get_json(silent=True) or {} text = data.get('csv', '') if not text.strip(): return error_response(ErrorCodes.VALIDATION_ERROR, 'No CSV provided') reader = csv.DictReader(io.StringIO(text)) # Normalize headers to lower for tolerant matching. added = updated = skipped = 0 for raw in reader: row = {(k or '').strip().lower(): (v or '').strip() for k, v in raw.items()} sso_raw = row.get('sso') or row.get('sso ') first = row.get('first_name') or row.get('firstname') last = row.get('last_name') or row.get('lastname') if not (sso_raw and sso_raw.isdigit() and first and last): skipped += 1 continue sso = int(sso_raw) team = row.get('team') or None role = row.get('role') or None picture = row.get('picture') or None emp = db.session.get(DirectoryEmployee, sso) if emp: emp.firstname, emp.lastname, emp.team, emp.role, emp.picture = first, last, team, role, picture updated += 1 else: db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last, team=team, role=role, picture=picture)) added += 1 db.session.commit() return success_response({'added': added, 'updated': updated, 'skipped': skipped}, message=f'Import done: {added} added, {updated} updated, {skipped} skipped.') # ============================================================================= # Self-hosted employee photos (upload/replace/delete + public serve) # ============================================================================= @employees_bp.route('//photo', methods=['POST']) @jwt_required() @require_role('admin') def upload_employee_photo(sso): """Upload (or replace) the photo for a self-hosted directory employee. multipart/form-data: file=. Saves to the instance employeephotos dir as photo- (one photo per person) and points photofilename at it. Re-upload replaces the old file even when the extension changes. External mode is a 409 (photo is owned by the HR database).""" guard = _require_selfhosted_photo() if guard: return guard emp = db.session.get(DirectoryEmployee, sso) if not emp: return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) upload = request.files.get('file') if not upload or not upload.filename: return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided') ext = os.path.splitext(upload.filename)[1].lower() if ext not in EMPLOYEE_PHOTO_EXTENSIONS: return error_response(ErrorCodes.VALIDATION_ERROR, f'Unsupported image type {ext}') photodir = _employeephoto_dir() os.makedirs(photodir, exist_ok=True) # Wipe any prior photo-.* so a new extension does not orphan the old one. for old in glob.glob(os.path.join(photodir, secure_filename(f'photo-{sso}') + '.*')): os.remove(old) filename = secure_filename(f'photo-{sso}{ext}') upload.save(os.path.join(photodir, filename)) emp.photofilename = filename db.session.commit() return success_response(_with_photo_url(emp.to_dict()), message='Employee photo uploaded') @employees_bp.route('/photo/', methods=['GET']) def serve_employee_photo(filename): """Serve an uploaded employee photo (public - kiosk cards read it).""" return send_from_directory(_employeephoto_dir(), filename) @employees_bp.route('//photo', methods=['DELETE']) @jwt_required() @require_role('admin') def delete_employee_photo(sso): """Clear an employee photo and delete the uploaded file. External mode 409s.""" guard = _require_selfhosted_photo() if guard: return guard emp = db.session.get(DirectoryEmployee, sso) if not emp: return error_response(ErrorCodes.NOT_FOUND, 'Employee not found', http_code=404) if emp.photofilename: # secure_filename strips any traversal; the file lives in our dir only. path = os.path.join(_employeephoto_dir(), secure_filename(emp.photofilename)) if os.path.exists(path): os.remove(path) emp.photofilename = None db.session.commit() return success_response(_with_photo_url(emp.to_dict()), message='Employee photo removed')