"""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']) @jwt_required() @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.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']) @jwt_required() @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']) @jwt_required() @require_permission('printedparts.create') def create_item(): """Create a printed item; the itemcode is minted from the row id.""" data = request.get_json() or {} itemname = (data.get('itemname') or '').strip() if not itemname: return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required') threshold = data.get('lowstockthreshold') if threshold is None: threshold = int(Setting.get('printedparts_default_threshold') or 5) item = PrintedItem( itemname=itemname, 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']) @jwt_required() @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') 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']) @jwt_required() @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']) @jwt_required() @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']) @jwt_required() @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).""" from flask import send_from_directory return send_from_directory(_imagedir(), filename) @printedparts_bp.route('/items//image', methods=['DELETE']) @jwt_required() @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): """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, )) 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_alert subject = (f'Low stock: {item.itemname} ({item.itemcode}) - ' f'{item.quantityonhand} left') html = (f'

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

' f'

Bin: {item.binlocation or "-"}

' f'

Time to print more.

') try: recipients = _alert_recipients() if recipients: send_email(recipients, subject, html) else: send_alert(subject, html) except Exception: import logging logging.getLogger(__name__).exception( 'Low-stock alert failed for %s', item.itemcode) @printedparts_bp.route('/items//restock', methods=['POST']) @jwt_required() @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']) @jwt_required() @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: UNauthenticated by decision record -------------------------------- # The take endpoint is the product's first open WRITE. The proposal's decision # record sets the bar it must meet: decrement-only, badge-attributed, bounded, # physically rate-limited. It can reduce stock of an active item and nothing # else; identity comes from the badge resolved server-side, never the client. 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.""" itemcode = (itemcode or '').strip() item = PrintedItem.query.filter( PrintedItem.itemcode == itemcode, PrintedItem.isactive == True).first() if not item and itemcode.isdigit(): # The digits in a minted code ARE the row id, so id lookup keeps # working even for labels printed under an older prefix. candidate = db.session.get(PrintedItem, int(itemcode)) if candidate and candidate.isactive: item = candidate return item @printedparts_bp.route('/kiosk/item/', methods=['GET']) def kiosk_item(itemcode): """Item summary for a scanned bin barcode (open read for the kiosk).""" 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}.""" 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') if not isinstance(quantity, int) or quantity < 1: return error_response(ErrorCodes.VALIDATION_ERROR, 'Enter how many you are taking') 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 as badge_error: return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error), http_code=422) _ledger_write(item, 'take', -quantity, sso, name) 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']) @jwt_required(optional=True) 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']) @jwt_required(optional=True) 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']) @jwt_required(optional=True) 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 ..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']) @jwt_required() @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']) @jwt_required() @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']) @jwt_required(optional=True) def download_item_file(file_id: int): """Download a revision under its original filename.""" from flask import send_from_directory record = db.session.get(PrintedItemFile, file_id) if not record: return error_response(ErrorCodes.NOT_FOUND, 'File not found', http_code=404) return send_from_directory(_filedir(), record.storedfilename, as_attachment=True, download_name=record.filename) @printedparts_bp.route('/files/', methods=['DELETE']) @jwt_required() @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')