diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index ec19d772..ac1fceec 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1126,3 +1126,61 @@ export const measuringtoolsApi = { } } } + +// 3D printed parts (printedparts plugin) +export const printedpartsApi = { + list(params = {}) { + return api.get('/printedparts/items', { params }) + }, + get(printeditemid) { + return api.get(`/printedparts/items/${printeditemid}`) + }, + create(data) { + return api.post('/printedparts/items', data) + }, + update(printeditemid, data) { + return api.put(`/printedparts/items/${printeditemid}`, data) + }, + remove(printeditemid) { + return api.delete(`/printedparts/items/${printeditemid}`) + }, + restore(printeditemid) { + return api.post(`/printedparts/items/${printeditemid}/restore`) + }, + uploadImage(printeditemid, file) { + const formData = new FormData() + formData.append('file', file) + return api.post(`/printedparts/items/${printeditemid}/image`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + deleteImage(printeditemid) { + return api.delete(`/printedparts/items/${printeditemid}/image`) + }, + restock(printeditemid, data) { + return api.post(`/printedparts/items/${printeditemid}/restock`, data) + }, + adjust(printeditemid, data) { + return api.post(`/printedparts/items/${printeditemid}/adjust`, data) + }, + kioskItem(itemcode) { + return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`) + }, + kioskTake(data) { + return api.post('/printedparts/kiosk/take', data) + }, + listFiles(printeditemid) { + return api.get(`/printedparts/items/${printeditemid}/files`) + }, + uploadFile(printeditemid, file, note) { + const formData = new FormData() + formData.append('file', file) + if (note) formData.append('note', note) + return api.post(`/printedparts/items/${printeditemid}/files`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + removeFile(fileid) { + return api.delete(`/printedparts/files/${fileid}`) + } +} diff --git a/frontend/src/components/TouchKeypad.vue b/frontend/src/components/TouchKeypad.vue new file mode 100644 index 00000000..6baa0b8b --- /dev/null +++ b/frontend/src/components/TouchKeypad.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index ade1a1ec..2405699c 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -65,6 +65,14 @@ const routes = [ name: 'shopfloor', component: () => import('../views/ShopfloorDashboard.vue') }, + { + // Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad. + // Open on purpose - see the decision record in the printedparts proposal. + path: '/parts-kiosk', + name: 'parts-kiosk', + component: () => import('../views/printedparts/PartsKiosk.vue'), + meta: { plugin: 'printedparts' } + }, { path: '/tv', name: 'tv', @@ -109,6 +117,14 @@ const routes = [ component: () => import('../views/print/USBLabelBatch.vue'), meta: { plugin: 'usb' } }, + { + // Unlike the other print pages this one requires login: it lists the + // whole catalog, which is printedparts.view-gated at the API. + path: '/print/printedparts-labels', + name: 'print-printedparts-labels', + component: () => import('../views/print/PrintedPartsLabels.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, { path: '/', component: AppLayout, diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js new file mode 100644 index 00000000..8b19b25c --- /dev/null +++ b/frontend/src/router/routes/printedparts.js @@ -0,0 +1,41 @@ +/** + * Printedparts plugin routes. + * + * Auto-discovered by the router via import.meta.glob, so no registration + * edit is needed. Every route carries meta.plugin 'printedparts' so the ADR-009 + * guard redirects to the dashboard when the printedparts backend plugin is + * disabled. Form routes add requiresAuth so anonymous users cannot reach + * create or edit. + */ +export default [ + { + path: 'printedparts', + name: 'printedparts', + component: () => import('../../views/printedparts/PrintedItemsList.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/new', + name: 'printedparts-new', + component: () => import('../../views/printedparts/PrintedItemForm.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/:id', + name: 'printedparts-detail', + component: () => import('../../views/printedparts/PrintedItemDetail.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'printedparts/:id/edit', + name: 'printedparts-edit', + component: () => import('../../views/printedparts/PrintedItemForm.vue'), + meta: { requiresAuth: true, plugin: 'printedparts' } + }, + { + path: 'settings/printedparts', + name: 'settings-printedparts', + component: () => import('../../views/settings/PrintedPartsSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printedparts' } + } +] diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 44e61ee1..530584b3 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -27,6 +27,8 @@ Shopfloor Dashboard TV Slideshow + Parts Kiosk Settings @@ -101,13 +103,14 @@ import ToastHost from '../components/ToastHost.vue' import { Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, - KeyRound, LogOut + Box, KeyRound, LogOut } from 'lucide-vue-next' import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' import { dashboardApi, notificationsApi } from '../api' import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings' import { withBase } from '../utils/basePath' +import { isPluginEnabled } from '../composables/enabledPlugins' const router = useRouter() const route = useRoute() @@ -147,6 +150,7 @@ const iconMap = { 'image': Image, 'shield': ShieldCheck, 'ruler': Ruler, + 'box': Box, } // Default navigation (used as fallback if API fails) diff --git a/frontend/src/views/print/PrintedPartsLabels.vue b/frontend/src/views/print/PrintedPartsLabels.vue new file mode 100644 index 00000000..c2dd1c6f --- /dev/null +++ b/frontend/src/views/print/PrintedPartsLabels.vue @@ -0,0 +1,207 @@ + + + + + + + diff --git a/frontend/src/views/printedparts/PartsKiosk.vue b/frontend/src/views/printedparts/PartsKiosk.vue new file mode 100644 index 00000000..543d5479 --- /dev/null +++ b/frontend/src/views/printedparts/PartsKiosk.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemDetail.vue b/frontend/src/views/printedparts/PrintedItemDetail.vue new file mode 100644 index 00000000..335895b8 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemDetail.vue @@ -0,0 +1,349 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemForm.vue b/frontend/src/views/printedparts/PrintedItemForm.vue new file mode 100644 index 00000000..10058d06 --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemForm.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/src/views/printedparts/PrintedItemsList.vue b/frontend/src/views/printedparts/PrintedItemsList.vue new file mode 100644 index 00000000..09a0c46a --- /dev/null +++ b/frontend/src/views/printedparts/PrintedItemsList.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/frontend/src/views/settings/PrintedPartsSettings.vue b/frontend/src/views/settings/PrintedPartsSettings.vue new file mode 100644 index 00000000..fd2469b2 --- /dev/null +++ b/frontend/src/views/settings/PrintedPartsSettings.vue @@ -0,0 +1,177 @@ + + + + + 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']