"""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 io import logging from flask import Blueprint, request from flask_jwt_extended import jwt_required from shopdb.api import ( db, success_response, error_response, ErrorCodes, employee_connection, require_role, ) from shopdb.core.models 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' 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 @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([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(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 = DirectoryEmployee.query.get(int(sso)) if not emp: return error_response(ErrorCodes.NOT_FOUND, f'Employee with SSO {sso} not found', http_code=404) return success_response(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(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 = [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() 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([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 DirectoryEmployee.query.get(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 = DirectoryEmployee.query.get(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 = DirectoryEmployee.query.get(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 = DirectoryEmployee.query.get(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.')