The shopfloor board lost every photo and started showing lowercase SSOs where names belong, and nothing in the system could say why. Both resolvers returned None on any problem and three separate bare excepts threw the cause away, so an unreachable HR host, a rotated credential, a renamed column and a genuinely unknown SSO all produced identical output and no log line. That is not a bug in the resolution so much as a hole where the diagnosis should be. GET /api/employees/resolve/<sso> returns the same answer the board gets, plus which source produced it (directory, hrdirectory, useaccount), which mode the directory is in, and the exception text when a source failed. It is the difference between "the board is broken" and "the HR host refused the connection". The two bare excepts in the shared resolvers now log rather than pass. The external-directory branch is the one that fails on a live site; it was the one saying nothing. No behaviour change to the board itself - it still falls back exactly as before. What changed is that the fallback is now visible.
641 lines
24 KiB
Python
641 lines
24 KiB
Python
"""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/<sso>', 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/<sso>', 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/<int:sso>', 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/<int:sso>', 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('/<int:sso>/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=<image>. Saves to the instance employeephotos dir
|
|
as photo-<sso><ext> (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-<sso>.* 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/<path:filename>', 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('/<int:sso>/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')
|