Add photo management for models and employees; fix stale detail navigation
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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:
cproudlock
2026-07-11 21:00:37 -04:00
parent 7dae281993
commit 1d21bf0206
19 changed files with 926 additions and 29 deletions

View File

@@ -0,0 +1,149 @@
"""Tests for vendor-model photo upload, replace, delete, and serve.
Covers the core model-image endpoints: admin-gated upload/delete, the public
serve route, one-image-per-model replace semantics, and the guarantee that
deleting a model whose imageurl is an external URL clears the field without
touching the filesystem.
"""
import io
import os
from shopdb.core.models import Model
from shopdb.core.api.models import MODEL_IMAGE_URL_PREFIX
def _make_model(db, imageurl=None):
m = Model(modelnumber='TESTMODEL-1', imageurl=imageurl)
db.session.add(m)
db.session.commit()
return m
def test_upload_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot upload a model image."""
m = _make_model(db)
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', 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_imageurl_and_file_exists(client, db, auth_headers, app):
"""Admin upload writes the served URL and the file lands in the instance dir."""
m = _make_model(db)
payload = b'\x89PNG\r\n\x1a\nfake-png-bytes'
data = {'file': (io.BytesIO(payload), 'photo.png')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
expected_url = f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png'
assert resp.get_json()['data']['imageurl'] == expected_url
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl == expected_url
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
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."""
m = _make_model(db)
data = {'file': (io.BytesIO(b'MZ...'), 'photo.exe')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', 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_model_is_404(client, db, auth_headers):
"""Uploading to a nonexistent model id is a 404."""
data = {'file': (io.BytesIO(b'<svg/>'), 'photo.svg')}
resp = client.post('/api/models/999999/image', 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."""
m = _make_model(db)
first = {'file': (io.BytesIO(b'first'), 'photo.png')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=first,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
oldpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
assert os.path.exists(oldpath)
second = {'file': (io.BytesIO(b'second'), 'photo.jpg')}
resp = client.post(f'/api/models/{m.modelnumberid}/image', data=second,
content_type='multipart/form-data', headers=auth_headers)
assert resp.status_code == 200
# Old .png is gone; new .jpg exists and imageurl points at it.
assert not os.path.exists(oldpath)
newpath = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.jpg')
assert os.path.exists(newpath)
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl == f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.jpg'
def test_serve_returns_bytes(client, db, auth_headers):
"""The public serve route returns the uploaded bytes without auth."""
m = _make_model(db)
payload = b'\x89PNG\r\n\x1a\nserved-bytes'
data = {'file': (io.BytesIO(payload), 'photo.png')}
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.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 imageurl and removes the owned file."""
m = _make_model(db)
data = {'file': (io.BytesIO(b'bytes'), 'photo.png')}
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
content_type='multipart/form-data', headers=auth_headers)
path = os.path.join(app.instance_path, 'modelimages', f'model-{m.modelnumberid}.png')
assert os.path.exists(path)
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['imageurl'] is None
assert not os.path.exists(path)
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl is None
def test_delete_external_url_clears_field_without_filesystem_error(client, db, auth_headers):
"""Delete on a model whose imageurl is an external URL just clears the field."""
m = _make_model(db, imageurl='https://example.com/product.png')
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['imageurl'] is None
refreshed = db.session.get(Model, m.modelnumberid)
assert refreshed.imageurl is None
def test_delete_legacy_path_clears_field_without_filesystem_error(client, db, auth_headers):
"""Delete on a shipped /images/models/* path clears the field, touches no disk."""
m = _make_model(db, imageurl='/images/models/machines/legacy.png')
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['imageurl'] is None
def test_delete_forbidden_for_non_admin(client, db, member_headers):
"""A role-less authenticated user cannot delete a model image."""
m = _make_model(db, imageurl='https://example.com/product.png')
resp = client.delete(f'/api/models/{m.modelnumberid}/image', headers=member_headers)
assert resp.status_code == 403