diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md index 1aea041..da2e08b 100644 --- a/docs/PLUGIN-LAB-PRINTEDPARTS.md +++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md @@ -1,447 +1,1165 @@ -# Plugin lab: build the printedparts plugin +# Build a plugin from scratch: the printedparts walkthrough -A hand-held, build-along tutorial: construct the 3D-printed-parts storefront + -kiosk plugin specified in `docs/proposals/printedparts-plugin.md`, stage by -stage, seeing each piece work before moving on. Written for someone building -their first plugin. The finished implementation lives on the -`feat/printedparts-plugin` branch with one commit per stage, tagged -`lab-stage-01` .. `lab-stage-10` - when stuck, `git diff lab-stage-03 -lab-stage-04` shows exactly what a stage changes. +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`. Know before you start -- You are building a BUNDLED plugin inside this repo. Plugin frontend files - live in core (`frontend/src/...`), and three core files get small edits: - `frontend/src/api/index.js`, the sidebar icon map in `AppLayout.vue`, and - `PLUGIN_TABLE_OWNERS` in `shopdb/plugins/alembic_template.py`. Normal for - all bundled plugins - external-plugin UI packaging does not exist yet. -- Three deliberate divergences from the scaffold, each a teaching point: - (1) NO AssetType - these are quantity consumables, not ADR-001 assets - (stage 1); (2) the migration is a REAL baseline that creates tables, not a - stamp-only anchor (stage 2); (3) the kiosk take endpoint is the product's - first UNauthenticated write - read the decision record in the proposal - before stage 7. +- 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 (`quantityonhand`); run - `bash scripts/check-naming-and-style.sh` + the tests at every stage; one - git commit per stage. - -Prerequisites: working dev environment (README quick start), skim -`PLUGIN-QUICKSTART.md`, `PLUGIN-GUIDE.md` (the measuringtools exemplar this -lab imitates), `PLUGIN-HOOKS.md`, and `CONTRIBUTING.md` naming rules. + 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 the two reference plugins you will imitate: -`plugins/usb/` (checkout ledger + badge contract) and -`plugins/measuringtools/` (post-cutover migration baseline, hooks). -See it work: run the app, log in. +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" ``` -Walk the generated tree. Then diverge: -1. In `plugins/printedparts/plugin.py`, DELETE `_ensure_asset_type` and its - `on_install` call - a printed part is a kind-with-a-count, not an asset. - Replace it with settings seeding (see the tagged commit): three Setting - rows, category `printedparts` - `printedparts_code_prefix` (3DP), - `printedparts_default_threshold` (5), `printedparts_unknown_badge` (deny). -2. `manifest.json`: `"dependencies": ["employees"]` (badge names), - `"core_version": ">=0.11.0,<1.0.0"`, `"default_enabled": false`, - `"display_name": "3D Printed Parts"`. +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): -See it work: `flask plugin list` shows printedparts [Available]. -Commit: `printedparts stage 1: scaffold, no AssetType, manifest per spec` +```python + def on_install(self, app: Flask) -> None: + with app.app_context(): + self._seed_settings() + logger.info('Printedparts plugin installed') -## Stage 2 - models + real migration baseline + tables live + 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() -1. Replace the scaffold model with `models/printeditem.py`: `PrintedItem` - (itemcode unique+indexed, itemname, itemdescription, imageurl, - quantityonhand, lowstockthreshold, binlocation, printnotes) and - `PrintedItemTransaction` (printeditemid FK CASCADE, transactiontype - take/restock/adjust, SIGNED quantitychange, employeesso, employeename, - reason, transactiondate) - both on `BaseModel`. The ledger is the source - of truth; quantityonhand is a cache moved in the same commit. -2. Update `models/__init__.py` exports and `plugin.py` `get_models`. -3. Register in `PLUGIN_TABLE_OWNERS` (`shopdb/plugins/alembic_template.py`): - `'printedparts': ('printeditems', 'printeditemtransactions'),` -4. `migrations/`: copy `script.py.mako` + the 3-line `env.py` from - measuringtools (change PLUGIN_NAME), then hand-write - `versions/0001_printedparts_baseline.py` with explicit `op.create_table` - for both tables + the three transaction indexes. -5. The scaffold's `api/routes.py` still imports the model you deleted - make - the blueprint import cleanly (a placeholder route is fine for now). + 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 (idempotent) +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): -- An empty `Migration error:` on install. Root cause: anything that makes - `plugins.printedparts.models` fail to import - the alembic env imports the - models package, which pulls in plugin.py and routes.py. Here it was the - scaffold routes importing the deleted model; the ImportError gets caught - and retried down a subprocess path with no stderr. Fix the import, not the - migration. -- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py`: add - `EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'` - - the guard makes every new plugin declare its expected head on purpose. +- 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) -1. Real `api/routes.py`: `GET /items` (jwt-optional; pagination via - `get_pagination_params`/`paginate_query`, search across - code/name/description/bin, `?lowstock=true` filter) and - `GET /items/` returning the item + its 25 most recent transactions. -2. `get_navigation_items` on the plugin: `{'name': '3D Parts', 'icon': 'box', - 'route': '/printedparts', 'position': 46}`. -3. Frontend: paste the `printedpartsApi` client into - `frontend/src/api/index.js` (list/get for now, paths under - `/printedparts/items`); rename the scaffold views to - `PrintedItemsList/PrintedItemDetail/PrintedItemForm.vue` and repoint - `router/routes/printedparts.js`; build the list page from - `PrintersList.vue` (global styles, `useListQuery`, PaginationBar) with an - image thumb column and a red/green quantity badge vs the threshold. -4. Seed two or three rows by hand (SQL or flask shell) purely to have - something 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. +### Backend - `plugins/printedparts/api/routes.py` -See it work: navigate to `/printedparts` - your parts in a table, low-stock -row red-badged. Everything before this moment was invisible; from here on -every stage shows on screen. +```python +from flask import Blueprint, request +from flask_jwt_extended import jwt_required +from sqlalchemy import or_ -Common error: nav icon missing. The sidebar maps icon NAMES to Lucide -components in `AppLayout.vue` (`iconMap`); an unknown name renders nothing. -Add `'box': Box` to the map (and the import) or reuse an existing name. +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 6a 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 -1. `POST /items` mints the itemcode AFTER `db.session.flush()` assigns the - id: `-` with the prefix from Setting. `PUT /items/` - updates catalog fields but REFUSES `quantityonhand` (ledger-managed). - `DELETE` soft-retires. All `@jwt_required()` (permissions come in - stage 6). -2. Image trio copied from `shopdb/core/api/models.py`: POST/DELETE - `/items//image` + public `GET /image/`, storing - `printeditem-.` in `instance/printedpartsimages/`, wiping prior - extensions on replace, prefix-guarded delete. -3. `PrintedItemDetail.vue` on the unified detail skeleton (hero image, info - list, transactions table); `PrintedItemForm.vue` create/edit + photo - upload on edit; extend the api client. +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). -See it work: add a part with a photo in the UI; thumbnail on the list, hero -on the detail; `PUT` with `quantityonhand` returns the ledger-managed error. +```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 -1. `services/badges.py` - COPY the USB badge contract (do not import - `plugins.usb`; cross-plugin imports fail the contract test): - `^0(\d+)BZ$` PayNo wrap, all-digits SSO, name lookup via the employees - plugin `DirectoryEmployee` (lazy import, graceful fallback), and the - `printedparts_unknown_badge` policy - deny raises a kiosk-displayable - `BadgeError`, allow records the SSO with an empty name. -2. `_ledger_write(item, type, change, sso, name, reason)` - THE invariant: - append the transaction row and move the cached quantity in ONE commit. - Every write path goes through it. -3. `POST /items//restock` {quantity, badge} and `/adjust` - {quantitychange, reason, badge}; adjust requires a reason and refuses to - drive stock below zero. -4. Detail page: Restock/Adjust modals (shared `Modal.vue`). -5. Tests as you go: minting, cache==ledger after a restock, the PayNo badge - shape, reason-required + below-zero guards, the policy toggle, 401 for - anonymous. See `tests/test_plugins/test_printedparts_ledger.py`. +### The badge resolver - `plugins/printedparts/services/badges.py` -See it work: restock from the detail page with your SSO - quantity moves AND -a named transaction row appears. +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: -Common error: in tests, mutating rows through a nested `app.app_context()` -does not reliably stick in the sqlite test env - stock the item through the -real restock endpoint instead (also more honest). +```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: + sso = str(row['SSO']) + name = f"{(row['First_Name'] or '').strip()} " \ + f"{(row['Last_Name'] or '').strip()}".strip() + return sso, name + 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 -1. `get_permissions` on the plugin: view/create/edit/delete/restock, category - `printedparts` (seeded automatically on install/enable and by - `flask seed permissions`). -2. Add `@require_permission('printedparts.')` under `@jwt_required()` on - every mutation: create/edit/delete/image = create/edit/delete; restock + - adjust = restock. -3. Test with the `member_headers` fixture (authenticated, role-less): 403 - where admin succeeds - authentication alone is not authorization. +Declare on the plugin class: -See it work: the permissions appear in the role grid (Settings > Roles), and -the member test passes. +```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. The take endpoint must stay: -decrement-only, badge-attributed server-side, bounded, physically -rate-limited. Put the justification in the plugin README. +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). -1. Backend, both UNdecorated: `GET /kiosk/item/` (summary for a - scanned bin code) and `POST /kiosk/take` {itemcode, badge, quantity} - - validate active item, 1 <= qty <= onhand, resolve the badge, then - `_ledger_write(..., 'take', -quantity, ...)`. Error strings are shown - verbatim on the kiosk - write them for a person standing at a screen. -2. `TouchKeypad.vue` - net-new, dumb 3x4 grid emitting digit/clear/backspace. -3. `PartsKiosk.vue` + a top-level `/parts-kiosk` route registered beside - `/shopfloor` in `router/index.js` (NO requiresAuth, outside AppLayout, - `meta.plugin` so a disabled plugin dead-ends). Three steps - scan item, - scan badge, keypad quantity - driven by ONE hidden always-focused input - that consumes keyboard-wedge scans (scanners type the code + Enter) for - whichever step is active; manual type-in fallbacks for damaged labels. - Success screen auto-resets after a few seconds. -4. Kiosk test: open access, over-take guard, unknown-badge 422, and - cache==ledger afterward. +### Backend - both endpoints UNdecorated -See it work: full walkthrough in a browser - type a code, badge in, keypad 2, -TAKE - stock drops with your name in the ledger. +```python +def _kiosk_find_item(itemcode): + """Resolve a scanned or typed code to an active item. -Common error (by design): the full suite fails with + Accepts the full code (WJRP0042) or bare digits from the touch keypad - + the digits in a minted code ARE the row id, so id lookup keeps working + even for labels printed under an older prefix.""" + itemcode = (itemcode or '').strip() + item = PrintedItem.query.filter( + PrintedItem.itemcode == itemcode, + PrintedItem.isactive == True).first() + if not item and itemcode.isdigit(): + 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 = _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 resolved by row id server-side - 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 -framework's net against accidentally-open writes. Your kiosk take is open on -purpose, so add `printedparts.kiosk_take` to EXEMPT_ENDPOINTS with a comment -pointing at the decision record. The net stays; the exception is explicit -and reviewable. +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 -1. `frontend/src/views/print/PrintedPartsLabels.vue` + a public - `/print/printedparts-labels` route beside `/print/usb-labels` (a plugin - OWNS its label page - the USB precedent; parts are not in the asset-label - TYPE_CONFIG because they are not assets). -2. The label: CODE128 of the itemcode via JsBarcode - (`{format:'CODE128', displayValue:false, width:1.4, height:26, margin:0}`) - + the code text at ~6.5pt. A QR at 0.4in is at the edge of scanner - tolerance; CODE128 of `3DP-0042` is comfortable. -3. Roll stock = one label per page: a global (unscoped) print style with - `@page { size: 1in 0.5in; margin: 0 }` and `page-break-after: always` on - each `.bin-label`. Multi-select + per-item copies; `?item=` - preselects (the Detail page's Bin Label button). +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: -See it work: print preview shows one 1x0.5 label per page; scan the printed -barcode (or the on-screen one with a phone scanner app) into the kiosk - -label -> scan -> badge -> take -> ledger is the demo moment. +```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 -1. Three jwt-optional endpoints in the plugin blueprint, each honoring - `?format=csv` (local CSV helper - `generate_csv` is not on the contract - surface): `/reports/stock`, `/reports/consumption?days=N`, - `/reports/by-person?days=N`. -2. The stock report's `ledgerdelta` column = cached quantityonhand minus the - ledger SUM per item. Always 0 for ledger-driven stock; nonzero flags a - write path that bypassed `_ledger_write` - your stage-3 hand-seeded rows - show up here, proving the check works. -3. `get_reports` on the plugin (endpoint-style entries, categories - inventory/usage) - they merge into `GET /api/reports` and the /reports hub - while the plugin is enabled. +Three jwt-optional endpoints with `?format=csv` (local CSV helper - +`generate_csv` is not on the contract surface), merged into `/reports` via +the hook: -Common error: MySQL `SUM()` returns Decimal; `int()` it or the JSON carries -strings. +```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'}, + ] +``` -Deferred by decision: `get_dashboard_widgets` (predates the ADR-010 data-only -renderers; needs a core component) and a Settings card (needs a settings page -to link). Reports are the monitoring surface. +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, and - grantable permissions disappear; API routes only disappear after a - RESTART (blueprints register at startup - the guide's section 12 gotcha). - Re-enable. +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` + - `flask plugin install/enable printedparts` + `upgrade-all` - green with - zero manual SQL. + `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. - -## Stage 11 (extension) - low-stock email alerts - -Per-item thresholds already exist; alerting on them is a worked example of a -CONTRACT ADDITION, because the mailer was not on the plugin surface: -1. Export `send_email`/`send_alert` from `shopdb/api/__init__.py`, bump - `__contract_version__` 0.11.0 -> 0.12.0, and update PLUGIN-HOOKS.md - the - docs-drift guard test fails until the doc's version example matches. - Manifest pins `core_version >=0.12.0` since the plugin now needs it. -2. Fire the alert inside `_ledger_write` when a DECREMENT crosses the - threshold (before > threshold >= after). Crossing, not being-below, is the - natural debounce: one alert per depletion, restocking above rearms. - Best-effort try/except AFTER the commit - mail failure must never fail - the take. -3. Recipients: Setting `printedparts_alert_email` (comma-separated), empty - falls back to the site's alert_recipients via `send_alert`. Seed the new - setting in on_enable too (idempotent) so already-installed sites get it. -4. Test with a monkeypatched sender: no alert above threshold, one on the - crossing, no re-fire while below, rearm after restock (see - `test_lowstock_alert_fires_on_crossing_only`). - -## Stage 12 (extension) - the admin settings page - -A get_settings_cards card needs a PAGE to link, which is why stage 9 deferred -it. The page is ordinary: -1. `frontend/src/views/settings/PrintedPartsSettings.vue` - load the four - keys via `settingsApi.list({category: 'printedparts'})`, save each with - `settingsApi.update(key, value)` (admin-gated server-side). -2. Route in the PLUGIN's router file with path `settings/printedparts` + - `requiresAuth, requiresAdmin, plugin` meta - the router shell - automatically nests any `settings/...` path under the two-pane settings - rail. -3. `get_settings_cards` on the plugin pointing at `/settings/printedparts` - - the card appears in the rail's catalog while the plugin is enabled. - -## Stage 13 (extension) - alert recipients picked from shopdb users - -Free-text emails rot; user accounts do not. Another contract addition: -`User` joins the surface (0.13.0 - export, PLUGIN-HOOKS, version bump, the -docs-drift guard again). -1. Setting `printedparts_alert_userids` (comma-separated user ids), seeded - beside the others. -2. `_alert_recipients()`: resolve each selected id to an ACTIVE user's - account email, merge with the free-text list, dedupe order-preserving; - empty result still falls back to the site alert_recipients. -3. Settings page: checkbox picker over `usersApi.list()` (the page is - admin-only, matching the endpoint), saving joined ids. -4. Test: active user's email + free-text merge deduped, inactive user - skipped (`test_alert_recipients_merge_users_and_freetext`). - -## Stage 14 (extension) - retire/restore in the UI, dashless codes - -Field feedback stage: the soft-delete endpoint existed with no button, and -the site wanted `WJRP0042`, not `WJRP-0042`. -1. Detail gains Retire (confirm dialog; item leaves the storefront and the - kiosk 404s its code, history and label intact) and Restore; the list - gains an Include-retired toggle (`?active=false`) with a Retired badge. - Restore is its own POST gated by printedparts.delete - PUT deliberately - cannot flip isactive. -2. Minting drops the dash: `f'{prefix}{id:04d}'`. Existing items keep their - codes - itemcode is an immutable label once printed on a bin. - -## Stage 15 (extension) - print-file revisions + role-based alerts - -Two more field requests, and the plugin's FIRST incremental migration: -1. `printeditemfiles` (append-only revisions of the STL/3MF/gcode per item) - arrives as `0002_printeditemfiles` on top of the 0001 baseline - the - ADR-008 payoff: the plugin evolves its own schema, `flask plugin - upgrade-all` applies it, the core chain never hears about it. Update - PLUGIN_TABLE_OWNERS and the guard test's expected head. - Gotchas hit live: (a) MySQL 5.6 dev box - a VARCHAR(255) UNIQUE on - utf8mb4 dies with error 1071 because the per-plugin chain does not apply - the core env's ROW_FORMAT=DYNAMIC hook; size unique columns to 191 or - less (191*4 = 764 bytes fits the 767 prefix). (b) The dev container's - innodb_large_prefix globals reset on restart (documented dev caveat). -2. Upload endpoint assigns revision = max+1, stores - `printeditem--rev` in `instance/printedpartsfiles/` - (extension allowlist, 100 MB cap), records uploader from the JWT. - Download serves the ORIGINAL filename; delete (permission-gated) exists - for wrong-file mistakes, otherwise history is append-only. Detail page - gains the revision table with a "current" badge on the newest. -3. Role-based alert recipients: `Role` joins the 0.13.0 surface beside User; - Setting `printedparts_alert_roleids`; `_alert_recipients` folds in every - ACTIVE member of each selected role (role.users backref), deduped with - the user picks and free-text; settings page gains a role picker. - -## Stage 16a (extension) - view permission on the catalog - -The catalog started with open reads (the product's jwt-optional list -convention). Field decision: browsing and managing the parts catalog is -staff-only, so the reads (list, detail, file listings) move behind -`@jwt_required()` + `require_permission('printedparts.view')`, the -`/printedparts` routes and the label print page gain `requiresAuth`, and -the view permission becomes meaningful in the role grid. -Deliberately still open: the kiosk endpoints (decision record), the image -serve and file download (fetched by `` tags and anchor clicks, which -cannot carry a JWT header), and the reports (product-wide jwt-optional -convention). Grant `printedparts.view` to the roles that should see the -catalog - admins bypass as always. - -## Stage 16b (extension) - badge resolution honors the directory mode - -Prod runs the employee directory in EXTERNAL mode (live HR database), where -the self-hosted directoryemployees table is empty - so the original -resolver's every lookup missed and the deny policy blocked the kiosk. The -resolver now branches on the same employee_directory_mode setting the -usb/employees plugins use: selfhosted reads DirectoryEmployee by SSO; -external queries the HR directory via employee_connection() - and resolves -PayNo badges by their actual PayNo column, recovering the real SSO, which -the self-hosted table cannot do. Dual-backend lesson in miniature: a plugin -that resolves people must honor the site's directory mode. - -## Stage 16 (extension) - kiosk touch fixes from first hands-on use - -First real touchscreen session found two problems worth their own stage: -1. Focus steal: the page's tap-anywhere handler refocused the hidden wedge - input, yanking focus out of the manual-entry field the moment it was - tapped. Guard the handler - never reclaim focus from INPUT/SELECT/ - TEXTAREA/BUTTON/A targets, only from dead space. -2. No physical keyboard on a touchscreen: manual fallbacks now use the - TouchKeypad. Badge entry is digits (an SSO) so the keypad covers it; - item codes are letters+digits, solved server-side instead of building an - alphanumeric keyboard - the digits in a minted code ARE the row id, so - `/kiosk/item/` resolves bare digits by id. Bonus: labels printed - under an older code prefix keep working after the prefix changes. +Commit + tag `lab-stage-10`. --- -## Post-stage polish (untagged commits on the branch) +## Field extensions (stages 11-16): how plugins actually finish -Small refinements that did not warrant stages but complete the picture: -- The kiosk launches from the sidebar's hardcoded "Displays" section (beside - Shopfloor Dashboard / TV Slideshow), plugin-gated and opening a new tab - - kiosk-style pages belong there, not in the plugin's Information nav. -- The touch keypad was restyled into a terminal-style card panel (boxed - entry display, fixed 3-column grid, press feedback) after the first - hands-on review called the initial version ugly. Looks are requirements - on a kiosk. +Each stage below landed after deployment, from a real request or a real +failure. Summaries here; complete diffs at the tags. -The overall arc is the lab's closing lesson: the spec carried the build to -stage 10; every stage after came from deployment and real users. Plugins are -finished by the floor, not by the spec. +- **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 stage-7 code above is the corrected + version). + +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**, the same three gates CI runs: + ```bash + bash scripts/check-naming-and-style.sh + venv/bin/python -m pytest tests/ -q + cd frontend && npx vitest run && npm run build + ``` +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/` (`api/routes.py` badge regex, `api/selfhosted.py` name resolve) | +| 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 usage in `AssetLabel.vue`, `qrLogo.js` | +| 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..10` | +| The finished plugin itself | branch `feat/printedparts-plugin`, tags `lab-stage-01..16` |