Both from the second Darkwing scan. Every claim in it was verified against the
code before acting.
The kiosk was the product's only unauthenticated write, justified by a decision
record saying it was "physically rate-limited: it serves a touch screen on the
shop floor". Nothing in code enforced the physical half, and an HTTP route is
reachable from wherever the app is. What that left open:
- _kiosk_find_item falls back to matching bare digits against the numeric
tail of every active item, so 1, 2, 3 walk the catalogue without knowing a
barcode;
- a badge is a short numeric SSO, and the route answered 422 for an unknown
one and 200 for a real one - an oracle for sorting guesses into employees;
- the write then decremented stock and stamped the ledger, which IS the audit
trail, with whatever SSO the caller supplied;
- quantity was bounded only by what was on hand, so one request emptied a bin.
A badge is attribution, never authorization. Both kiosk routes now need the
site's kiosk flag AND a per-device token, and 404 rather than 403 without them
so a caller does not learn the door is there. The READ is gated too: gating only
the write would have left the enumeration oracle running against a kiosk a site
had switched off.
The token is per-DEVICE, not per-page, because a kiosk is a browser on a touch
screen and a token served inside the page is readable by anyone who can load it.
An admin opens the screen once as /parts-kiosk?token=..., the page moves it into
that browser's localStorage and strips it from the address bar.
Also: quantity is capped per take, JSON true no longer passes as 1
(isinstance(True, int) is True), and every badge failure on the anonymous route
returns one message. The gated restock and adjust handlers keep the specific
message - there the person is known and the detail is help, not an oracle. I
changed the wrong one of those three handlers first; caught by the test.
/api/dashboard/health formatted str(e) straight into the body of a route
PUBLIC_ENDPOINTS declares open because "monitoring has no account". A pymysql
OperationalError names the database account, the app host's address, the driver
and the failing statement. It is logged server-side now and the body carries a
coarse status. The version goes with it - it told an anonymous caller which
release to look up. Status stays 200 either way: a monitor keyed on the body's
status field is the contract here, and turning a degraded database into a
transport failure is a separate decision from not leaking the exception.
Nine tests. The three gate tests were confirmed to fail with the gate stubbed
out, rather than assumed to cover it. Suite 2325, frontend 316, build clean.
Scope: product
816 lines
34 KiB
Python
816 lines
34 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
|
|
|
|
from flask import current_app
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from shopdb.api import Setting
|
|
|
|
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
|
|
|
|
|
|
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.
|
|
"""
|
|
quantitybefore = item.quantityonhand
|
|
item.quantityonhand += 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)
|
|
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
|
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
|
|
|
|
# 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
|
|
|
|
|
|
def _kiosk_open():
|
|
"""True when this site has the kiosk on AND the caller carries its token."""
|
|
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
|
|
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_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):
|
|
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
|
|
if tail and int(tail) == 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
|
|
|
|
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
|
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')
|