Files
cproudlock cbdfcbabb7 One badge vocabulary, and images a browser can actually load
Two operator-visible defects and three doc corrections, closing the tail of the
2026-09-03 run.

THE BADGE. The two kiosks read the same physical badges and disagreed. The
parts kiosk understood both shapes a scanner emits - the bare SSO and the PayNo
wrapper 0<digits>BZ - while the USB kiosk compared the raw string to an SSO, so
the wrapper resolved to nobody. An operator whose keyboard-wedge scanner emits
it could take a 3D-printed part and could not check out a drive, with the same
badge, at two cabinets a few metres apart.

The shape now lives in shopdb/utils/badges.py on the contract surface, because
a plugin may not import another plugin and because a badge is a property of the
site rather than of a cabinet. Both kiosks use it, and both store the DIGITS
rather than the string that arrived: one row per spelling makes a custody
history that cannot be joined to itself.

That closed a subtler hole in the same place. str.isdigit() is true for
Arabic-Indic and other decimal forms, and int() parses them, so a badge written
in those characters was accepted, matched a REAL employee, and was then stored
as an SSO string nothing else matches. ASCII digits only. An empty scan and an
unreadable one now get different answers, because they are different problems
for the person at the cabinet.

THE IMAGES. /api/applications/image/<filename> and /api/models/image/<filename>
required a permission, and a browser fetching <img src="..."> sends no
Authorization header - so the gate did not protect the file, it guaranteed the
picture never appeared. Both are ungated now, like the four sibling image routes
(branding logo, map blueprint, employee photo, part picture), and registered in
PUBLIC_ENDPOINTS with that reasoning.

/api/printers/lookup?fqdn= never matched anything: both arguments were compared
against Communication.ipaddress, so a hostname lookup 404d on printers whose
hostname was on the record. An FQDN now matches the hostname, with a fallback to
the short name.

The slide manager gained the per-slide hold time. The PATCH route and the feed
have always carried `seconds`; nothing called it, so the one documented
per-slide control was curl-only.

Docs: API-REFERENCE listed the employee lookups and the whole notifications
surface as unauthenticated when they answer 401, and called both kiosks
"deliberately open" when they are off by default and refuse anything without the
site's device token. The installer download says applications.download, which is
what the code requires. api-inventory.json said printers/lookup was
jwt-optional.

Fourteen tests. The badge rule verified by planting the old per-kiosk behaviour:
three fail, one in each kiosk.

Scope: product
2026-09-03 16:25:08 -04:00

272 lines
8.7 KiB
Python

