"""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 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/', 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/', 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/', 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//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//image', methods=['POST']) @require_permission('printedparts.edit') def upload_item_image(item_id: int): """Upload (or replace) the photo for an item (multipart file=).""" 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/', 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//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:///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'

View {code}

' if item_url else '' html = (f'

{item.itemname} ({code}) is down ' f'to {item.quantityonhand} ' f'(threshold {item.lowstockthreshold}).

' f'

Bin: {item.binlocation or "-"}

' f'{link_html}' f'

Time to print more.

') 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//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//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/', 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= (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= (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//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//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=, note=. 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//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/', 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')