Add photo management for models and employees; fix stale detail navigation
Model photos: upload/replace/delete on /api/models/<id>/image (admin), stored under instance/modelimages/ with a public serve route; thumbnail plus Upload/Replace/Remove controls in the Models settings modal; the URL field remains as a manual alternative. Employee photos, mode-aware: self-hosted directory employees get upload/replace/delete (photo-<sso> under instance/employeephotos/, employees plugin migration 0002); external directory mode passes the HR-supplied picture URL through read-only (writes 409). One resolver feeds both consumers - the shopfloor recognition/recert kiosk cards and the employee detail hero - in either mode. Navigation fix: router-view is keyed on route path, so following a relationship link between two assets of the same type (machine -> dualpath machine) reloads the page instead of showing stale content; query-only URL changes still avoid a remount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
212
tests/test_plugins/test_employee_photo.py
Normal file
212
tests/test_plugins/test_employee_photo.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
|
||||
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
|
||||
|
||||
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
|
||||
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
|
||||
directory modes. The wjf_employees HR DB is not available under test, so external
|
||||
resolution is exercised by passing the Picture value directly (no query)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.employees.api.routes import (
|
||||
resolve_employee_photo_url,
|
||||
EMPLOYEE_PHOTO_URL_PREFIX,
|
||||
EMPLOYEE_PHOTO_STATIC_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def _set_mode(db, mode):
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
if row:
|
||||
row.value = mode
|
||||
else:
|
||||
db.session.add(Setting(key='employee_directory_mode', value=mode,
|
||||
valuetype='string', category='site'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _make_employee(db, sso=210000001):
|
||||
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
|
||||
db.session.add(emp)
|
||||
db.session.commit()
|
||||
return emp
|
||||
|
||||
|
||||
# --- upload / replace / delete (self-hosted) --------------------------------
|
||||
|
||||
def test_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app):
|
||||
"""Admin upload writes photofilename + photourl and lands the file on disk."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nfake-png'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
filename = f'photo-{emp.sso}.png'
|
||||
body = resp.get_json()['data']
|
||||
assert body['photofilename'] == filename
|
||||
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == filename
|
||||
|
||||
path = os.path.join(app.instance_path, 'employeephotos', filename)
|
||||
assert os.path.exists(path)
|
||||
with open(path, 'rb') as handle:
|
||||
assert handle.read() == payload
|
||||
|
||||
|
||||
def test_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_upload_missing_employee_is_404(client, db, auth_headers):
|
||||
"""Uploading to a nonexistent SSO is a 404."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/999999/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
|
||||
"""Re-upload with a different extension deletes the prior file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
first = {'file': (io.BytesIO(b'first'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=first,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(oldpath)
|
||||
|
||||
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert not os.path.exists(oldpath)
|
||||
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
|
||||
assert os.path.exists(newpath)
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
|
||||
|
||||
|
||||
def test_serve_returns_bytes(client, db, auth_headers):
|
||||
"""The public serve route returns the uploaded bytes without auth."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nserved'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
|
||||
"""Delete clears photofilename and removes the uploaded file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(path)
|
||||
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename is None
|
||||
|
||||
|
||||
def test_delete_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot delete an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- external-mode 409 ------------------------------------------------------
|
||||
|
||||
def test_upload_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Upload is a 409 when the directory is external (HR owns the photo)."""
|
||||
_set_mode(db, 'external')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/210000001/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
|
||||
|
||||
|
||||
def test_delete_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Delete is a 409 when the directory is external."""
|
||||
_set_mode(db, 'external')
|
||||
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# --- resolver ---------------------------------------------------------------
|
||||
|
||||
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
|
||||
"""Self-hosted: served upload URL when a photo exists, else None."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
assert resolve_employee_photo_url(emp.sso) is None
|
||||
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
|
||||
|
||||
def test_resolver_external_prefixes_relative_and_passes_urls(db):
|
||||
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
|
||||
_set_mode(db, 'external')
|
||||
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
|
||||
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
|
||||
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
|
||||
'https://hr.example.net/p.png'
|
||||
assert resolve_employee_photo_url(210000001, None) is None
|
||||
assert resolve_employee_photo_url(None) is None
|
||||
|
||||
|
||||
def test_lookup_includes_photourl(client, db, auth_headers):
|
||||
"""The lookup serializer carries the resolved photourl (self-hosted)."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
resp = client.get(f'/api/employees/lookup/{emp.sso}')
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
Reference in New Issue
Block a user