"""Printedparts plugin API routes. Reads are open (jwt optional) like every list surface; mutations arrive in later stages with permission gates. The kiosk endpoints (unauthenticated by explicit decision - see the proposal) also land later. """ 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(optional=True) 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(optional=True) 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') # --- 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. """ item.quantityonhand += quantitychange db.session.add(PrintedItemTransaction( printeditemid=item.printeditemid, transactiontype=transactiontype, quantitychange=quantitychange, employeesso=sso, employeename=name, reason=reason, )) db.session.commit() @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. @printedparts_bp.route('/kiosk/item/', methods=['GET']) def kiosk_item(itemcode): """Item summary for a scanned bin barcode (open read for the kiosk).""" item = PrintedItem.query.filter( PrintedItem.itemcode == itemcode.strip(), PrintedItem.isactive == True).first() 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 = PrintedItem.query.filter( PrintedItem.itemcode == (data.get('itemcode') or '').strip(), PrintedItem.isactive == True).first() 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')