Files
shopdb-flask/plugins/printedparts/api/routes.py
cproudlock a1802a2bf5 Match the number on the bin, and let an unset kiosk say so
The last two kiosk findings.

Keypad entry collected EVERY digit in an item's code, and the shipped prefix
contains one: '3DP0004' collapsed to 30004, so an operator typing the 4 printed
on the label was told no part matches that barcode and had to work out that the
leading 3 belonged to the prefix. The number on a label is the number at the
end, so the trailing digit run is what is compared now. An ambiguous number is
still refused rather than guessed - taking from the wrong bin is worse than
being asked to scan the label.

A parts kiosk that had never been provisioned drew its normal "Scan the barcode
on the bin" screen and answered "Not found" to every scan, which reads as "we
do not stock that part" rather than "this screen was never set up". The server
cannot say which: the routes answer a bare 404 on purpose, so a caller without
the device token does not learn the door exists, and that stays. But the BROWSER
knows whether it holds a token, and saying so discloses nothing - an
unprovisioned screen now says it is not set up and how to fix it.

For the case the client genuinely cannot distinguish - kiosk switched off versus
a part that is not stocked, both a 404 by design - three misses in a row add a
line suggesting the kiosk may have been switched off. Truthful without the
server having to leak anything.

Three tests, verified by planting the all-digits match back.

Scope: product
2026-09-03 17:00:49 -04:00

892 lines
37 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
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
# 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):
# 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')