# Build a plugin from scratch: the printedparts walkthrough This is the literal, type-along guide to building a complete shopdb plugin, using the 3D-printed-parts storefront as the example. Every core stage shows the actual code; the finished implementation lives on branch `feat/printedparts-plugin` with one commit per stage, tagged `lab-stage-01` .. `lab-stage-16` - so `git show lab-stage-05` or `git diff lab-stage-04 lab-stage-05` always has the complete answer, including the long Vue files this guide abridges. What you are building: a catalog of 3D-printed parts (photo, description, quantity on hand), a stock LEDGER attributing every take/restock/adjust to a badge-scanned employee, a touch kiosk (scan bin barcode, scan badge, keypad quantity), 1x0.5in bin labels, low-stock email alerts, and reports. Spec with decision records: `docs/proposals/printedparts-plugin.md`. > **Windows / VS Code:** command examples below use the Linux venv path > `venv/bin/python`; on Windows use `venv\Scripts\python` and > `$env:FLASK_APP="shopdb"` (not `export`). Full Windows onboarding: > [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md). Know before you start - BUNDLED plugin: frontend files live in core `frontend/src/`, and three core files get small edits (api client, sidebar icon map, PLUGIN_TABLE_OWNERS). Normal for every bundled plugin. - Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`); DB names lowercase concatenated; run `bash scripts/check-naming-and-style.sh` + tests each stage; one commit per stage. - Three deliberate divergences from the scaffold, each a lesson: no AssetType (stage 1), a REAL migration baseline (stage 2), and one deliberately unauthenticated write (stage 7 - read the decision record first). --- ## Stage 0 - orientation (no code) Read the proposal. Tour `plugins/usb/` (checkout ledger + badge contract) and `plugins/measuringtools/` (post-cutover migration baseline + hooks) - the two reference implementations this build imitates. Get the dev environment running and log in. --- ## Stage 1 - scaffold, minus the AssetType ```bash flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout" ``` The scaffold assumes an ASSET-extension plugin and generates AssetType seeding. Printed parts are quantity consumables - one row is a KIND of part with a count, not a physical thing - so delete `_ensure_asset_type` and its `on_install` call, and seed the plugin's settings instead. `plugin.py` after the edit (imports/meta boilerplate unchanged from the scaffold): ```python 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'), ] 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() ``` (`Setting` and `db` come from `shopdb.api`.) `manifest.json`: ```json { "name": "printedparts", "version": "0.1.0", "description": "3D-printed parts inventory + kiosk checkout", "display_name": "3D Printed Parts", "dependencies": ["employees"], "core_version": ">=0.13.0,<1.0.0", "api_prefix": "/api/printedparts", "default_enabled": false } ``` `dependencies` is enforced (employees must be installed/enabled first - badge names come from it); `default_enabled: false` means each site opts in. See it work: `flask plugin list` shows `printedparts [Available]`. Commit + tag `lab-stage-01`. --- ## Stage 2 - models, real migration baseline, tables live ### The two models - `plugins/printedparts/models/printeditem.py` The design idea of the whole plugin: the LEDGER is the source of truth; `quantityonhand` is a cache moved in the same commit as every ledger write. ```python 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. 3DP0042') 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 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) 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) ``` (Each model also carries a `to_dict()` - see the tag; `BaseModel` supplies createddate/modifieddate/isactive.) Export both from `models/__init__.py` and return them from `get_models()`. ### Register ownership - `shopdb/plugins/alembic_template.py` ```python 'printedparts': ('printeditems', 'printeditemtransactions'), ``` ### The migration - `plugins/printedparts/migrations/` `env.py` is three lines (copy `script.py.mako` from measuringtools too): ```python import os os.environ['PLUGIN_NAME'] = 'printedparts' from shopdb.plugins.alembic_template import run_migrations # noqa: E402 run_migrations() ``` `versions/0001_printedparts_baseline.py` - post-cutover plugins CREATE their tables (unlike the ten legacy plugins whose 0001 is a stamp-only anchor): ```python from alembic import op import sqlalchemy as sa 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') ``` See it work: ``` flask plugin install printedparts; flask plugin enable printedparts mysql> SHOW TABLES LIKE 'printed%'; -- both tables mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline flask plugin upgrade-all -- printedparts: ok ``` Common errors (both hit for real while building this): - Empty `Migration error:` on install = anything breaking the models import (the alembic env imports the whole plugin package - here, the scaffold's routes.py still importing the deleted scaffold model). Fix the import. - `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py` = add `EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'`. Commit + tag `lab-stage-02`. --- ## Stage 3 - read API + list page (the first visible win) ### Backend - `plugins/printedparts/api/routes.py` ```python 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, ) from ..models import PrintedItem printedparts_bp = Blueprint('printedparts', __name__) @printedparts_bp.route('/items', methods=['GET']) @jwt_required(optional=True) # stage 16a tightens this to view-gated def list_items(): """List printed items, paginated; search + low-stock filter.""" page, per_page = get_pagination_params(request) query = PrintedItem.query if request.args.get('active', 'true').lower() != 'false': query = query.filter(PrintedItem.isactive == True) if search := request.args.get('search'): like = f'%{search}%' query = query.filter(or_( PrintedItem.itemcode.ilike(like), PrintedItem.itemname.ilike(like), PrintedItem.itemdescription.ilike(like), PrintedItem.binlocation.ilike(like), )) if request.args.get('lowstock', '').lower() == 'true': query = query.filter( PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold) query = query.order_by(PrintedItem.itemname) items, total = paginate_query(query, page, per_page) return paginated_response( [item.to_dict() for item in items], page, per_page, total) @printedparts_bp.route('/items/', methods=['GET']) @jwt_required(optional=True) def get_item(item_id: int): """Get one printed item with its recent transactions.""" item = db.session.get(PrintedItem, item_id) if not item: return error_response(ErrorCodes.NOT_FOUND, f'Printed item {item_id} not found', http_code=404) data = item.to_dict() recent = (item.transactions .order_by(db.desc('transactiondate')) .limit(25).all()) data['recenttransactions'] = [t.to_dict() for t in recent] return success_response(data) ``` ### Nav entry - on the plugin class ```python def get_navigation_items(self) -> List[dict]: return [ {'name': '3D Parts', 'icon': 'box', 'route': '/printedparts', 'position': 46}, ] ``` Gotcha hit live: icon NAMES map to Lucide components in `frontend/src/views/AppLayout.vue` (`iconMap`); unknown names render NOTHING. Add `'box': Box` to the map and the lucide import. ### Frontend 1. API client appended to `frontend/src/api/index.js`: ```javascript // 3D printed parts (printedparts plugin) export const printedpartsApi = { list(params = {}) { return api.get('/printedparts/items', { params }) }, get(printeditemid) { return api.get(`/printedparts/items/${printeditemid}`) } } ``` 2. Rename the scaffold views to `PrintedItemsList/PrintedItemDetail/ PrintedItemForm.vue` and repoint `frontend/src/router/routes/printedparts.js` (auto-discovered by the router; list/detail carry `meta.plugin`, new/edit add `requiresAuth`). 3. The list page, core of `PrintedItemsList.vue` (master template: `PrintersList.vue`; global CSS classes; full file at the tag): ```vue ``` 4. Seed two or three rows by hand purely to look at. NOTE: hand-seeded stock has no ledger backing - the stage-9 reconcile report will flag exactly these rows, which is the check working. See it work: `/printedparts` shows your parts, low-stock row red-badged. Commit + tag `lab-stage-03`. --- ## Stage 4 - catalog mutations + item photos + detail/form pages Mutations append to routes.py. The two design points: the itemcode is minted AFTER `flush()` assigns the row id, and `quantityonhand` is REFUSED here - stock only moves through the ledger (stage 5). ```python from shopdb.api import Setting from werkzeug.utils import secure_filename import glob import os from flask import current_app EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold', 'binlocation', 'printnotes') IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'} IMAGE_URL_PREFIX = '/api/printedparts/image/' def _imagedir(): return os.path.join(current_app.instance_path, 'printedpartsimages') def _mint_itemcode(item): prefix = Setting.get('printedparts_code_prefix') or '3DP' item.itemcode = f'{prefix}{item.printeditemid:04d}' @printedparts_bp.route('/items', methods=['POST']) @jwt_required() def create_item(): 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() def update_item(item_id: int): 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') ``` The photo endpoints are a verbatim copy of the models-image trio in `shopdb/core/api/models.py` (upload replaces any prior extension, serve is public because `` tags cannot carry a JWT, delete only removes files under the owned prefix) - see the tag for the three functions, they are mechanical. `PrintedItemDetail.vue` follows the unified detail skeleton (hero image, `.info-list`, transactions table) and `PrintedItemForm.vue` is a standard form + photo upload on edit; both are ordinary Vue and live at the tag in full. See it work: add a part with a photo in the UI; a `PUT` carrying `quantityonhand` returns the ledger-managed error. Commit + tag `lab-stage-04`. --- ## Stage 5 - the ledger: restock/adjust with badge attribution ### The badge resolver - `plugins/printedparts/services/badges.py` Copied from the USB contract, NOT imported from it (cross-plugin imports fail the contract guard). Final (stage-16b) form - mode-aware, because a site running the external HR directory has an empty self-hosted table: ```python import logging import re from shopdb.api import Setting, employee_connection logger = logging.getLogger(__name__) _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 _parse_badge(badge): """Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.""" badge = (badge or '').strip() if not badge: raise BadgeError('Scan or enter a badge') if badge.isdigit(): return 'sso', badge match = _PAYNO_BADGE.match(badge) if match: return 'payno', match.group(1) raise BadgeError('Unrecognized badge format') def _selfhosted_lookup(digits): try: from plugins.employees.models import DirectoryEmployee from shopdb.api import db employee = db.session.get(DirectoryEmployee, int(digits)) if employee: return digits, f'{employee.firstname} {employee.lastname}'.strip() except Exception: logger.exception('Self-hosted directory lookup failed for %s', digits) return None def _external_lookup(kind, digits): """PayNo badges resolve by their real PayNo column, recovering the SSO.""" try: conn = employee_connection() except Exception: logger.exception('HR directory connection failed') return None try: with conn.cursor() as cursor: column = 'SSO' if kind == 'sso' else 'PayNo' cursor.execute( f'SELECT SSO, First_Name, Last_Name FROM employees ' f'WHERE {column} = %s', (digits,)) row = cursor.fetchone() if row: # pymysql may return a tuple or a dict cursor - handle both. sso = str(row[0] if not isinstance(row, dict) else row['SSO']) first = row[1] if not isinstance(row, dict) else row['First_Name'] last = row[2] if not isinstance(row, dict) else row['Last_Name'] return sso, f"{(first or '').strip()} {(last or '').strip()}".strip() except Exception: logger.exception('HR directory lookup failed for %s %s', kind, digits) finally: try: conn.close() except Exception: pass return None def resolve_badge(badge): """Return (sso, name), enforcing the unknown-badge policy.""" kind, digits = _parse_badge(badge) mode = (Setting.get('employee_directory_mode') or 'selfhosted').lower() resolved = (_external_lookup(kind, digits) if mode == 'external' else _selfhosted_lookup(digits)) if resolved is None: policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower() if policy != 'allow': raise BadgeError('Badge not recognized - see the parts team') return digits, '' return resolved ``` ### The single-commit invariant + the endpoints (routes.py) ```python 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. Every write path must go through here - it is what keeps quantityonhand equal to the ledger sum.""" item.quantityonhand += quantitychange db.session.add(PrintedItemTransaction( printeditemid=item.printeditemid, transactiontype=transactiontype, quantitychange=quantitychange, employeesso=sso, employeename=name, reason=reason, )) db.session.commit() @printedparts_bp.route('/items//restock', methods=['POST']) @jwt_required() 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() def adjust_item(item_id: int): """Correct the count. 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') ``` The detail page gains Restock/Adjust modals (shared `Modal.vue` - see tag). Write the tests AS you build: minting, cache==ledger after restock, PayNo shape, reason-required + below-zero guards, policy toggle, anonymous 401 - `tests/test_plugins/test_printedparts_ledger.py` at the tag. Gotcha hit live: mutating rows through nested `app.app_context()` in tests does not reliably stick - stock the item through the real endpoint instead. See it work: restock from the detail page - quantity moves AND a named transaction row appears. Commit + tag `lab-stage-05`. --- ## Stage 6 - RBAC Declare on the plugin class: ```python def get_permissions(self) -> List: 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'), ] ``` Seeded automatically on install/enable. Gate every mutation - the decorator stacks under `@jwt_required()`: ```python @printedparts_bp.route('/items', methods=['POST']) @jwt_required() @require_permission('printedparts.create') def create_item(): ... ``` (create/update/delete/images = create/edit/delete; restock+adjust = restock; `require_permission` comes from `shopdb.api`.) Test with the `member_headers` fixture (authenticated, role-less): 403 where admin succeeds - authentication alone is not authorization. Commit + tag `lab-stage-06`. --- ## Stage 7 - the kiosk (the deliberate open write) Read the decision record in the proposal first. `POST /kiosk/take` is the product's first UNauthenticated write, held to four criteria: decrement-only, badge-attributed server-side, bounded blast radius, physically rate-limited. Put the justification in the plugin README, and expect the authz sweep to catch you (below). ### Backend - both endpoints UNdecorated ```python def _kiosk_find_item(itemcode): """Resolve a scanned or typed code to an active item. Matches the internal code OR the gage-lab tag exactly; bare keypad digits match the numeric tail of EITHER identifier, and only when exactly one active item matches (see stage 17).""" scanned = (itemcode or '').strip().upper() item = PrintedItem.query.filter( or_(PrintedItem.itemcode == scanned, PrintedItem.gagelabtag == scanned), PrintedItem.isactive == True).first() if not item and scanned.isdigit(): wanted = int(scanned) matches = [] for candidate in PrintedItem.query.filter_by(isactive=True).all(): for value in (candidate.itemcode, candidate.gagelabtag): tail = ''.join(ch for ch in (value or '') if ch.isdigit()) if tail and int(tail) == wanted: matches.append(candidate) break if len(matches) == 1: item = matches[0] return item @printedparts_bp.route('/kiosk/item/', methods=['GET']) def kiosk_item(itemcode): item = _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}. Error strings are shown VERBATIM on the kiosk - write them for a person standing at a screen.""" 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') ``` ### The keypad component - `frontend/src/components/TouchKeypad.vue` ```vue ``` (Terminal-style CSS - fixed 3-column grid, big targets, press feedback - at the tag.) ### The kiosk view - `frontend/src/views/printedparts/PartsKiosk.vue` Full-screen, no auth, registered TOP-LEVEL beside `/shopfloor` in `frontend/src/router/index.js` (outside AppLayout, `meta.plugin` only): ```javascript { path: '/parts-kiosk', name: 'parts-kiosk', component: () => import('../views/printedparts/PartsKiosk.vue'), meta: { plugin: 'printedparts' } }, ``` Three steps driven by ONE hidden always-focused input that consumes keyboard-wedge scans (scanners type the code + Enter) for whichever step is active. The two mechanisms that matter: ```vue ``` ```javascript function focusWedge(event) { // Tapping a visible input/button must keep it - only reclaim focus for // the wedge scanner from dead space. (Skipping this guard steals focus // from the manual-entry field the moment it is tapped - hit live.) const tag = event?.target?.tagName if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || tag === 'BUTTON' || tag === 'A') return wedgeInput.value?.focus() } function onWedgeEnter() { const scanned = wedgeBuffer.value.trim() wedgeBuffer.value = '' if (!scanned) return if (step.value === 'item') lookupItem(scanned) else if (step.value === 'badge') acceptBadge(scanned) } ``` Manual fallbacks use the TouchKeypad: badge entry is digits (an SSO), and item entry is bare digits matched server-side against the numeric tail of either the internal code or the gage-lab tag (unique match only) - no alphanumeric on-screen keyboard needed. Success screen auto-resets after a few seconds. Full component (~250 lines) at the tag. ### The authz sweep catches you - on purpose The full suite fails: `test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`. That sweep asserts EVERY mutating route rejects a role-less user - the net against accidentally-open writes. Yours is open on purpose, so exempt it EXPLICITLY with a comment pointing at the decision record: ```python EXEMPT_ENDPOINTS = {..., # Deliberately open kiosk write: decrement-only, # badge-attributed server-side. Decision record in # docs/proposals/printedparts-plugin.md. 'printedparts.kiosk_take'} ``` See it work: scan/type a code -> item card -> badge -> keypad -> TAKE; stock drops with your name in the ledger; over-take and unknown-badge produce friendly messages. Commit + tag `lab-stage-07`. --- ## Stage 8 - 1in x 0.5in bin labels A plugin OWNS its label page (the USB precedent) - parts are not assets, so they do not join the shared asset-label TYPE_CONFIG. New public route beside `/print/usb-labels`, view `frontend/src/views/print/PrintedPartsLabels.vue`. The pieces that matter: ```javascript // CODE128 of the short item code fits 1x0.5in with comfortable scanner // tolerance; a QR at this size would be marginal. JsBarcode(element, label.itemcode, { format: 'CODE128', displayValue: false, width: 1.4, height: 26, margin: 0 }) ``` ```css /* 1in x 0.5in roll stock: one label per page */ @media print { @page { size: 1in 0.5in; margin: 0; } .no-print { display: none; } .bin-label { page-break-after: always; break-after: page; } } .bin-label { width: 1in; height: 0.5in; display: flex; flex-direction: column; align-items: center; justify-content: center; } .bin-barcode { width: 0.92in; height: 0.3in; } .bin-code { font-size: 6.5pt; font-family: monospace; } ``` Multi-select + per-item copies; `?item=` preselects (the Detail page's Bin Label button). Full view at the tag. See it work: print preview shows one label per page; a printed (or phone-scanned on-screen) barcode pulls the right item up at the kiosk. Label -> scan -> badge -> take -> named ledger row is the demo moment. Commit + tag `lab-stage-08`. --- ## Stage 9 - reports + the reconcile check Three jwt-optional endpoints with `?format=csv` (local CSV helper - `generate_csv` is not on the contract surface), merged into `/reports` via the hook: ```python 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'}, ] ``` The stock report's heart - the reconcile check: ```python # int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a # string (gotcha hit live). 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()} ... 'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0), ``` `ledgerdelta` must be 0 for every ledger-driven item; nonzero flags a write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows show here, proving the check works. Deferred by decision: `get_dashboard_widgets` (needs a core component) and a settings card (needs a page - stage 12 adds both). Commit + tag `lab-stage-09`. --- ## Stage 10 - closeout 1. Lifecycle: `flask plugin disable printedparts` - nav, reports, grantable permissions vanish; API routes only after a RESTART (blueprints register at startup). Re-enable. 2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` + `install/enable/upgrade-all` - green with zero manual SQL. 3. Full suite: backend pytest, vitest, frontend build, naming hook. 4. Walk `PLUGIN-GUIDE.md` section 12's End checklist. Done means: a colleague can clone the repo, enable the plugin, print a bin label, and take a part at the kiosk with their badge - without asking you anything. Commit + tag `lab-stage-10`. --- ## Field extensions (stages 11-16): how plugins actually finish Each stage below landed after deployment, from a real request or a real failure. Summaries here; complete diffs at the tags. - **11 - low-stock email alerts** (`lab-stage-11`): a worked CONTRACT ADDITION - `send_email`/`send_alert` join `shopdb.api`, `__contract_version__` bumps, PLUGIN-HOOKS.md updates (the docs-drift guard fails until it does), the manifest pins the new floor. The alert fires inside `_ledger_write` only when a decrement CROSSES the item's threshold (crossing = natural debounce; restocking above re-arms), best-effort AFTER the commit so mail trouble can never fail a take. - **12 - admin settings page** (`lab-stage-12`): a page under `settings/printedparts` in the PLUGIN's router file (any `settings/...` path auto-nests into the two-pane rail) + `get_settings_cards` for the catalog card. - **13 - recipients from shopdb users** (`lab-stage-13`): `User` joins the surface (0.13.0); checkbox picker; account emails merged + deduped with free-text; inactive users skipped. - **14 - retire/restore + dashless codes** (`lab-stage-14`): soft-delete needs UI; Restore is its own permission-gated POST (the generic update cannot flip isactive); item codes are immutable once printed on a bin. - **15 - print-file revisions + role recipients** (`lab-stage-15`): the plugin's FIRST incremental migration (`0002_printeditemfiles`) - the ADR-008 payoff. Append-only revisions (revision = max+1, uploader from the JWT, extension allowlist, 100 MB cap, download under the original name). Gotcha: a VARCHAR(255) UNIQUE on utf8mb4 dies with error 1071 in the per-plugin chain (no core ROW_FORMAT hook) - size unique columns 191 or less. `Role` joins the surface; every active member of selected roles is folded into the alert recipients. - **16a - catalog goes staff-only**: reads move behind `require_permission('printedparts.view')`; routes + label page gain `requiresAuth`. Structurally still open: image serve and file download (``/anchor cannot carry a JWT), kiosk (decision record), reports (product convention). - **16b - badges at an external-HR site**: the resolver originally read only the self-hosted table - empty under external mode, so every kiosk badge hit the deny policy. Lesson: anything resolving PEOPLE must honor the site's directory mode (the stage-5 code above is the corrected version). - **16 - touchscreen findings** (`lab-stage-16`): the focus-steal guard and keypad-driven manual entry (the kiosk resolver above is the stage-17 version). - **17 - the gage-lab asset tag** (`lab-stage-17`): the field team assigns real WJRP asset numbers at the gage lab, so identity split in two: the internal `itemcode` stays auto-minted (stable, encodes the row id) and a new optional UNIQUE `gagelabtag` carries the lab's number (migration 0003). Search covers it; the kiosk resolves scans and bare keypad digits against the numeric tail of EITHER identifier, unique-match only. Lesson: when the real world already numbers things, model their identifier alongside yours instead of fighting over one field. Also in this stage: the print-files table became stacked revision cards after the table forced horizontal scrolling in its column. Post-stage polish (untagged commits): the kiosk launches from the sidebar's "Displays" section (beside Shopfloor Dashboard / TV Slideshow, plugin-gated, new tab), and the keypad was restyled into a terminal-style panel after the first hands-on review. Looks are requirements on a kiosk. The closing lesson: the spec carried this build to stage 10; every stage after came from deployment and real users. Plugins are finished by the floor, not by the spec. --- ## Contributing your plugin via GitHub The public home is https://github.com/ge-aero/shopdb-flask. Development flow for a contributor: 1. **Clone and branch** (never work on main): ```bash git clone https://github.com/ge-aero/shopdb-flask.git cd shopdb-flask git checkout -b feat/ ``` Set up the dev environment per the README (venv + requirements, MySQL, `flask db upgrade`, `flask plugin upgrade-all`, seeds, npm install). 2. **Build in stage-sized commits** exactly as this lab does - each commit a working checkpoint with its tests. Subject line: short, plain English, present tense ("printedparts stage 5: the ledger"); body says WHY. 3. **Before every push**, run the three gates CI runs (in VS Code: the **Check: naming + tests + build** task). By hand in PowerShell: ```powershell venv\Scripts\python -m pytest tests/ -q cd frontend; npx vitest run; npm run build; cd .. bash scripts/check-naming-and-style.sh # naming - runs via Git Bash ``` 4. **Push your branch and open a Pull Request** against `main`: ```bash git push -u origin feat/ ``` In the PR description: what the plugin does, which hooks it implements, any contract additions (these need a version bump + PLUGIN-HOOKS.md update in the same PR), and any deliberate security posture (open endpoints demand a decision record like stage 7's). 5. **Review checklist** (what the maintainer looks for): contract purity (imports only via shopdb.api - the guard test), naming convention, per-plugin migration chain + PLUGIN_TABLE_OWNERS entry + the expected-head declaration, permissions declared AND enforced, tests for the invariants (not just the happy path), and `default_enabled` correct for the plugin's nature. 6. **After approval** the maintainer lands the change on the internal mainline and the next published release commit includes it - your PR is then closed as merged. Day-to-day development history lives on the internal server; GitHub carries the published line, so do not be surprised when your commits arrive squashed or folded into a release commit. --- ## Where each pattern lives (cheat sheet) | Need | Copy from | |---|---| | Standalone (non-asset) plugin shape | `plugins/knowledgebase/` | | Checkout/ledger + badge contract | `plugins/usb/` | | Real-baseline plugin migration | `plugins/measuringtools/migrations/` | | Blueprint style, pagination, authz | `plugins/measuringtools/api/routes.py` | | Image upload/serve/delete | `shopdb/core/api/models.py` | | Open kiosk endpoints precedent | `plugins/employees/api/routes.py`, `plugins/notifications/api/routes.py` | | Plugin-owned label print view | `frontend/src/views/print/USBLabelBatch.vue` | | Barcode/QR rendering | JsBarcode in `AssetLabel.vue`, `qrLogo.js` | | Kiosk route posture | `/shopfloor` in `frontend/src/router/index.js` | | List/Detail master templates | `PrintersList.vue`, `PrinterDetail.vue` | | Reports hook + CSV | `plugins/warranty/` + `shopdb/core/api/reports.py` | | Permissions declaration | `plugins/usb/plugin.py::get_permissions` | | The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..17` |