"""Models (vendor model catalog) API endpoints - Full CRUD."""
import glob
import os
from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from werkzeug.utils import secure_filename
from shopdb.extensions import db
from shopdb.core.models import Model, ModelType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.uploads import send_upload
from shopdb.utils.authz import require_permission
models_bp = Blueprint('models', __name__)
# Uploaded model photos live in the instance dir and are served publicly
# (asset detail pages read the model image without auth). Same image set the
# map/branding uploads accept.
MODEL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
# URL prefix an uploaded image resolves to. imageurl values with this prefix
# are our own files under the instance dir; anything else (external URLs or the
# shipped /images/models/* assets) is left on disk untouched.
MODEL_IMAGE_URL_PREFIX = '/api/models/image/'
def _modelimage_dir():
return os.path.join(current_app.instance_path, 'modelimages')
@models_bp.route('', methods=['GET'])
@require_permission('assets.view')
def list_models():
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
query = Model.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Model.isactive == True)
if vendor_id := request.args.get('vendor', type=int):
query = query.filter(Model.vendorid == vendor_id)
if modeltype_id := request.args.get('modeltype', type=int):
query = query.filter(Model.modeltypeid == modeltype_id)
# Scope to a model-type category (Equipment, PC, Network, Printer) so a
# subtype form only offers models that make sense for it. Models with no
# type are excluded, since a null category cannot satisfy the filter.
if category := request.args.get('category'):
query = query.join(ModelType).filter(ModelType.category == category)
# Exact-match natural-key lookup for idempotent import. Natural key is
# modelnumber + vendor; pair this with ?vendor=<id> to disambiguate.
if exactmodelnumber := request.args.get('modelnumber'):
query = query.filter(Model.modelnumber == exactmodelnumber)
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
query = query.order_by(Model.modelnumber)
items, total = paginate_query(query, page, per_page)
data = []
for m in items:
d = m.to_dict()
d['vendor'] = m.vendor.vendor if m.vendor else None
d['modeltype'] = m.modeltype.modeltype if m.modeltype else None
d['category'] = m.modeltype.category if m.modeltype else None
data.append(d)
return paginated_response(data, page, per_page, total)
@models_bp.route('/<int:model_id>', methods=['GET'])
@require_permission('assets.view')
def get_model(model_id: int):
"""Get a single model."""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
data = m.to_dict()
data['vendor'] = m.vendor.to_dict() if m.vendor else None
data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None
return success_response(data)
@models_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_model():
"""Create a new model."""
data = request.get_json()
if not data or not data.get('modelnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumber is required')
# Check duplicate
existing = Model.query.filter_by(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid')
).first()
if existing:
return error_response(
ErrorCodes.CONFLICT,
f"Model '{data['modelnumber']}' already exists for this vendor",
http_code=409
)
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
modeltypeid=data.get('modeltypeid'),
description=data.get('description'),
imageurl=data.get('imageurl'),
documentationurl=data.get('documentationurl'),
notes=data.get('notes')
)
db.session.add(m)
apply_import_timestamps(m, data)
db.session.commit()
return success_response(m.to_dict(), message='Model created', http_code=201)
@models_bp.route('/<int:model_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_model(model_id: int):
"""Update a model."""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])
apply_import_timestamps(m, data)
db.session.commit()
return success_response(m.to_dict(), message='Model updated')
@models_bp.route('/<int:model_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_model(model_id: int):
"""Delete (deactivate) a model."""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
m.isactive = False
db.session.commit()
return success_response(message='Model deleted')
@models_bp.route('/<int:model_id>/image', methods=['POST'])
@jwt_required()
@require_role('admin')
def upload_model_image(model_id: int):
"""Upload (or replace) the photo for a model.
multipart/form-data: file=<image>. Saves to the instance modelimages dir as
model-<id><ext> (one image per model) and points model.imageurl at the
served URL. Re-upload replaces the old file even when the extension changes.
"""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} 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 MODEL_IMAGE_EXTENSIONS:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'Unsupported image type {ext}')
imagedir = _modelimage_dir()
os.makedirs(imagedir, exist_ok=True)
# Wipe any prior model-<id>.* file so a new extension does not orphan the old
# one (one image per model).
for old in glob.glob(os.path.join(imagedir, secure_filename(f'model-{model_id}') + '.*')):
os.remove(old)
filename = secure_filename(f'model-{model_id}{ext}')
upload.save(os.path.join(imagedir, filename))
m.imageurl = f'{MODEL_IMAGE_URL_PREFIX}{filename}'
db.session.commit()
return success_response(m.to_dict(), message='Model image uploaded')
@models_bp.route('/image/<path:filename>', methods=['GET'])
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_upload(_modelimage_dir(), filename)
@models_bp.route('/<int:model_id>/image', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_model_image(model_id: int):
"""Clear a model image and delete the uploaded file if we own it.
External URLs and the shipped /images/models/* assets are never touched on
disk - only the imageurl field is cleared.
"""
m = db.session.get(Model, model_id)
if not m:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model with ID {model_id} not found',
http_code=404
)
url = m.imageurl or ''
if url.startswith(MODEL_IMAGE_URL_PREFIX):
# secure_filename strips any traversal; the file lives in our dir only.
filename = secure_filename(url[len(MODEL_IMAGE_URL_PREFIX):])
path = os.path.join(_modelimage_dir(), filename)
if os.path.exists(path):
os.remove(path)
m.imageurl = None
db.session.commit()
return success_response(m.to_dict(), message='Model image removed')