+ New items mint codes like {{ values.printedparts_code_prefix || '3DP' }}0042.
+ Changing it does not rename existing items.
+
+
+
+
+
+
+
Seed value for new items; each item can override.
+
+
+
+
+
+
+
+
+
+
+
+
No users loaded
+
+
+ Selected users receive low-stock alerts at their account email.
+
+
+
+
+
+
+
+
No roles loaded
+
+
+ Every active member of a selected role receives low-stock alerts.
+
+
+
+
+
+
+
+ Comma-separated. Empty uses the site-wide alert recipients
+ (Settings > System > Email). Alerts fire once when an item
+ crosses its threshold; restocking above re-arms.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/printedparts/README.md b/plugins/printedparts/README.md
new file mode 100644
index 00000000..51fe460d
--- /dev/null
+++ b/plugins/printedparts/README.md
@@ -0,0 +1,59 @@
+# Printedparts plugin
+
+3D-printed parts inventory + kiosk checkout
+
+This plugin was generated by `flask plugin new printedparts`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain.
+
+## What's here
+
+- `plugin.py` - the `PrintedpartsPlugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data.
+- `models/printedparts.py` - example Asset extension table. Replace `examplefield` with your domain fields.
+- `api/routes.py` - example list and detail endpoints. Add CRUD as needed.
+- `schemas/__init__.py` - marshmallow schema stub for request/response validation.
+- `tests/test_plugin.py` - smoke tests asserting contract compliance.
+- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad.
+
+## Common edits
+
+| You want to... | Do this |
+|---|---|
+| Add a hook (search, navigation, dashboard widget) | Override the method in `PrintedpartsPlugin`. See the PLUGIN-HOOKS wiki page. |
+| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. |
+| Add another model | Create `models/.py`, export it in `models/__init__.py`, return it in `get_models()`. |
+| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. |
+
+## Frontend
+
+Vue components for this plugin live under `frontend/src/views/printedparts/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point.
+
+## Install and run
+
+```bash
+flask plugin install printedparts
+flask db migrate -m "Add printedparts plugin tables"
+flask db upgrade
+pytest plugins/printedparts/tests/
+```
+
+## References
+
+- the PLUGIN-HOOKS wiki page - canonical hook reference
+- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough
+- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
+- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
+
+## Why the kiosk take endpoint is unauthenticated
+
+`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every
+other kiosk endpoint is a read). Accepted deliberately, against the criteria
+in docs/proposals/printedparts-plugin.md:
+
+1. Decrement-only: it can reduce stock of an active item, nothing else.
+2. Fully attributed: it refuses to act without a badge that resolves under
+ the site policy; every action lands in the ledger with SSO + name + time.
+3. Bounded blast radius: worst case is stock counts driven low - visible in
+ the ledger and reversible with an adjust.
+4. Physically rate-limited: it serves a touch screen on the shop floor;
+ nothing enumerable, nothing worth scraping.
+
+Any future open-write endpoint must clear the same bar.
diff --git a/plugins/printedparts/__init__.py b/plugins/printedparts/__init__.py
new file mode 100644
index 00000000..90bc9c3f
--- /dev/null
+++ b/plugins/printedparts/__init__.py
@@ -0,0 +1,5 @@
+"""Printedparts plugin package."""
+
+from .plugin import PrintedpartsPlugin
+
+__all__ = ['PrintedpartsPlugin']
diff --git a/plugins/printedparts/api/__init__.py b/plugins/printedparts/api/__init__.py
new file mode 100644
index 00000000..2043056e
--- /dev/null
+++ b/plugins/printedparts/api/__init__.py
@@ -0,0 +1,5 @@
+"""Printedparts plugin API package."""
+
+from .routes import printedparts_bp
+
+__all__ = ['printedparts_bp']
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
new file mode 100644
index 00000000..18c3072d
--- /dev/null
+++ b/plugins/printedparts/api/routes.py
@@ -0,0 +1,679 @@
+"""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')
diff --git a/plugins/printedparts/frontend-api-snippet.js b/plugins/printedparts/frontend-api-snippet.js
new file mode 100644
index 00000000..7dfebb85
--- /dev/null
+++ b/plugins/printedparts/frontend-api-snippet.js
@@ -0,0 +1,35 @@
+/*
+ * Printedparts API client snippet.
+ *
+ * Paste this printedpartsApi block into frontend/src/api/index.js (next to the
+ * other per-resource blocks). Then, in the generated PrintedpartsList, PrintedpartsDetail,
+ * and PrintedpartsForm views, delete the local printedpartsApi const and import the shared
+ * one instead:
+ *
+ * import { printedpartsApi } from '../../api'
+ *
+ * The scaffolded views ship with an identical inline client so they build and
+ * run before you touch the shared api module. This file is NOT auto-merged into
+ * api/index.js on purpose; that module is hand-maintained and shared.
+ *
+ * The create/update/delete calls assume matching POST/PUT/DELETE routes exist
+ * on the backend. The scaffolded api/routes.py only ships list and get; add the
+ * write endpoints when you wire up the form.
+ */
+export const printedpartsApi = {
+ list(params = {}) {
+ return api.get('/printedparts', { params })
+ },
+ get(itemId) {
+ return api.get(`/printedparts/${itemId}`)
+ },
+ create(data) {
+ return api.post('/printedparts', data)
+ },
+ update(itemId, data) {
+ return api.put(`/printedparts/${itemId}`, data)
+ },
+ remove(itemId) {
+ return api.delete(`/printedparts/${itemId}`)
+ }
+}
diff --git a/plugins/printedparts/manifest.json b/plugins/printedparts/manifest.json
new file mode 100644
index 00000000..dfb02311
--- /dev/null
+++ b/plugins/printedparts/manifest.json
@@ -0,0 +1,11 @@
+{
+ "name": "printedparts",
+ "version": "0.1.0",
+ "description": "3D-printed parts inventory + kiosk checkout",
+ "display_name": "3D Printed Parts",
+ "author": "",
+ "dependencies": ["employees"],
+ "core_version": ">=0.12.0,<1.0.0",
+ "api_prefix": "/api/printedparts",
+ "default_enabled": false
+}
diff --git a/plugins/printedparts/migrations/env.py b/plugins/printedparts/migrations/env.py
new file mode 100644
index 00000000..1490d7a5
--- /dev/null
+++ b/plugins/printedparts/migrations/env.py
@@ -0,0 +1,14 @@
+"""Alembic environment for the printedparts plugin migration chain.
+
+Delegates to the shared runner in shopdb.plugins.alembic_template, which
+filters the metadata to this plugin's tables and drives Alembic against the
+per-plugin version table alembic_version_printedparts (ADR-008). This plugin
+is NEW (post-cutover): its 0001 baseline really CREATES its tables.
+"""
+import os
+
+os.environ['PLUGIN_NAME'] = 'printedparts'
+
+from shopdb.plugins.alembic_template import run_migrations # noqa: E402
+
+run_migrations()
diff --git a/plugins/printedparts/migrations/script.py.mako b/plugins/printedparts/migrations/script.py.mako
new file mode 100644
index 00000000..2c015630
--- /dev/null
+++ b/plugins/printedparts/migrations/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py
new file mode 100644
index 00000000..f0bb3484
--- /dev/null
+++ b/plugins/printedparts/migrations/versions/0001_printedparts_baseline.py
@@ -0,0 +1,71 @@
+"""printedparts plugin baseline (real create).
+
+Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator
+of printeditems and printeditemtransactions - the core chain never knew them.
+Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`)
+after `flask db upgrade` builds the core schema.
+
+Both tables are self-contained (the only FK is transactions -> items inside
+the plugin), so the shared create_plugin_tables helper would work here; the
+ops are written out explicitly anyway to match the measuringtools exemplar
+and keep the baseline reviewable.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'printedparts0001baseline'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'printeditems',
+ sa.Column('printeditemid', sa.Integer(), nullable=False),
+ sa.Column('itemcode', sa.String(length=20), nullable=True),
+ sa.Column('itemname', sa.String(length=120), nullable=False),
+ sa.Column('itemdescription', sa.String(length=500), nullable=True),
+ sa.Column('imageurl', sa.String(length=255), nullable=True),
+ sa.Column('quantityonhand', sa.Integer(), nullable=False),
+ sa.Column('lowstockthreshold', sa.Integer(), nullable=False),
+ sa.Column('binlocation', sa.String(length=100), nullable=True),
+ sa.Column('printnotes', sa.Text(), nullable=True),
+ sa.Column('createddate', sa.DateTime(), nullable=False),
+ sa.Column('modifieddate', sa.DateTime(), nullable=False),
+ sa.Column('isactive', sa.Boolean(), nullable=False),
+ sa.PrimaryKeyConstraint('printeditemid'),
+ sa.UniqueConstraint('itemcode'),
+ )
+ op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
+
+ op.create_table(
+ 'printeditemtransactions',
+ sa.Column('transactionid', sa.Integer(), nullable=False),
+ sa.Column('printeditemid', sa.Integer(), nullable=False),
+ sa.Column('transactiontype', sa.String(length=10), nullable=False),
+ sa.Column('quantitychange', sa.Integer(), nullable=False),
+ sa.Column('employeesso', sa.String(length=20), nullable=False),
+ sa.Column('employeename', sa.String(length=120), nullable=True),
+ sa.Column('reason', sa.String(length=255), nullable=True),
+ sa.Column('transactiondate', sa.DateTime(), nullable=False),
+ sa.Column('createddate', sa.DateTime(), nullable=False),
+ sa.Column('modifieddate', sa.DateTime(), nullable=False),
+ sa.Column('isactive', sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'],
+ ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('transactionid'),
+ )
+ op.create_index('ix_printeditemtransactions_printeditemid',
+ 'printeditemtransactions', ['printeditemid'])
+ op.create_index('ix_printeditemtransactions_employeesso',
+ 'printeditemtransactions', ['employeesso'])
+ op.create_index('ix_printeditemtransactions_transactiondate',
+ 'printeditemtransactions', ['transactiondate'])
+
+
+def downgrade():
+ op.drop_table('printeditemtransactions')
+ op.drop_table('printeditems')
diff --git a/plugins/printedparts/migrations/versions/0002_printeditemfiles.py b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py
new file mode 100644
index 00000000..d0b2c9a9
--- /dev/null
+++ b/plugins/printedparts/migrations/versions/0002_printeditemfiles.py
@@ -0,0 +1,43 @@
+"""Add printeditemfiles: append-only print-file revisions per item.
+
+The plugin's first incremental migration on top of its 0001 baseline -
+the ADR-008 payoff: the plugin evolves its own schema without touching
+the core chain. Applied by `flask plugin upgrade-all`.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'printedparts0002files'
+down_revision = 'printedparts0001baseline'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'printeditemfiles',
+ sa.Column('fileid', sa.Integer(), nullable=False),
+ sa.Column('printeditemid', sa.Integer(), nullable=False),
+ sa.Column('revision', sa.Integer(), nullable=False),
+ sa.Column('filename', sa.String(length=255), nullable=False),
+ sa.Column('storedfilename', sa.String(length=191), nullable=False),
+ sa.Column('filesize', sa.Integer(), nullable=False),
+ sa.Column('uploadnote', sa.String(length=255), nullable=True),
+ sa.Column('uploadedby', sa.String(length=80), nullable=False),
+ sa.Column('createddate', sa.DateTime(), nullable=False),
+ sa.Column('modifieddate', sa.DateTime(), nullable=False),
+ sa.Column('isactive', sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(['printeditemid'],
+ ['printeditems.printeditemid'],
+ ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('fileid'),
+ sa.UniqueConstraint('storedfilename'),
+ )
+ op.create_index('ix_printeditemfiles_printeditemid',
+ 'printeditemfiles', ['printeditemid'])
+
+
+def downgrade():
+ op.drop_table('printeditemfiles')
diff --git a/plugins/printedparts/models/__init__.py b/plugins/printedparts/models/__init__.py
new file mode 100644
index 00000000..b39cb082
--- /dev/null
+++ b/plugins/printedparts/models/__init__.py
@@ -0,0 +1,11 @@
+"""Printedparts plugin models."""
+
+from .printeditem import (
+ PrintedItem,
+ PrintedItemTransaction,
+ PrintedItemFile,
+ TRANSACTION_TYPES,
+)
+
+__all__ = ['PrintedItem', 'PrintedItemTransaction', 'PrintedItemFile',
+ 'TRANSACTION_TYPES']
diff --git a/plugins/printedparts/models/printeditem.py b/plugins/printedparts/models/printeditem.py
new file mode 100644
index 00000000..9f924e1a
--- /dev/null
+++ b/plugins/printedparts/models/printeditem.py
@@ -0,0 +1,135 @@
+"""Printedparts models.
+
+PrintedItem is a KIND of 3D-printed part with a quantity on hand - a
+consumable, not an ADR-001 asset (which is one row per physical thing).
+PrintedItemTransaction is the ledger: every take, restock, and adjust as a
+signed quantity change attributed to a badge-resolved employee. The ledger is
+the source of truth; quantityonhand is a cache moved in the same commit as
+each ledger write, and the stock report reconciles the two.
+"""
+
+from datetime import datetime, timezone
+
+from shopdb.api import db, BaseModel
+
+
+def _utcnow():
+ return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+TRANSACTION_TYPES = ('take', 'restock', 'adjust')
+
+
+class PrintedItem(BaseModel):
+ """A printable part the engineers stock in bins."""
+
+ __tablename__ = 'printeditems'
+
+ printeditemid = db.Column(db.Integer, primary_key=True)
+ itemcode = db.Column(db.String(20), unique=True, index=True,
+ comment='Generated bin-label code, e.g. 3DP-0042')
+ itemname = db.Column(db.String(120), nullable=False)
+ itemdescription = db.Column(db.String(500))
+ imageurl = db.Column(db.String(255))
+ quantityonhand = db.Column(db.Integer, nullable=False, default=0)
+ lowstockthreshold = db.Column(db.Integer, nullable=False, default=5)
+ binlocation = db.Column(db.String(100))
+ printnotes = db.Column(db.Text, comment='Material, print time, slicer file')
+
+ transactions = db.relationship(
+ 'PrintedItemTransaction', backref='printeditem',
+ cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic')
+
+ @property
+ def islowstock(self):
+ return self.quantityonhand <= self.lowstockthreshold
+
+ def to_dict(self):
+ return {
+ 'printeditemid': self.printeditemid,
+ 'itemcode': self.itemcode,
+ 'itemname': self.itemname,
+ 'itemdescription': self.itemdescription,
+ 'imageurl': self.imageurl,
+ 'quantityonhand': self.quantityonhand,
+ 'lowstockthreshold': self.lowstockthreshold,
+ 'islowstock': self.islowstock,
+ 'binlocation': self.binlocation,
+ 'printnotes': self.printnotes,
+ 'isactive': self.isactive,
+ 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
+ 'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
+ }
+
+
+class PrintedItemTransaction(BaseModel):
+ """One signed stock movement, always attributed to an employee."""
+
+ __tablename__ = 'printeditemtransactions'
+
+ transactionid = db.Column(db.Integer, primary_key=True)
+ printeditemid = db.Column(
+ db.Integer,
+ db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
+ nullable=False, index=True)
+ transactiontype = db.Column(db.String(10), nullable=False,
+ comment='take, restock, or adjust')
+ quantitychange = db.Column(db.Integer, nullable=False,
+ comment='Negative for take, signed for adjust')
+ employeesso = db.Column(db.String(20), nullable=False, index=True)
+ employeename = db.Column(db.String(120))
+ reason = db.Column(db.String(255))
+ transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow,
+ index=True)
+
+ def to_dict(self):
+ return {
+ 'transactionid': self.transactionid,
+ 'printeditemid': self.printeditemid,
+ 'transactiontype': self.transactiontype,
+ 'quantitychange': self.quantitychange,
+ 'employeesso': self.employeesso,
+ 'employeename': self.employeename,
+ 'reason': self.reason,
+ 'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None,
+ }
+
+
+class PrintedItemFile(BaseModel):
+ """One uploaded revision of an item's print file (STL/3MF/gcode/...).
+
+ Revisions are append-only per item: uploading assigns the next revision
+ number and never replaces earlier files, so the history of what was
+ actually printed stays reconstructible. The current file is simply the
+ highest revision.
+ """
+
+ __tablename__ = 'printeditemfiles'
+
+ fileid = db.Column(db.Integer, primary_key=True)
+ printeditemid = db.Column(
+ db.Integer,
+ db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
+ nullable=False, index=True)
+ revision = db.Column(db.Integer, nullable=False)
+ filename = db.Column(db.String(255), nullable=False,
+ comment='Original upload name, used for download')
+ storedfilename = db.Column(db.String(191), nullable=False, unique=True,
+ comment='191: unique index fits the 767-byte MySQL prefix')
+ filesize = db.Column(db.Integer, nullable=False)
+ uploadnote = db.Column(db.String(255),
+ comment='What changed in this revision')
+ uploadedby = db.Column(db.String(80), nullable=False,
+ comment='Username of the uploader')
+
+ def to_dict(self):
+ return {
+ 'fileid': self.fileid,
+ 'printeditemid': self.printeditemid,
+ 'revision': self.revision,
+ 'filename': self.filename,
+ 'filesize': self.filesize,
+ 'uploadnote': self.uploadnote,
+ 'uploadedby': self.uploadedby,
+ 'uploadeddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
+ }
diff --git a/plugins/printedparts/plugin.py b/plugins/printedparts/plugin.py
new file mode 100644
index 00000000..38a82c9d
--- /dev/null
+++ b/plugins/printedparts/plugin.py
@@ -0,0 +1,147 @@
+"""Printedparts plugin main class.
+
+3D-printed parts inventory + kiosk checkout. Quantity-based consumables:
+one row is a KIND of part with a count, not an individually tracked asset,
+so unlike most plugins this one seeds NO AssetType (ADR-001 assets are
+one-row-per-physical-thing). See docs/proposals/printedparts-plugin.md.
+"""
+
+import json
+import logging
+from pathlib import Path
+from typing import List, Optional, Type
+
+from flask import Flask, Blueprint
+
+from shopdb.plugins.base import BasePlugin, PluginMeta
+from shopdb.api import db, Setting
+
+from .models import PrintedItem, PrintedItemTransaction, PrintedItemFile
+from .api import printedparts_bp
+
+logger = logging.getLogger(__name__)
+
+
+class PrintedpartsPlugin(BasePlugin):
+ """3D-printed parts inventory + kiosk checkout."""
+
+ def __init__(self):
+ manifest_path = Path(__file__).parent / 'manifest.json'
+ with open(manifest_path) as f:
+ self._manifest = json.load(f)
+
+ @property
+ def meta(self) -> PluginMeta:
+ return PluginMeta(
+ name=self._manifest['name'],
+ version=self._manifest['version'],
+ description=self._manifest['description'],
+ author=self._manifest.get('author', ''),
+ dependencies=self._manifest.get('dependencies', []),
+ core_version=self._manifest.get('core_version', '>=0.1.0'),
+ api_prefix=self._manifest.get('api_prefix'),
+ )
+
+ def get_blueprint(self) -> Optional[Blueprint]:
+ return printedparts_bp
+
+ def get_models(self) -> List[Type]:
+ return [PrintedItem, PrintedItemTransaction, PrintedItemFile]
+
+ def init_app(self, app: Flask, db_instance) -> None:
+ logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
+
+ def get_permissions(self) -> List:
+ """RBAC permissions this plugin owns (seeded on install/enable)."""
+ return [
+ ('printedparts.view', 'View 3D printed parts', 'printedparts'),
+ ('printedparts.create', 'Create printed parts', 'printedparts'),
+ ('printedparts.edit', 'Edit printed parts', 'printedparts'),
+ ('printedparts.delete', 'Retire printed parts', 'printedparts'),
+ ('printedparts.restock', 'Restock and adjust stock counts',
+ 'printedparts'),
+ ]
+
+ def get_settings_cards(self) -> List[dict]:
+ return [
+ {
+ 'group': '3D Printed Parts',
+ 'to': '/settings/printedparts',
+ 'icon': 'box',
+ 'title': '3D Parts Settings',
+ 'description': 'Item code prefix, default threshold, kiosk '
+ 'badge policy, low-stock alert recipients',
+ 'position': 47,
+ },
+ ]
+
+ def get_reports(self) -> List[dict]:
+ return [
+ {
+ 'id': 'printedparts-stock',
+ 'name': '3D Parts Stock',
+ 'description': 'Stock levels with low-stock flags and the '
+ 'cache-vs-ledger reconcile check',
+ 'category': 'inventory',
+ 'endpoint': '/api/printedparts/reports/stock',
+ },
+ {
+ 'id': 'printedparts-consumption',
+ 'name': '3D Parts Consumption',
+ 'description': 'Takes per item over a date range',
+ 'category': 'usage',
+ 'endpoint': '/api/printedparts/reports/consumption',
+ },
+ {
+ 'id': 'printedparts-by-person',
+ 'name': '3D Parts by Person',
+ 'description': 'Takes grouped by employee',
+ 'category': 'usage',
+ 'endpoint': '/api/printedparts/reports/by-person',
+ },
+ ]
+
+ def get_navigation_items(self) -> List[dict]:
+ return [
+ {
+ 'name': '3D Parts',
+ 'icon': 'box',
+ 'route': '/printedparts',
+ 'position': 46,
+ },
+ ]
+
+ def on_install(self, app: Flask) -> None:
+ with app.app_context():
+ self._seed_settings()
+ logger.info('Printedparts plugin installed')
+
+ def on_enable(self, app: Flask) -> None:
+ # Idempotent re-seed so settings added in later versions reach sites
+ # that installed earlier (enable runs on every upgrade cycle).
+ with app.app_context():
+ self._seed_settings()
+
+ def _seed_settings(self) -> None:
+ defaults = [
+ ('printedparts_code_prefix', '3DP', 'string',
+ 'Prefix for generated item codes'),
+ ('printedparts_default_threshold', '5', 'integer',
+ 'Default low-stock threshold for new items'),
+ ('printedparts_unknown_badge', 'deny', 'string',
+ 'Kiosk policy when a badge resolves to no employee: allow or deny'),
+ ('printedparts_alert_email', '', 'string',
+ 'Comma-separated low-stock alert recipients; empty uses the '
+ 'site alert_recipients'),
+ ('printedparts_alert_userids', '', 'string',
+ 'Comma-separated shopdb user ids whose account emails receive '
+ 'low-stock alerts'),
+ ('printedparts_alert_roleids', '', 'string',
+ 'Comma-separated role ids; every active member of these roles '
+ 'receives low-stock alerts'),
+ ]
+ for key, value, valuetype, description in defaults:
+ if Setting.get(key) is None:
+ Setting.set(key, value, valuetype=valuetype,
+ category='printedparts', description=description)
+ db.session.commit()
diff --git a/plugins/printedparts/schemas/__init__.py b/plugins/printedparts/schemas/__init__.py
new file mode 100644
index 00000000..656980f5
--- /dev/null
+++ b/plugins/printedparts/schemas/__init__.py
@@ -0,0 +1,6 @@
+"""Printedparts plugin schemas (marshmallow).
+
+Add schema classes here when you need request/response validation
+beyond the simple to_dict() output. The framework wires marshmallow
+into the response helpers; see the PLUGIN-HOOKS wiki page for details.
+"""
diff --git a/plugins/printedparts/services/__init__.py b/plugins/printedparts/services/__init__.py
new file mode 100644
index 00000000..21758a61
--- /dev/null
+++ b/plugins/printedparts/services/__init__.py
@@ -0,0 +1 @@
+"""Printedparts plugin services."""
diff --git a/plugins/printedparts/services/badges.py b/plugins/printedparts/services/badges.py
new file mode 100644
index 00000000..81c7f430
--- /dev/null
+++ b/plugins/printedparts/services/badges.py
@@ -0,0 +1,69 @@
+"""Badge resolution for the printedparts plugin.
+
+Same input contract as the USB plugin (deliberately copied, not imported -
+cross-plugin imports break the shopdb.api-only contract):
+
+- all digits -> an SSO typed or scanned directly
+- 0BZ -> a physical badge wrapping a PayNo (keyboard-wedge
+ scanners emit this shape)
+- anything else -> unresolvable
+
+Names come from the employees plugin's self-hosted directory, looked up by
+SSO. The directory carries no PayNo column, so PayNo badges resolve only when
+the wrapped digits are themselves the SSO (true at sites whose badges encode
+the SSO); otherwise they fall to the unknown-badge policy.
+
+Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
+with no directory match; 'allow' records the SSO with an empty name.
+"""
+
+import re
+
+from shopdb.api import Setting
+
+_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
+
+
+class BadgeError(ValueError):
+ """Raised when a badge cannot be accepted under the site policy."""
+
+
+def _directory_name(sso):
+ """Best-effort display name from the employees plugin directory."""
+ try:
+ from plugins.employees.models import DirectoryEmployee
+ from shopdb.api import db
+ if sso and str(sso).isdigit():
+ employee = db.session.get(DirectoryEmployee, int(sso))
+ if employee:
+ return f'{employee.firstname} {employee.lastname}'.strip()
+ except Exception:
+ pass
+ return None
+
+
+def resolve_badge(badge):
+ """Return (sso, name) for a scanned badge, enforcing the site policy.
+
+ Raises BadgeError with a kiosk-displayable message when the badge shape is
+ unrecognized or the policy denies an unmatched badge.
+ """
+ badge = (badge or '').strip()
+ if not badge:
+ raise BadgeError('Scan or enter a badge')
+
+ if badge.isdigit():
+ sso = badge
+ else:
+ match = _PAYNO_BADGE.match(badge)
+ if not match:
+ raise BadgeError('Unrecognized badge format')
+ sso = match.group(1)
+
+ name = _directory_name(sso)
+ if name is None:
+ policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
+ if policy != 'allow':
+ raise BadgeError('Badge not recognized - see the parts team')
+ return sso, ''
+ return sso, name
diff --git a/plugins/printedparts/tests/__init__.py b/plugins/printedparts/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/plugins/printedparts/tests/test_plugin.py b/plugins/printedparts/tests/test_plugin.py
new file mode 100644
index 00000000..8ec0d05f
--- /dev/null
+++ b/plugins/printedparts/tests/test_plugin.py
@@ -0,0 +1,30 @@
+"""Printedparts plugin smoke tests.
+
+Asserts the plugin loads cleanly and satisfies the framework contract.
+Replace and extend with domain tests as you build the plugin out.
+"""
+
+from plugins.printedparts.plugin import PrintedpartsPlugin
+
+
+def test_printedparts_plugin_meta_is_valid():
+ """PrintedpartsPlugin.meta returns a PluginMeta with the expected name."""
+ plugin = PrintedpartsPlugin()
+ assert plugin.meta.name == 'printedparts'
+ assert plugin.meta.api_prefix == '/api/printedparts'
+
+
+def test_printedparts_plugin_get_blueprint_returns_blueprint():
+ """get_blueprint returns a Flask Blueprint, not None."""
+ from flask import Blueprint
+ plugin = PrintedpartsPlugin()
+ assert isinstance(plugin.get_blueprint(), Blueprint)
+
+
+def test_printedparts_plugin_get_models_returns_a_model():
+ """get_models returns a list with at least one SQLAlchemy model."""
+ plugin = PrintedpartsPlugin()
+ models = plugin.get_models()
+ assert len(models) >= 1
+ for model in models:
+ assert hasattr(model, '__tablename__')
diff --git a/shopdb/__init__.py b/shopdb/__init__.py
index 2a6857a2..a209c4d8 100644
--- a/shopdb/__init__.py
+++ b/shopdb/__init__.py
@@ -36,7 +36,7 @@ from .plugins import plugin_manager
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
# managed service token without importing core token internals. Additive name
# on the import surface, minor bump.
-__contract_version__ = '0.11.0'
+__contract_version__ = '0.13.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py
index d4b2ac95..be2c5a0c 100644
--- a/shopdb/api/__init__.py
+++ b/shopdb/api/__init__.py
@@ -44,6 +44,8 @@ from shopdb.core.models import (
OperatingSystem,
AssetRelationship,
RelationshipType,
+ User,
+ Role,
)
# Response + pagination helpers for plugin API blueprints
@@ -78,6 +80,7 @@ from shopdb.core.services.dualpath import (
# Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection
+from shopdb.utils.mailer import send_email, send_alert
# CMMC USB check-in/out database (read-write) used by the usb plugin
from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
@@ -266,6 +269,10 @@ __all__ = [
'parse_import_datetime',
# Legacy employee directory
'employee_connection',
+ 'send_email',
+ 'send_alert',
+ 'User',
+ 'Role',
# CMMC USB check-in/out database
'cmmc_usb_connection',
]
diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py
index ff7dd64f..67c87021 100644
--- a/shopdb/plugins/alembic_template.py
+++ b/shopdb/plugins/alembic_template.py
@@ -53,6 +53,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),
+ 'printedparts': ('printeditems', 'printeditemtransactions',
+ 'printeditemfiles'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
'slides': ('tvslides',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py
index 75d4cd80..04a8feec 100644
--- a/tests/test_core/test_authz.py
+++ b/tests/test_core/test_authz.py
@@ -45,7 +45,12 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
# shape as the exempt collector blueprint; the geenforce admin endpoints in
# the same blueprint are JWT+permission gated and ARE swept.
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
- 'geenforce.post_report'}
+ 'geenforce.post_report',
+ # Deliberately open kiosk write: decrement-only,
+ # badge-attributed server-side. Decision record in
+ # docs/proposals/printedparts-plugin.md; justification in
+ # the plugin README.
+ 'printedparts.kiosk_take'}
@pytest.fixture(autouse=True)
diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py
index 16e02690..f001ef85 100644
--- a/tests/test_plugin_migrations.py
+++ b/tests/test_plugin_migrations.py
@@ -54,6 +54,8 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
+# printedparts is post-cutover: its 0001 really creates its tables.
+EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0002files'
# notifications indexes businessunitid on top of its anchor.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py
new file mode 100644
index 00000000..48f3aa36
--- /dev/null
+++ b/tests/test_plugins/test_printedparts_ledger.py
@@ -0,0 +1,340 @@
+"""Printedparts ledger + badge tests.
+
+The invariants that make the plugin trustworthy: itemcode minting, the
+single-commit cache==ledger rule, the below-zero guard, quantity edits
+forced through the ledger, and the badge contract (SSO digits, PayNo
+wrap, unknown-badge policy).
+"""
+
+import pytest
+
+from shopdb.api import db
+from shopdb.core.models import Setting
+from plugins.printedparts.models import PrintedItem, PrintedItemTransaction
+
+
+@pytest.fixture
+def item(app, db):
+ with app.app_context():
+ row = PrintedItem(itemcode='3DP-9001', itemname='Test clip',
+ quantityonhand=0, lowstockthreshold=5)
+ db.session.add(row)
+ db.session.commit()
+ yield row.printeditemid
+
+
+@pytest.fixture
+def directory_employee(app):
+ with app.app_context():
+ from plugins.employees.models import DirectoryEmployee
+ if not db.session.get(DirectoryEmployee, 502000001):
+ db.session.add(DirectoryEmployee(
+ sso=502000001, firstname='Pat', lastname='Printer'))
+ db.session.commit()
+ return '502000001'
+
+
+def test_create_mints_itemcode(client, auth_headers):
+ response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'},
+ headers=auth_headers)
+ assert response.status_code == 201
+ data = response.get_json()['data']
+ assert data['itemcode'] == f"3DP{data['printeditemid']:04d}"
+ assert data['quantityonhand'] == 0
+
+
+def test_update_refuses_quantity(client, auth_headers, item):
+ response = client.put(f'/api/printedparts/items/{item}',
+ json={'quantityonhand': 50}, headers=auth_headers)
+ assert response.status_code == 400
+
+
+def test_restock_writes_ledger_and_cache(client, auth_headers, app, item,
+ directory_employee):
+ response = client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 10, 'badge': directory_employee},
+ headers=auth_headers)
+ assert response.status_code == 200
+ assert response.get_json()['data']['quantityonhand'] == 10
+ with app.app_context():
+ rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all()
+ assert len(rows) == 1
+ assert rows[0].transactiontype == 'restock'
+ assert rows[0].quantitychange == 10
+ assert rows[0].employeename == 'Pat Printer'
+ cached = db.session.get(PrintedItem, item).quantityonhand
+ assert cached == sum(r.quantitychange for r in rows)
+
+
+def test_payno_badge_shape_resolves(client, auth_headers, item,
+ directory_employee):
+ response = client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 1,
+ 'badge': f'0{directory_employee}BZ'},
+ headers=auth_headers)
+ assert response.status_code == 200
+
+
+def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item,
+ directory_employee):
+ no_reason = client.post(f'/api/printedparts/items/{item}/adjust',
+ json={'quantitychange': -1,
+ 'badge': directory_employee},
+ headers=auth_headers)
+ assert no_reason.status_code == 400
+
+ below_zero = client.post(f'/api/printedparts/items/{item}/adjust',
+ json={'quantitychange': -1, 'reason': 'test',
+ 'badge': directory_employee},
+ headers=auth_headers)
+ assert below_zero.status_code == 400
+
+
+def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app,
+ item):
+ denied = client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 1, 'badge': '999999999'},
+ headers=auth_headers)
+ assert denied.status_code == 422
+
+ with app.app_context():
+ Setting.set('printedparts_unknown_badge', 'allow',
+ valuetype='string', category='printedparts')
+ db.session.commit()
+ try:
+ allowed = client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 1, 'badge': '999999999'},
+ headers=auth_headers)
+ assert allowed.status_code == 200
+ assert allowed.get_json()['data']['quantityonhand'] == 1
+ finally:
+ with app.app_context():
+ Setting.set('printedparts_unknown_badge', 'deny',
+ valuetype='string', category='printedparts')
+ db.session.commit()
+
+
+def test_anonymous_cannot_mutate(client, item):
+ assert client.post('/api/printedparts/items',
+ json={'itemname': 'X'}).status_code == 401
+ assert client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 1, 'badge': '1'}).status_code == 401
+
+
+def test_catalog_reads_require_view_permission(client, member_headers, item):
+ """Browsing the catalog is printedparts.view-gated; the kiosk stays open."""
+ assert client.get('/api/printedparts/items').status_code == 401
+ assert client.get(f'/api/printedparts/items/{item}').status_code == 401
+ assert client.get('/api/printedparts/items',
+ headers=member_headers).status_code == 403
+ assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
+
+
+def test_member_without_permission_gets_403(client, member_headers, item):
+ """Authentication alone is not authorization: a role-less user is denied."""
+ assert client.post('/api/printedparts/items', json={'itemname': 'X'},
+ headers=member_headers).status_code == 403
+ assert client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 1, 'badge': '1'},
+ headers=member_headers).status_code == 403
+
+
+def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
+ directory_employee):
+ """The kiosk endpoint needs no auth but only ever decrements stock."""
+ itemcode = '3DP-9001'
+ stocked = client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 5, 'badge': directory_employee},
+ headers=auth_headers)
+ assert stocked.status_code == 200
+
+ lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}')
+ assert lookup.status_code == 200
+
+ take = client.post('/api/printedparts/kiosk/take', json={
+ 'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2})
+ assert take.status_code == 200, take.get_json()
+ assert take.get_json()['data']['quantityonhand'] == 3
+
+ too_many = client.post('/api/printedparts/kiosk/take', json={
+ 'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99})
+ assert too_many.status_code == 400
+
+ unknown = client.post('/api/printedparts/kiosk/take', json={
+ 'itemcode': itemcode, 'badge': '111111111', 'quantity': 1})
+ assert unknown.status_code == 422
+
+ with app.app_context():
+ rows = PrintedItemTransaction.query.filter_by(
+ printeditemid=item, transactiontype='take').all()
+ assert len(rows) == 1
+ assert rows[0].quantitychange == -2
+ assert rows[0].employeename == 'Pat Printer'
+ cached = db.session.get(PrintedItem, item).quantityonhand
+ ledgersum = sum(r.quantitychange for r in
+ PrintedItemTransaction.query.filter_by(
+ printeditemid=item).all())
+ assert cached == ledgersum
+
+
+def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
+ directory_employee, monkeypatch):
+ """One alert when stock CROSSES the threshold downward; restocking above
+ rearms it; staying below does not re-fire."""
+ sent = []
+ import plugins.printedparts.api.routes as printedparts_routes
+ monkeypatch.setattr(
+ printedparts_routes, '_send_lowstock_alert',
+ lambda alerted_item: sent.append(alerted_item.itemcode))
+
+ def restock(quantity):
+ return client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': quantity,
+ 'badge': directory_employee},
+ headers=auth_headers)
+
+ def take(quantity):
+ return client.post('/api/printedparts/kiosk/take',
+ json={'itemcode': '3DP-9001',
+ 'badge': directory_employee,
+ 'quantity': quantity})
+
+ restock(10) # 10 on hand, threshold 5
+ assert take(3).status_code == 200 # 7: above threshold, no alert
+ assert sent == []
+ assert take(3).status_code == 200 # 4: CROSSES 5 -> one alert
+ assert sent == ['3DP-9001']
+ assert take(2).status_code == 200 # 2: still below, no re-fire
+ assert sent == ['3DP-9001']
+ restock(20) # 22: rearmed
+ assert take(18).status_code == 200 # 4: crosses again -> second alert
+ assert sent == ['3DP-9001', '3DP-9001']
+
+
+def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
+ item, directory_employee,
+ monkeypatch):
+ """Selected shopdb users' account emails merge with the free-text list,
+ deduped; inactive users are skipped."""
+ import shopdb.api as contract_surface
+ captured = {}
+ monkeypatch.setattr(contract_surface, 'send_email',
+ lambda to, subject, html, text=None:
+ captured.setdefault('to', to) or True)
+
+ with app.app_context():
+ from shopdb.api import User
+ from werkzeug.security import generate_password_hash
+ active = User(username='partslead', email='lead@site.test',
+ passwordhash=generate_password_hash('x'), isactive=True)
+ inactive = User(username='oldtimer', email='gone@site.test',
+ passwordhash=generate_password_hash('x'),
+ isactive=False)
+ db.session.add_all([active, inactive])
+ db.session.commit()
+ Setting.set('printedparts_alert_userids',
+ f'{active.userid},{inactive.userid}',
+ valuetype='string', category='printedparts')
+ Setting.set('printedparts_alert_email',
+ 'extra@site.test, lead@site.test',
+ valuetype='string', category='printedparts')
+ db.session.commit()
+
+ client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 10, 'badge': directory_employee},
+ headers=auth_headers)
+ take = client.post('/api/printedparts/kiosk/take',
+ json={'itemcode': '3DP-9001',
+ 'badge': directory_employee, 'quantity': 6})
+ assert take.status_code == 200 # 4 on hand: crossed threshold 5
+
+ assert captured['to'] == ['lead@site.test', 'extra@site.test']
+
+
+def test_retire_hides_and_restore_returns(client, auth_headers, item):
+ """Retire drops the item from the default list and the kiosk; restore
+ brings it back with history intact."""
+ assert client.delete(f'/api/printedparts/items/{item}',
+ headers=auth_headers).status_code == 200
+
+ listed = client.get('/api/printedparts/items',
+ headers=auth_headers).get_json()['data']
+ assert all(row['printeditemid'] != item for row in listed)
+ kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
+ assert kiosk.status_code == 404
+
+ including = client.get('/api/printedparts/items?active=false',
+ headers=auth_headers)
+ assert any(row['printeditemid'] == item
+ for row in including.get_json()['data'])
+
+ assert client.post(f'/api/printedparts/items/{item}/restore',
+ headers=auth_headers).status_code == 200
+ assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
+
+
+def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
+ """Uploads mint sequential revisions; download returns the original name."""
+ import io
+
+ first = client.post(f'/api/printedparts/items/{item}/files',
+ data={'file': (io.BytesIO(b'solid part'), 'clip_v1.stl'),
+ 'note': 'initial'},
+ headers=auth_headers,
+ content_type='multipart/form-data')
+ assert first.status_code == 201, first.get_json()
+ assert first.get_json()['data']['revision'] == 1
+
+ second = client.post(f'/api/printedparts/items/{item}/files',
+ data={'file': (io.BytesIO(b'G1 X0 Y0'), 'clip_v2.gcode')},
+ headers=auth_headers,
+ content_type='multipart/form-data')
+ assert second.get_json()['data']['revision'] == 2
+
+ bad = client.post(f'/api/printedparts/items/{item}/files',
+ data={'file': (io.BytesIO(b'x'), 'malware.exe')},
+ headers=auth_headers,
+ content_type='multipart/form-data')
+ assert bad.status_code == 400
+
+ listing = client.get(f'/api/printedparts/items/{item}/files',
+ headers=auth_headers).get_json()['data']
+ assert [f['revision'] for f in listing] == [2, 1]
+
+ fileid = listing[1]['fileid']
+ download = client.get(f'/api/printedparts/files/{fileid}/download')
+ assert download.status_code == 200
+ assert download.data == b'solid part'
+ assert 'clip_v1.stl' in download.headers['Content-Disposition']
+
+
+def test_alert_role_members_receive(client, auth_headers, app, item,
+ directory_employee, monkeypatch):
+ """Every active member of a selected role gets the alert."""
+ import shopdb.api as contract_surface
+ captured = {}
+ monkeypatch.setattr(contract_surface, 'send_email',
+ lambda to, subject, html, text=None:
+ captured.setdefault('to', to) or True)
+
+ with app.app_context():
+ from shopdb.api import User, Role
+ from werkzeug.security import generate_password_hash
+ role = Role(rolename='partscrew', description='3D parts crew')
+ member = User(username='crewone', email='crewone@site.test',
+ passwordhash=generate_password_hash('x'), isactive=True)
+ member.roles.append(role)
+ db.session.add_all([role, member])
+ db.session.commit()
+ Setting.set('printedparts_alert_roleids', str(role.roleid),
+ valuetype='string', category='printedparts')
+ db.session.commit()
+
+ client.post(f'/api/printedparts/items/{item}/restock',
+ json={'quantity': 10, 'badge': directory_employee},
+ headers=auth_headers)
+ take = client.post('/api/printedparts/kiosk/take',
+ json={'itemcode': '3DP-9001',
+ 'badge': directory_employee, 'quantity': 6})
+ assert take.status_code == 200
+ assert captured['to'] == ['crewone@site.test']