Files
shopdb-flask/docs/PLUGIN-LAB-PRINTEDPARTS.md
cproudlock f77f0a8d90
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Add GitHub Actions CI + Windows notes on the developer docs
GitHub had no CI, so naming/tests/build were unenforced on the public
mirror. Add .github/workflows/ci.yml mirroring the internal pipeline:
backend pytest, the naming gate, frontend vitest+build, and the
migrations-mysql job that proves a fresh flask db upgrade + every
plugin chain on utf8mb4 MySQL 8 is idempotent. Flip the dev-setup CI
note to reflect it. Add an identical Windows/VS Code convention note to
the four developer docs (venv\Scripts vs venv/bin, $env: vs export,
pointer to DEVELOPMENT-SETUP).
2026-07-17 15:12:36 -04:00

45 KiB

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.

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

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):

    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:

{
    "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.

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

    'printedparts':  ('printeditems', 'printeditemtransactions'),

The migration - plugins/printedparts/migrations/

env.py is three lines (copy script.py.mako from measuringtools too):

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):

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

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 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/<int:item_id>', 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

    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:
// 3D printed parts (printedparts plugin)
export const printedpartsApi = {
  list(params = {}) {
    return api.get('/printedparts/items', { params })
  },
  get(printeditemid) {
    return api.get(`/printedparts/items/${printeditemid}`)
  }
}
  1. 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).

  2. The list page, core of PrintedItemsList.vue (master template: PrintersList.vue; global CSS classes; full file at the tag):

<template>
  <div>
    <div class="page-header">
      <h2>3D Printed Parts</h2>
      <router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
    </div>

    <div class="filters">
      <input v-model="search" type="text" class="form-control"
             placeholder="Search code, name, description, bin..."
             @input="debouncedSearch" />
      <label class="lowstock-filter">
        <input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
        Low stock only
      </label>
    </div>

    <div class="card">
      <div v-if="loading" class="loading">Loading...</div>
      <template v-else>
        <div class="table-container">
          <table>
            <thead>
              <tr><th></th><th>Code</th><th>Name</th><th>Quantity</th>
                  <th>Bin</th><th>Description</th></tr>
            </thead>
            <tbody>
              <tr v-for="item in items" :key="item.printeditemid"
                  class="clickable-row"
                  @click="$router.push(`/printedparts/${item.printeditemid}`)">
                <td class="thumb-cell">
                  <img v-if="item.imageurl" :src="withBase(item.imageurl)"
                       :alt="item.itemname" class="item-thumb" />
                </td>
                <td>{{ item.itemcode || '-' }}</td>
                <td>{{ item.itemname }}</td>
                <td>
                  <span :class="['badge',
                        item.islowstock ? 'badge-danger' : 'badge-success']">
                    {{ item.quantityonhand }}
                  </span>
                </td>
                <td>{{ item.binlocation || '-' }}</td>
                <td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
              </tr>
            </tbody>
          </table>
        </div>
        <PaginationBar :page="page" :total-pages="totalPages" @change="setPage" />
      </template>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { printedpartsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import { withBase } from '../../utils/basePath'

const items = ref([])
const loading = ref(true)
const lowstockOnly = ref(false)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null

onMounted(loadItems)

async function loadItems() {
  loading.value = true
  try {
    const params = { page: page.value, perpage: perPage.value }
    if (search.value) params.search = search.value
    if (lowstockOnly.value) params.lowstock = 'true'
    const response = await printedpartsApi.list(params)
    items.value = response.data.data || []
    totalPages.value = response.data.meta?.pagination?.totalpages || 1
  } finally {
    loading.value = false
  }
}

function debouncedSearch() {
  clearTimeout(searchTimeout)
  searchTimeout = setTimeout(() => setSearch(search.value), 300)
}
</script>
  1. 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).

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/<int:item_id>', 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 <img> 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:

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)

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/<int:item_id>/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/<int:item_id>/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:

    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():

@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

def _kiosk_find_item(itemcode):
    """Resolve a scanned or typed code to an active item.

    Accepts the full code (WJRP0042) or bare digits from the touch keypad -
    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/<itemcode>', 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

<template>
  <div class="touch-keypad">
    <button v-for="digit in digits" :key="digit" type="button"
            class="keypad-button" @click="$emit('digit', digit)">
      {{ digit }}
    </button>
    <button type="button" class="keypad-button keypad-action"
            @click="$emit('clear')">Clear</button>
    <button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
    <button type="button" class="keypad-button keypad-action"
            aria-label="Backspace" @click="$emit('backspace')">&#9003;</button>
  </div>
</template>

<script setup>
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
defineEmits(['digit', 'clear', 'backspace'])
</script>

(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):

  {
    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:

<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
       autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
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 net against accidentally-open writes. Yours is open on purpose, so exempt it EXPLICITLY with a comment pointing at the decision record:

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:

// 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
})
/* 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=<id> 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:

    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:

    # 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 (<img>/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).

  • 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):
    git clone https://github.com/ge-aero/shopdb-flask.git
    cd shopdb-flask
    git checkout -b feat/<yourplugin>
    
    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 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:
    git push -u origin feat/<yourplugin>
    
    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..16