Both kiosks admit a check-in or check-out with no login when the caller sits in a configured network. The subtraction that makes that safe was missing. A reverse proxy normally sits ON the plant network a site enters in the allowlist. When the proxy forwards nothing - misconfigured, or something bypassed it - the request arrives wearing the proxy's own address, which matches the allowlist, while carrying no evidence whatever about who sent it. That is the one case that has to refuse, and it was being admitted. The previous fail-closed test only looked closed because the proxy in it happened to sit outside the range. Addresses that belong to the path are now subtracted before the allowlist is consulted: loopback always, since no site's kiosk is the server itself, plus whatever the site declares as its proxies. USB and printedparts get the same treatment - they had diverged for no reason anybody chose. Three more, same surface: A range that admits everybody can no longer be saved by accident. 0.0.0.0/0 and ::/0 are refused outright, anything wider than a site plausibly needs is refused unless an explicit override is set, and the refusal says why. The kiosk token no longer travels in a URL. The provisioning link carries a single-use setup code redeemed on first open, so the token stays out of browser history, referrers and proxy logs. The route is declared public deliberately: the secret is the code, the way a password-reset link is the secret. The badge-to-name lookup is bounded. It answers without a login and is now reachable from a whole network rather than one screen, so a source that keeps missing gets refused rather than being allowed to walk the directory. On by default, budget and kill switch are settings. All four new settings carry controls on their settings pages. A setting that can stop the kiosk, or widen it, must not be reachable only by hand-editing rows. The installer's client-IP advice is corrected: it described the opposite of what the shipped waitress flags do, and named only GE-Enforce as the thing that breaks when the rule is absent, when the two kiosk allowlists depend on it too.
959 lines
40 KiB
Python
959 lines
40 KiB
Python
"""Printedparts plugin API routes.
|
|
|
|
Access model: browsing the catalog (items, detail, file listings) requires
|
|
the printedparts.view permission; every mutation carries its own permission.
|
|
Deliberately open: the kiosk endpoints (decision record in the proposal),
|
|
the image serve and file download (fetched by <img> tags and anchor
|
|
downloads, which cannot carry a JWT header), and the reports (jwt-optional
|
|
like every other report in the product).
|
|
"""
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
from sqlalchemy import or_
|
|
|
|
from shopdb.api import (
|
|
db,
|
|
success_response,
|
|
error_response,
|
|
paginated_response,
|
|
ErrorCodes,
|
|
get_pagination_params,
|
|
paginate_query,
|
|
require_permission,
|
|
)
|
|
|
|
from ..models import PrintedItem
|
|
|
|
printedparts_bp = Blueprint('printedparts', __name__)
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def list_items():
|
|
"""List printed items, paginated; search + low-stock filter."""
|
|
page, per_page = get_pagination_params(request)
|
|
query = PrintedItem.query
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(PrintedItem.isactive == True)
|
|
if search := request.args.get('search'):
|
|
like = f'%{search}%'
|
|
query = query.filter(or_(
|
|
PrintedItem.itemcode.ilike(like),
|
|
PrintedItem.gagelabtag.ilike(like),
|
|
PrintedItem.itemname.ilike(like),
|
|
PrintedItem.itemdescription.ilike(like),
|
|
PrintedItem.binlocation.ilike(like),
|
|
))
|
|
if request.args.get('lowstock', '').lower() == 'true':
|
|
query = query.filter(
|
|
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
|
|
query = query.order_by(PrintedItem.itemname)
|
|
items, total = paginate_query(query, page, per_page)
|
|
return paginated_response(
|
|
[item.to_dict() for item in items], page, per_page, total)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def get_item(item_id: int):
|
|
"""Get one printed item with its recent transactions."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found',
|
|
http_code=404)
|
|
data = item.to_dict()
|
|
recent = (item.transactions
|
|
.order_by(db.desc('transactiondate'))
|
|
.limit(25).all())
|
|
data['recenttransactions'] = [t.to_dict() for t in recent]
|
|
return success_response(data)
|
|
|
|
|
|
# --- catalog mutations (stage 6 adds permission gates on top of jwt) --------
|
|
|
|
import glob
|
|
import os
|
|
import re
|
|
|
|
from flask import current_app
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from shopdb.api import Setting, client_ip, ip_in_cidrs
|
|
|
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
|
|
|
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
|
|
'binlocation', 'printnotes')
|
|
|
|
|
|
def _imagedir():
|
|
return os.path.join(current_app.instance_path, 'printedpartsimages')
|
|
|
|
|
|
def _mint_itemcode(item):
|
|
"""Set itemcode from the configured prefix + the flushed row id."""
|
|
prefix = Setting.get('printedparts_code_prefix') or '3DP'
|
|
item.itemcode = f'{prefix}{item.printeditemid:04d}'
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['POST'])
|
|
@require_permission('printedparts.create')
|
|
def create_item():
|
|
"""Create a printed item.
|
|
|
|
The internal itemcode is always auto-minted; the OPTIONAL gagelabtag
|
|
carries the gage lab's assigned WJRP asset number (unique-checked)."""
|
|
data = request.get_json() or {}
|
|
itemname = (data.get('itemname') or '').strip()
|
|
if not itemname:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
|
|
|
|
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
|
|
if gagelabtag and PrintedItem.query.filter_by(gagelabtag=gagelabtag).first():
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f'Gage lab tag {gagelabtag} is already in use',
|
|
http_code=409)
|
|
|
|
threshold = data.get('lowstockthreshold')
|
|
if threshold is None:
|
|
threshold = int(Setting.get('printedparts_default_threshold') or 5)
|
|
|
|
item = PrintedItem(
|
|
itemname=itemname,
|
|
gagelabtag=gagelabtag or None,
|
|
itemdescription=data.get('itemdescription'),
|
|
lowstockthreshold=threshold,
|
|
binlocation=data.get('binlocation'),
|
|
printnotes=data.get('printnotes'),
|
|
quantityonhand=0,
|
|
)
|
|
db.session.add(item)
|
|
db.session.flush() # assigns printeditemid
|
|
_mint_itemcode(item)
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item created',
|
|
http_code=201)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
|
@require_permission('printedparts.edit')
|
|
def update_item(item_id: int):
|
|
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
if 'quantityonhand' in data:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
'quantityonhand is ledger-managed; use restock or adjust')
|
|
if 'gagelabtag' in data:
|
|
gagelabtag = (data.get('gagelabtag') or '').strip().upper()
|
|
if gagelabtag:
|
|
clash = PrintedItem.query.filter(
|
|
PrintedItem.gagelabtag == gagelabtag,
|
|
PrintedItem.printeditemid != item.printeditemid).first()
|
|
if clash:
|
|
return error_response(
|
|
ErrorCodes.CONFLICT,
|
|
f'Gage lab tag {gagelabtag} is already in use',
|
|
http_code=409)
|
|
item.gagelabtag = gagelabtag or None
|
|
for field in EDITABLE_FIELDS:
|
|
if field in data:
|
|
setattr(item, field, data[field])
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item updated')
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
|
@require_permission('printedparts.delete')
|
|
def delete_item(item_id: int):
|
|
"""Soft-retire an item; its ledger history stays."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
item.isactive = False
|
|
db.session.commit()
|
|
return success_response(message='Printed item retired')
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
|
|
@require_permission('printedparts.delete')
|
|
def restore_item(item_id: int):
|
|
"""Bring a retired item back; code, photo, and history are intact."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
item.isactive = True
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item restored')
|
|
|
|
|
|
# --- item image: the models.py upload/serve/delete trio ---------------------
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
|
@require_permission('printedparts.edit')
|
|
def upload_item_image(item_id: int):
|
|
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_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 IMAGE_EXTENSIONS:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
f'Unsupported image type {ext}')
|
|
|
|
imagedir = _imagedir()
|
|
os.makedirs(imagedir, exist_ok=True)
|
|
for old in glob.glob(os.path.join(
|
|
imagedir, secure_filename(f'printeditem-{item_id}') + '.*')):
|
|
os.remove(old)
|
|
|
|
filename = secure_filename(f'printeditem-{item_id}{ext}')
|
|
upload.save(os.path.join(imagedir, filename))
|
|
item.imageurl = f'{IMAGE_URL_PREFIX}{filename}'
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Item image uploaded')
|
|
|
|
|
|
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
|
|
def serve_item_image(filename):
|
|
"""Serve an uploaded item image (public - kiosk and list read it)."""
|
|
return send_upload(_imagedir(), filename)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
|
@require_permission('printedparts.delete')
|
|
def delete_item_image(item_id: int):
|
|
"""Clear an item image; delete the file only if this plugin owns it."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
url = item.imageurl or ''
|
|
if url.startswith(IMAGE_URL_PREFIX):
|
|
filename = secure_filename(url[len(IMAGE_URL_PREFIX):])
|
|
path = os.path.join(_imagedir(), filename)
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
item.imageurl = None
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Item image removed')
|
|
|
|
|
|
# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock)
|
|
|
|
from ..models import PrintedItemTransaction
|
|
from ..services.badges import BadgeError, resolve_badge
|
|
|
|
|
|
class InsufficientStock(Exception):
|
|
"""A decrement was refused because the row no longer holds that many.
|
|
|
|
Carries the quantity the DATABASE holds, not the stale one the caller read,
|
|
so the message the operator sees is the truth at the moment of the write.
|
|
"""
|
|
|
|
def __init__(self, available):
|
|
super().__init__('insufficient stock')
|
|
self.available = available
|
|
|
|
|
|
def _move_quantity(item, quantitychange):
|
|
"""Move quantityonhand by quantitychange in ONE statement, and let the row
|
|
itself refuse a decrement it cannot cover.
|
|
|
|
This is the guard, not the caller's earlier `quantity > item.quantityonhand`
|
|
check. That check reads, decides in Python, and writes - so two kiosk takes
|
|
that interleave both read the same on-hand figure and both pass it. Five
|
|
concurrent takes of 5 from a bin holding 5 all succeeded, each wrote its own
|
|
5 - 5 = 0, and quantityonhand ended up permanently out of step with the
|
|
ledger that recorded all five.
|
|
|
|
Expressing it as `UPDATE ... WHERE quantityonhand >= :n` makes the database
|
|
do the comparing at write time, where it is atomic. A row that no longer
|
|
holds enough matches nothing, rowcount is 0, and the take is refused.
|
|
Returns the resulting quantity.
|
|
"""
|
|
query = PrintedItem.query.filter(
|
|
PrintedItem.printeditemid == item.printeditemid)
|
|
if quantitychange < 0:
|
|
query = query.filter(PrintedItem.quantityonhand >= -quantitychange)
|
|
moved = query.update(
|
|
{PrintedItem.quantityonhand:
|
|
PrintedItem.quantityonhand + quantitychange},
|
|
synchronize_session=False)
|
|
if not moved:
|
|
# Read back what is actually there so the refusal can say so.
|
|
db.session.rollback()
|
|
available = db.session.query(PrintedItem.quantityonhand).filter(
|
|
PrintedItem.printeditemid == item.printeditemid).scalar() or 0
|
|
raise InsufficientStock(available)
|
|
db.session.refresh(item)
|
|
return item.quantityonhand
|
|
|
|
|
|
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
|
|
revision=None):
|
|
"""Append a ledger row and move the cached quantity in ONE commit.
|
|
|
|
The single-commit invariant is what keeps quantityonhand equal to the
|
|
ledger sum; every write path must go through here. Fires the low-stock
|
|
alert when this write CROSSES the item's threshold downward - crossing
|
|
(not being below) is the natural debounce: one alert per depletion, and
|
|
restocking above the threshold rearms it.
|
|
|
|
Raises InsufficientStock when the row cannot cover the decrement. The
|
|
caller has already refused the obvious case; this is the one that only
|
|
shows up under concurrency.
|
|
"""
|
|
# Derived from what the write ACTUALLY produced, not from what this request
|
|
# read earlier. Under concurrency the two differ, and the threshold
|
|
# crossing has to be judged on the real numbers or the alert fires for the
|
|
# wrong take (or not at all).
|
|
quantityafter = _move_quantity(item, quantitychange)
|
|
quantitybefore = quantityafter - quantitychange
|
|
db.session.add(PrintedItemTransaction(
|
|
printeditemid=item.printeditemid,
|
|
transactiontype=transactiontype,
|
|
quantitychange=quantitychange,
|
|
employeesso=sso,
|
|
employeename=name,
|
|
reason=reason,
|
|
revision=revision,
|
|
))
|
|
db.session.commit()
|
|
if (quantitychange < 0
|
|
and quantitybefore > item.lowstockthreshold
|
|
and item.quantityonhand <= item.lowstockthreshold):
|
|
_send_lowstock_alert(item)
|
|
|
|
|
|
def _alert_recipients():
|
|
"""Merge selected shopdb users' account emails with the free-text list.
|
|
|
|
Empty result means fall back to the site-wide alert_recipients."""
|
|
from shopdb.api import User, Role
|
|
recipients = []
|
|
userids = (Setting.get('printedparts_alert_userids') or '').strip()
|
|
for rawid in userids.split(','):
|
|
rawid = rawid.strip()
|
|
if not rawid.isdigit():
|
|
continue
|
|
user = db.session.get(User, int(rawid))
|
|
if user and user.isactive and user.email:
|
|
recipients.append(user.email)
|
|
roleids = (Setting.get('printedparts_alert_roleids') or '').strip()
|
|
for rawid in roleids.split(','):
|
|
rawid = rawid.strip()
|
|
if not rawid.isdigit():
|
|
continue
|
|
role = db.session.get(Role, int(rawid))
|
|
if role:
|
|
recipients.extend(member.email for member in role.users
|
|
if member.isactive and member.email)
|
|
extra = (Setting.get('printedparts_alert_email') or '').strip()
|
|
recipients.extend(address.strip() for address in extra.split(',')
|
|
if address.strip())
|
|
# dedupe, order-preserving
|
|
return list(dict.fromkeys(recipients))
|
|
|
|
|
|
def _send_lowstock_alert(item):
|
|
"""Best-effort email when an item crosses its low-stock threshold.
|
|
|
|
Recipients: Setting printedparts_alert_email (comma-separated), falling
|
|
back to the site's alert_recipients. Never fails the transaction - the
|
|
ledger write already committed."""
|
|
from shopdb.api import send_email, send_webhook, Setting
|
|
code = item.gagelabtag or item.itemcode
|
|
subject = (f'Low stock: {item.itemname} ({code}) - '
|
|
f'{item.quantityonhand} left')
|
|
# Absolute link to the item page when the site base URL is configured
|
|
# (e.g. https://<host>/ops). Emails have no request context to derive it.
|
|
base = (Setting.get('site_base_url') or '').rstrip('/')
|
|
item_url = f'{base}/printedparts/{item.printeditemid}' if base else ''
|
|
link_html = f'<p><a href="{item_url}">View {code}</a></p>' if item_url else ''
|
|
html = (f'<p><strong>{item.itemname}</strong> ({code}) is down '
|
|
f'to <strong>{item.quantityonhand}</strong> '
|
|
f'(threshold {item.lowstockthreshold}).</p>'
|
|
f'<p>Bin: {item.binlocation or "-"}</p>'
|
|
f'{link_html}'
|
|
f'<p>Time to print more.</p>')
|
|
webhook_text = (f'**{item.itemname}** ({code}) is down to '
|
|
f'{item.quantityonhand} (threshold {item.lowstockthreshold}). '
|
|
f'Bin: {item.binlocation or "-"}.'
|
|
+ (f' [View]({item_url})' if item_url else ''))
|
|
try:
|
|
# Webhook: the selected support team's own webhook, else the site
|
|
# default (send_webhook falls back to alert_webhook_url when url=None).
|
|
send_webhook(subject, webhook_text, url=_alert_team_webhook())
|
|
# Email: this plugin's recipients, else the site's alert_recipients.
|
|
recipients = _alert_recipients() or _site_alert_recipients()
|
|
if recipients:
|
|
send_email(recipients, subject, html)
|
|
except Exception:
|
|
import logging
|
|
logging.getLogger(__name__).exception(
|
|
'Low-stock alert failed for %s', item.itemcode)
|
|
|
|
|
|
def _site_alert_recipients():
|
|
"""Site-wide alert_recipients setting as a clean list (email fallback)."""
|
|
from shopdb.api import Setting
|
|
raw = Setting.get('alert_recipients') or ''
|
|
return [r.strip() for r in raw.replace(';', ',').split(',') if r.strip()]
|
|
|
|
|
|
def _alert_team_webhook():
|
|
"""Webhook URL of the support team chosen for printedparts alerts, or None.
|
|
|
|
printedparts_alert_supportteamid selects a SupportTeam; alerts route to that
|
|
team's webhook. None -> send_webhook uses the site-wide default."""
|
|
from shopdb.api import Setting
|
|
team_id = Setting.get('printedparts_alert_supportteamid')
|
|
if not team_id:
|
|
return None
|
|
try:
|
|
from shopdb.api import SupportTeam
|
|
team = db.session.get(SupportTeam, int(team_id))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return (team.webhookurl or None) if team else None
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
|
@require_permission('printedparts.restock')
|
|
def restock_item(item_id: int):
|
|
"""Add freshly printed stock. Body: {quantity, badge}."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item or not item.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
quantity = data.get('quantity')
|
|
if not isinstance(quantity, int) or quantity < 1:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'quantity must be a positive integer')
|
|
try:
|
|
sso, name = resolve_badge(data.get('badge'))
|
|
except BadgeError as badge_error:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
|
http_code=422)
|
|
_ledger_write(item, 'restock', quantity, sso, name)
|
|
return success_response(item.to_dict(), message='Stock added')
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
|
@require_permission('printedparts.restock')
|
|
def adjust_item(item_id: int):
|
|
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item or not item.isactive:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_id} not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
quantitychange = data.get('quantitychange')
|
|
if not isinstance(quantitychange, int) or quantitychange == 0:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'quantitychange must be a non-zero integer')
|
|
reason = (data.get('reason') or '').strip()
|
|
if not reason:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'reason is required for an adjustment')
|
|
if item.quantityonhand + quantitychange < 0:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Adjustment would drive stock below zero '
|
|
f'(on hand: {item.quantityonhand})')
|
|
try:
|
|
sso, name = resolve_badge(data.get('badge'))
|
|
except BadgeError as badge_error:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
|
http_code=422)
|
|
try:
|
|
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
|
except InsufficientStock as shortfall:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Adjustment would drive stock below zero '
|
|
f'(on hand: {shortfall.available})')
|
|
return success_response(item.to_dict(), message='Stock adjusted')
|
|
|
|
|
|
# --- kiosk: off by default, bound to a provisioned device token ---------------
|
|
# The take endpoint is the product's only open WRITE. The original decision
|
|
# record justified that with "physically rate-limited: it serves a touch screen
|
|
# on the shop floor". Nothing in code enforced the physical part, and an HTTP
|
|
# route is reachable from anywhere the app is, so the justification did not
|
|
# survive contact with a scanner.
|
|
#
|
|
# What was actually reachable with no credential: bare digits in
|
|
# _kiosk_find_item enumerate the catalogue without knowing a barcode, a badge is
|
|
# a short numeric SSO, and this route answered 422 for an unknown badge and 200
|
|
# for a known one - an oracle for finding real ones. The write then decremented
|
|
# stock and stamped the ledger, which is the audit trail, with whichever SSO the
|
|
# caller supplied.
|
|
#
|
|
# So a badge is ATTRIBUTION, never authorization. Both kiosk routes now require
|
|
# the kiosk to be switched on for this site AND the device token an administrator
|
|
# provisioned. The read is gated too, not just the write: leaving it open would
|
|
# keep the enumeration oracle running against a kiosk that is supposedly off.
|
|
#
|
|
# The token is per-DEVICE, not per-page. A kiosk is a browser on a touch screen,
|
|
# so a token embedded in the served page would be readable by anyone who can
|
|
# load it. An admin opens the kiosk once with ?token=..., the page keeps it in
|
|
# that browser's localStorage and sends it as X-Kiosk-Token thereafter.
|
|
|
|
import hmac
|
|
|
|
from shopdb.api import audit_log
|
|
|
|
# Largest single take the kiosk records. A shop-floor grab is single digits; 25
|
|
# leaves real use untouched and denies draining a bin in one request.
|
|
KIOSK_MAX_TAKE_QUANTITY = 25
|
|
|
|
# Addresses that belong to the PATH, not to a caller. Subtracted from the
|
|
# allowlist before it is consulted.
|
|
#
|
|
# WHY, and it is the half that was missing here: a reverse proxy normally sits
|
|
# ON the network a site enters in printedparts_kiosk_allowed_cidrs. A request
|
|
# that arrives wearing the proxy's own address - because the proxy forwarded
|
|
# nothing, or because nothing forwarded at all - therefore MATCHED the allowlist
|
|
# and was admitted while carrying no evidence about who actually sent it. Being
|
|
# inside the allowlist is not enough; the address also has to be one a client
|
|
# could plausibly have had.
|
|
#
|
|
# Loopback is never a client at any site, so it needs no configuration. The USB
|
|
# kiosk closed this on 2026-09-18 and this one did not, which is the only reason
|
|
# the two read differently.
|
|
PARTS_KIOSK_TRUSTED_PROXIES = 'printedparts_kiosk_trusted_proxies'
|
|
PARTS_KIOSK_NEVER_A_CLIENT = '127.0.0.0/8, ::1/128'
|
|
|
|
|
|
def _kiosk_address_is_infrastructure(address):
|
|
"""True when this address belongs to the path rather than to a caller."""
|
|
if ip_in_cidrs(address, PARTS_KIOSK_NEVER_A_CLIENT):
|
|
return True
|
|
return ip_in_cidrs(address, Setting.get(PARTS_KIOSK_TRUSTED_PROXIES))
|
|
|
|
|
|
def _kiosk_token_presented():
|
|
"""True when the caller carries this kiosk's device token."""
|
|
expected = (Setting.get('printedparts_kiosk_token') or '').strip()
|
|
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
|
if not expected or not presented:
|
|
return False
|
|
# compare_digest, not ==: the token is a secret and == leaks its length and
|
|
# matching prefix through timing.
|
|
return hmac.compare_digest(presented, expected)
|
|
|
|
|
|
def _kiosk_open():
|
|
"""True when this site has the kiosk on AND the caller is admitted.
|
|
|
|
Two ways in, either sufficient:
|
|
1. the per-device token, for the unattended screen by the printers, and
|
|
2. a source address inside printedparts_kiosk_allowed_cidrs, for people
|
|
who open the page on their own PC and never log in.
|
|
|
|
Its own setting, not the USB kiosk's: the two are separate screens in
|
|
separate rooms, and one allowlist granting the other is exactly the sharing
|
|
the separate tokens exist to prevent.
|
|
"""
|
|
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
|
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
|
return False
|
|
if _kiosk_token_presented():
|
|
return True
|
|
address = client_ip(request)
|
|
if _kiosk_address_is_infrastructure(address):
|
|
return False
|
|
return ip_in_cidrs(address, Setting.get('printedparts_kiosk_allowed_cidrs'))
|
|
|
|
|
|
@printedparts_bp.route('/kiosk/provisioning-link', methods=['GET'])
|
|
@require_permission('settings.edit')
|
|
def kiosk_provisioning_link():
|
|
"""The URL that provisions a screen, token included, for an administrator.
|
|
|
|
Same reason as the USB kiosk: the settings API masks any key named
|
|
'*token*', so the value is visible for one moment after Generate and an
|
|
administrator who copies the field later provisions a screen with
|
|
'********' and gets a silent 404. This hands the real value to somebody
|
|
already allowed to set it, so the mask can stay.
|
|
"""
|
|
token = (Setting.get('printedparts_kiosk_token') or '').strip()
|
|
if not token:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'No kiosk token set. Generate one first.')
|
|
audit_log('viewed', 'Setting', entityname='printedparts_kiosk_token',
|
|
details='kiosk provisioning link')
|
|
db.session.commit()
|
|
return success_response({'url': '/parts-kiosk?token=' + token})
|
|
|
|
|
|
def _kiosk_shut():
|
|
"""404, not 403 - a caller without the token does not learn the door exists."""
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
|
|
|
def _kiosk_find_item(itemcode):
|
|
"""Resolve a scanned or typed code to an active item.
|
|
|
|
Accepts the full code (WJRP0042) or bare digits from the touch keypad
|
|
(42 -> prefix + zero-pad), so manual entry never needs letters. A label QR
|
|
carries 'TAG|rev'; the revision is stripped here (it identifies the physical
|
|
part's file rev, not a different item)."""
|
|
scanned = (itemcode or '').split('|', 1)[0].strip().upper()
|
|
item = PrintedItem.query.filter(
|
|
or_(PrintedItem.itemcode == scanned,
|
|
PrintedItem.gagelabtag == scanned),
|
|
PrintedItem.isactive == True).first()
|
|
if not item and scanned.isdigit():
|
|
# Bare digits from the touch keypad match the NUMBER inside either
|
|
# identifier (internal code or gage-lab tag). Small catalog: scan
|
|
# actives and compare numeric tails; only a UNIQUE match counts.
|
|
wanted = int(scanned)
|
|
matches = []
|
|
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
|
for value in (candidate.itemcode, candidate.gagelabtag):
|
|
# The TRAILING digit run, not every digit in the string. A
|
|
# prefix can contain digits - the shipped default is '3DP' -
|
|
# and collecting all of them turned 3DP0004 into 30004, so the
|
|
# operator typing the 4 printed on the bin got "no part matches
|
|
# that barcode" and had to work out that the 3 belonged to the
|
|
# prefix. The number on the label is the number at the end.
|
|
digits = re.search(r'(\d+)$', (value or '').strip())
|
|
if digits and int(digits.group(1)) == wanted:
|
|
matches.append(candidate)
|
|
break
|
|
if len(matches) == 1:
|
|
item = matches[0]
|
|
return item
|
|
|
|
|
|
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
|
def kiosk_item(itemcode):
|
|
"""Item summary for a scanned bin barcode. Kiosk-token gated."""
|
|
if not _kiosk_open():
|
|
return _kiosk_shut()
|
|
item = _kiosk_find_item(itemcode)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No part matches that barcode', http_code=404)
|
|
return success_response(item.to_dict())
|
|
|
|
|
|
@printedparts_bp.route('/kiosk/take', methods=['POST'])
|
|
def kiosk_take():
|
|
"""Take parts from a bin. Body: {itemcode, badge, quantity}.
|
|
|
|
The badge attributes the ledger row. It does not authorize the write - the
|
|
kiosk flag and the device token do.
|
|
"""
|
|
if not _kiosk_open():
|
|
return _kiosk_shut()
|
|
data = request.get_json() or {}
|
|
|
|
item = _kiosk_find_item(data.get('itemcode'))
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
'No part matches that barcode', http_code=404)
|
|
|
|
quantity = data.get('quantity')
|
|
# isinstance(True, int) is True in Python, so True would have passed as a
|
|
# quantity of 1. Reject bool explicitly.
|
|
if (not isinstance(quantity, int) or isinstance(quantity, bool)
|
|
or quantity < 1):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Enter how many you are taking')
|
|
if quantity > KIOSK_MAX_TAKE_QUANTITY:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Take at most {KIOSK_MAX_TAKE_QUANTITY} at a time - see the '
|
|
f'parts team')
|
|
if quantity > item.quantityonhand:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Only {item.quantityonhand} on hand - take fewer or see the '
|
|
f'parts team')
|
|
|
|
try:
|
|
sso, name = resolve_badge(data.get('badge'))
|
|
except BadgeError:
|
|
# ONE message for every badge failure on the ANONYMOUS route. This is
|
|
# the only place it matters: distinguishing "malformed" from "not
|
|
# recognized" let a caller with no account sort guesses into real
|
|
# employee numbers. The gated restock/adjust handlers keep the specific
|
|
# message, because there the person is known and the detail is help.
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'Badge not accepted - see the parts team',
|
|
http_code=422)
|
|
|
|
# Print-file revision the scanned part carried: explicit field, else the
|
|
# 'TAG|rev' tail of the scanned code. Recorded for traceability.
|
|
revision = data.get('revision')
|
|
if revision is None and '|' in (data.get('itemcode') or ''):
|
|
tail = data['itemcode'].split('|', 1)[1].strip()
|
|
revision = tail if tail.isdigit() else None
|
|
if revision is not None:
|
|
try:
|
|
revision = int(revision)
|
|
except (ValueError, TypeError):
|
|
revision = None
|
|
|
|
try:
|
|
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
|
except InsufficientStock as shortfall:
|
|
# Another kiosk took the last of it between this request's read and its
|
|
# write. Same message as the pre-check, with the number the database
|
|
# actually holds now.
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Only {shortfall.available} on hand - take fewer or see the '
|
|
f'parts team')
|
|
return success_response(item.to_dict(),
|
|
message=f'Took {quantity}, {item.quantityonhand} left')
|
|
|
|
|
|
# --- reports (merged into GET /api/reports while the plugin is enabled) ------
|
|
|
|
import csv
|
|
import io
|
|
|
|
from flask import Response
|
|
from sqlalchemy import func
|
|
|
|
|
|
def _csv_response(rows, columns, filename):
|
|
"""CSV download; local helper because generate_csv is not on the
|
|
contract surface (shopdb.api)."""
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(columns)
|
|
for row in rows:
|
|
writer.writerow([row.get(column, '') for column in columns])
|
|
return Response(
|
|
output.getvalue(), mimetype='text/csv',
|
|
headers={'Content-Disposition': f'attachment; filename={filename}'})
|
|
|
|
|
|
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def report_stock():
|
|
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
|
|
|
ledgerdelta should always be 0; anything else means a write path
|
|
bypassed the single-commit rule and needs finding.
|
|
"""
|
|
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
|
|
# string and breaks the delta arithmetic's type.
|
|
ledger = {itemid: int(total) for itemid, total in
|
|
db.session.query(
|
|
PrintedItemTransaction.printeditemid,
|
|
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
|
|
.group_by(PrintedItemTransaction.printeditemid).all()}
|
|
rows = []
|
|
for item in PrintedItem.query.filter_by(isactive=True).order_by(
|
|
PrintedItem.itemname).all():
|
|
rows.append({
|
|
'itemcode': item.itemcode,
|
|
'itemname': item.itemname,
|
|
'binlocation': item.binlocation or '',
|
|
'quantityonhand': item.quantityonhand,
|
|
'lowstockthreshold': item.lowstockthreshold,
|
|
'islowstock': item.islowstock,
|
|
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
|
|
})
|
|
columns = ['itemcode', 'itemname', 'binlocation', 'quantityonhand',
|
|
'lowstockthreshold', 'islowstock', 'ledgerdelta']
|
|
if request.args.get('format') == 'csv':
|
|
return _csv_response(rows, columns, 'printedparts-stock.csv')
|
|
return success_response({'columns': columns, 'rows': rows})
|
|
|
|
|
|
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def report_consumption():
|
|
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
|
days = request.args.get('days', 30, type=int)
|
|
query = (db.session.query(
|
|
PrintedItem.itemcode,
|
|
PrintedItem.itemname,
|
|
func.count(PrintedItemTransaction.transactionid),
|
|
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
|
|
.join(PrintedItemTransaction,
|
|
PrintedItemTransaction.printeditemid == PrintedItem.printeditemid)
|
|
.filter(PrintedItemTransaction.transactiontype == 'take'))
|
|
if days > 0:
|
|
from datetime import datetime, timedelta, timezone
|
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
|
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
|
|
query = query.group_by(PrintedItem.itemcode, PrintedItem.itemname)
|
|
rows = [{'itemcode': code, 'itemname': name, 'takes': takes,
|
|
'quantitytaken': int(taken)}
|
|
for code, name, takes, taken in query.all()]
|
|
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
|
|
columns = ['itemcode', 'itemname', 'takes', 'quantitytaken']
|
|
if request.args.get('format') == 'csv':
|
|
return _csv_response(rows, columns, 'printedparts-consumption.csv')
|
|
return success_response({'columns': columns, 'rows': rows, 'days': days})
|
|
|
|
|
|
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def report_by_person():
|
|
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
|
days = request.args.get('days', 30, type=int)
|
|
query = (db.session.query(
|
|
PrintedItemTransaction.employeesso,
|
|
func.max(PrintedItemTransaction.employeename),
|
|
func.count(PrintedItemTransaction.transactionid),
|
|
func.coalesce(func.sum(-PrintedItemTransaction.quantitychange), 0))
|
|
.filter(PrintedItemTransaction.transactiontype == 'take'))
|
|
if days > 0:
|
|
from datetime import datetime, timedelta, timezone
|
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
|
query = query.filter(PrintedItemTransaction.transactiondate >= cutoff)
|
|
query = query.group_by(PrintedItemTransaction.employeesso)
|
|
rows = [{'employeesso': sso, 'employeename': name or '', 'takes': takes,
|
|
'quantitytaken': int(taken)}
|
|
for sso, name, takes, taken in query.all()]
|
|
rows.sort(key=lambda row: row['quantitytaken'], reverse=True)
|
|
columns = ['employeesso', 'employeename', 'takes', 'quantitytaken']
|
|
if request.args.get('format') == 'csv':
|
|
return _csv_response(rows, columns, 'printedparts-by-person.csv')
|
|
return success_response({'columns': columns, 'rows': rows, 'days': days})
|
|
|
|
|
|
# --- print files: append-only revisions per item ------------------------------
|
|
|
|
from flask_jwt_extended import get_jwt_identity
|
|
from shopdb.api import send_upload
|
|
|
|
from ..models import PrintedItemFile
|
|
|
|
FILE_EXTENSIONS = {'.stl', '.3mf', '.gcode', '.gco', '.bgcode', '.step',
|
|
'.stp', '.obj', '.amf'}
|
|
MAX_FILE_BYTES = 100 * 1024 * 1024
|
|
|
|
|
|
def _filedir():
|
|
return os.path.join(current_app.instance_path, 'printedpartsfiles')
|
|
|
|
|
|
def _uploader_name():
|
|
from shopdb.api import User
|
|
identity = get_jwt_identity()
|
|
try:
|
|
user = db.session.get(User, int(identity))
|
|
if user:
|
|
return user.username
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return str(identity)
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def list_item_files(item_id: int):
|
|
"""Revision history, newest first."""
|
|
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)
|
|
.order_by(PrintedItemFile.revision.desc()).all())
|
|
return success_response([f.to_dict() for f in files])
|
|
|
|
|
|
@printedparts_bp.route('/items/<int:item_id>/files', methods=['POST'])
|
|
@require_permission('printedparts.edit')
|
|
def upload_item_file(item_id: int):
|
|
"""Upload the next revision of the item's print file.
|
|
|
|
multipart/form-data: file=<stl/3mf/gcode/...>, note=<what changed>.
|
|
Revisions are append-only; nothing is replaced.
|
|
"""
|
|
item = db.session.get(PrintedItem, item_id)
|
|
if not item:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'Printed item {item_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 FILE_EXTENSIONS:
|
|
return error_response(
|
|
ErrorCodes.VALIDATION_ERROR,
|
|
f'Unsupported file type {ext}; allowed: '
|
|
+ ', '.join(sorted(FILE_EXTENSIONS)))
|
|
|
|
upload.stream.seek(0, os.SEEK_END)
|
|
filesize = upload.stream.tell()
|
|
upload.stream.seek(0)
|
|
if filesize > MAX_FILE_BYTES:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'File exceeds the 100 MB limit')
|
|
|
|
latest = (db.session.query(db.func.max(PrintedItemFile.revision))
|
|
.filter_by(printeditemid=item_id).scalar()) or 0
|
|
revision = latest + 1
|
|
|
|
filedir = _filedir()
|
|
os.makedirs(filedir, exist_ok=True)
|
|
storedfilename = secure_filename(
|
|
f'printeditem-{item_id}-rev{revision}{ext}')
|
|
upload.save(os.path.join(filedir, storedfilename))
|
|
|
|
record = PrintedItemFile(
|
|
printeditemid=item_id,
|
|
revision=revision,
|
|
filename=secure_filename(upload.filename),
|
|
storedfilename=storedfilename,
|
|
filesize=filesize,
|
|
uploadnote=(request.form.get('note') or '').strip() or None,
|
|
uploadedby=_uploader_name(),
|
|
)
|
|
db.session.add(record)
|
|
db.session.commit()
|
|
return success_response(record.to_dict(),
|
|
message=f'Revision {revision} uploaded',
|
|
http_code=201)
|
|
|
|
|
|
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
|
@require_permission('printedparts.view')
|
|
def download_item_file(file_id: int):
|
|
"""Download a revision under its original filename."""
|
|
record = db.session.get(PrintedItemFile, file_id)
|
|
if not record:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
|
|
http_code=404)
|
|
return send_upload(_filedir(), record.storedfilename,
|
|
as_attachment=True,
|
|
download_name=record.filename)
|
|
|
|
|
|
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
|
@require_permission('printedparts.delete')
|
|
def delete_item_file(file_id: int):
|
|
"""Remove a bad revision (wrong file uploaded). History otherwise stays."""
|
|
record = db.session.get(PrintedItemFile, file_id)
|
|
if not record:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'File not found',
|
|
http_code=404)
|
|
path = os.path.join(_filedir(), record.storedfilename)
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
db.session.delete(record)
|
|
db.session.commit()
|
|
return success_response(message='Revision removed')
|