The milestone workbook becomes a from-scratch guide with the actual code inline for every core stage: models, the real migration baseline, read routes and the list page, mutations and minting, the badge resolver (final mode-aware form), the single-commit ledger invariant, RBAC gating, both kiosk endpoints with the wedge-input and focus-guard mechanics, the 1x0.5in label CSS, and the reconcile query. Field extensions stay summarized against their tags. New section: how to contribute a plugin through GitHub (branch, stage commits, the three CI gates, PR expectations, review checklist, and how publication folds PRs into release commits).
1166 lines
44 KiB
Markdown
1166 lines
44 KiB
Markdown
# 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`.
|
|
|
|
Know before you start
|
|
- BUNDLED plugin: frontend files live in core `frontend/src/`, and three core
|
|
files get small edits (api client, sidebar icon map, PLUGIN_TABLE_OWNERS).
|
|
Normal for every bundled plugin.
|
|
- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`);
|
|
DB names lowercase concatenated; run
|
|
`bash scripts/check-naming-and-style.sh` + tests each stage; one commit per
|
|
stage.
|
|
- Three deliberate divergences from the scaffold, each a lesson: no AssetType
|
|
(stage 1), a REAL migration baseline (stage 2), and one deliberately
|
|
unauthenticated write (stage 7 - read the decision record first).
|
|
|
|
---
|
|
|
|
## Stage 0 - orientation (no code)
|
|
|
|
Read the proposal. Tour `plugins/usb/` (checkout ledger + badge contract) and
|
|
`plugins/measuringtools/` (post-cutover migration baseline + hooks) - the two
|
|
reference implementations this build imitates. Get the dev environment
|
|
running and log in.
|
|
|
|
---
|
|
|
|
## Stage 1 - scaffold, minus the AssetType
|
|
|
|
```bash
|
|
flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout"
|
|
```
|
|
|
|
The scaffold assumes an ASSET-extension plugin and generates AssetType
|
|
seeding. Printed parts are quantity consumables - one row is a KIND of part
|
|
with a count, not a physical thing - so delete `_ensure_asset_type` and its
|
|
`on_install` call, and seed the plugin's settings instead. `plugin.py` after
|
|
the edit (imports/meta boilerplate unchanged from the scaffold):
|
|
|
|
```python
|
|
def on_install(self, app: Flask) -> None:
|
|
with app.app_context():
|
|
self._seed_settings()
|
|
logger.info('Printedparts plugin installed')
|
|
|
|
def on_enable(self, app: Flask) -> None:
|
|
# Idempotent re-seed so settings added in later versions reach sites
|
|
# that installed earlier (enable runs on every upgrade cycle).
|
|
with app.app_context():
|
|
self._seed_settings()
|
|
|
|
def _seed_settings(self) -> None:
|
|
defaults = [
|
|
('printedparts_code_prefix', '3DP', 'string',
|
|
'Prefix for generated item codes'),
|
|
('printedparts_default_threshold', '5', 'integer',
|
|
'Default low-stock threshold for new items'),
|
|
('printedparts_unknown_badge', 'deny', 'string',
|
|
'Kiosk policy when a badge resolves to no employee: allow or deny'),
|
|
]
|
|
for key, value, valuetype, description in defaults:
|
|
if Setting.get(key) is None:
|
|
Setting.set(key, value, valuetype=valuetype,
|
|
category='printedparts', description=description)
|
|
db.session.commit()
|
|
```
|
|
|
|
(`Setting` and `db` come from `shopdb.api`.) `manifest.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "printedparts",
|
|
"version": "0.1.0",
|
|
"description": "3D-printed parts inventory + kiosk checkout",
|
|
"display_name": "3D Printed Parts",
|
|
"dependencies": ["employees"],
|
|
"core_version": ">=0.13.0,<1.0.0",
|
|
"api_prefix": "/api/printedparts",
|
|
"default_enabled": false
|
|
}
|
|
```
|
|
|
|
`dependencies` is enforced (employees must be installed/enabled first - badge
|
|
names come from it); `default_enabled: false` means each site opts in.
|
|
|
|
See it work: `flask plugin list` shows `printedparts [Available]`.
|
|
Commit + tag `lab-stage-01`.
|
|
|
|
---
|
|
|
|
## Stage 2 - models, real migration baseline, tables live
|
|
|
|
### The two models - `plugins/printedparts/models/printeditem.py`
|
|
|
|
The design idea of the whole plugin: the LEDGER is the source of truth;
|
|
`quantityonhand` is a cache moved in the same commit as every ledger write.
|
|
|
|
```python
|
|
from datetime import datetime, timezone
|
|
|
|
from shopdb.api import db, BaseModel
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
TRANSACTION_TYPES = ('take', 'restock', 'adjust')
|
|
|
|
|
|
class PrintedItem(BaseModel):
|
|
"""A printable part the engineers stock in bins."""
|
|
|
|
__tablename__ = 'printeditems'
|
|
|
|
printeditemid = db.Column(db.Integer, primary_key=True)
|
|
itemcode = db.Column(db.String(20), unique=True, index=True,
|
|
comment='Generated bin-label code, e.g. 3DP0042')
|
|
itemname = db.Column(db.String(120), nullable=False)
|
|
itemdescription = db.Column(db.String(500))
|
|
imageurl = db.Column(db.String(255))
|
|
quantityonhand = db.Column(db.Integer, nullable=False, default=0)
|
|
lowstockthreshold = db.Column(db.Integer, nullable=False, default=5)
|
|
binlocation = db.Column(db.String(100))
|
|
printnotes = db.Column(db.Text, comment='Material, print time, slicer file')
|
|
|
|
transactions = db.relationship(
|
|
'PrintedItemTransaction', backref='printeditem',
|
|
cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic')
|
|
|
|
@property
|
|
def islowstock(self):
|
|
return self.quantityonhand <= self.lowstockthreshold
|
|
|
|
|
|
class PrintedItemTransaction(BaseModel):
|
|
"""One signed stock movement, always attributed to an employee."""
|
|
|
|
__tablename__ = 'printeditemtransactions'
|
|
|
|
transactionid = db.Column(db.Integer, primary_key=True)
|
|
printeditemid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
transactiontype = db.Column(db.String(10), nullable=False)
|
|
quantitychange = db.Column(db.Integer, nullable=False,
|
|
comment='Negative for take, signed for adjust')
|
|
employeesso = db.Column(db.String(20), nullable=False, index=True)
|
|
employeename = db.Column(db.String(120))
|
|
reason = db.Column(db.String(255))
|
|
transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
|
index=True)
|
|
```
|
|
|
|
(Each model also carries a `to_dict()` - see the tag; `BaseModel` supplies
|
|
createddate/modifieddate/isactive.) Export both from `models/__init__.py` and
|
|
return them from `get_models()`.
|
|
|
|
### Register ownership - `shopdb/plugins/alembic_template.py`
|
|
|
|
```python
|
|
'printedparts': ('printeditems', 'printeditemtransactions'),
|
|
```
|
|
|
|
### The migration - `plugins/printedparts/migrations/`
|
|
|
|
`env.py` is three lines (copy `script.py.mako` from measuringtools too):
|
|
|
|
```python
|
|
import os
|
|
|
|
os.environ['PLUGIN_NAME'] = 'printedparts'
|
|
|
|
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
|
|
|
run_migrations()
|
|
```
|
|
|
|
`versions/0001_printedparts_baseline.py` - post-cutover plugins CREATE their
|
|
tables (unlike the ten legacy plugins whose 0001 is a stamp-only anchor):
|
|
|
|
```python
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = 'printedparts0001baseline'
|
|
down_revision = None
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade():
|
|
op.create_table(
|
|
'printeditems',
|
|
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
|
sa.Column('itemcode', sa.String(length=20), nullable=True),
|
|
sa.Column('itemname', sa.String(length=120), nullable=False),
|
|
sa.Column('itemdescription', sa.String(length=500), nullable=True),
|
|
sa.Column('imageurl', sa.String(length=255), nullable=True),
|
|
sa.Column('quantityonhand', sa.Integer(), nullable=False),
|
|
sa.Column('lowstockthreshold', sa.Integer(), nullable=False),
|
|
sa.Column('binlocation', sa.String(length=100), nullable=True),
|
|
sa.Column('printnotes', sa.Text(), nullable=True),
|
|
sa.Column('createddate', sa.DateTime(), nullable=False),
|
|
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
|
sa.Column('isactive', sa.Boolean(), nullable=False),
|
|
sa.PrimaryKeyConstraint('printeditemid'),
|
|
sa.UniqueConstraint('itemcode'),
|
|
)
|
|
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
|
|
|
op.create_table(
|
|
'printeditemtransactions',
|
|
sa.Column('transactionid', sa.Integer(), nullable=False),
|
|
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
|
sa.Column('transactiontype', sa.String(length=10), nullable=False),
|
|
sa.Column('quantitychange', sa.Integer(), nullable=False),
|
|
sa.Column('employeesso', sa.String(length=20), nullable=False),
|
|
sa.Column('employeename', sa.String(length=120), nullable=True),
|
|
sa.Column('reason', sa.String(length=255), nullable=True),
|
|
sa.Column('transactiondate', sa.DateTime(), nullable=False),
|
|
sa.Column('createddate', sa.DateTime(), nullable=False),
|
|
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
|
sa.Column('isactive', sa.Boolean(), nullable=False),
|
|
sa.ForeignKeyConstraint(['printeditemid'],
|
|
['printeditems.printeditemid'],
|
|
ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('transactionid'),
|
|
)
|
|
op.create_index('ix_printeditemtransactions_printeditemid',
|
|
'printeditemtransactions', ['printeditemid'])
|
|
op.create_index('ix_printeditemtransactions_employeesso',
|
|
'printeditemtransactions', ['employeesso'])
|
|
op.create_index('ix_printeditemtransactions_transactiondate',
|
|
'printeditemtransactions', ['transactiondate'])
|
|
|
|
|
|
def downgrade():
|
|
op.drop_table('printeditemtransactions')
|
|
op.drop_table('printeditems')
|
|
```
|
|
|
|
See it work:
|
|
|
|
```
|
|
flask plugin install printedparts && flask plugin enable printedparts
|
|
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
|
|
mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline
|
|
flask plugin upgrade-all -- printedparts: ok
|
|
```
|
|
|
|
Common errors (both hit for real while building this):
|
|
- Empty `Migration error:` on install = anything breaking the models import
|
|
(the alembic env imports the whole plugin package - here, the scaffold's
|
|
routes.py still importing the deleted scaffold model). Fix the import.
|
|
- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py` = add
|
|
`EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'`.
|
|
|
|
Commit + tag `lab-stage-02`.
|
|
|
|
---
|
|
|
|
## Stage 3 - read API + list page (the first visible win)
|
|
|
|
### Backend - `plugins/printedparts/api/routes.py`
|
|
|
|
```python
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
from sqlalchemy import or_
|
|
|
|
from shopdb.api import (
|
|
db, success_response, error_response, paginated_response,
|
|
ErrorCodes, get_pagination_params, paginate_query,
|
|
)
|
|
|
|
from ..models import PrintedItem
|
|
|
|
printedparts_bp = Blueprint('printedparts', __name__)
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['GET'])
|
|
@jwt_required(optional=True) # stage 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
|
|
|
|
```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
|
|
<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>
|
|
```
|
|
|
|
4. Seed two or three rows by hand purely to look at. NOTE: hand-seeded stock
|
|
has no ledger backing - the stage-9 reconcile report will flag exactly these
|
|
rows, which is the check working.
|
|
|
|
See it work: `/printedparts` shows your parts, low-stock row red-badged.
|
|
Commit + tag `lab-stage-03`.
|
|
|
|
---
|
|
|
|
## Stage 4 - catalog mutations + item photos + detail/form pages
|
|
|
|
Mutations append to routes.py. The two design points: the itemcode is minted
|
|
AFTER `flush()` assigns the row id, and `quantityonhand` is REFUSED here -
|
|
stock only moves through the ledger (stage 5).
|
|
|
|
```python
|
|
from shopdb.api import Setting
|
|
from werkzeug.utils import secure_filename
|
|
import glob
|
|
import os
|
|
from flask import current_app
|
|
|
|
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
|
|
'binlocation', 'printnotes')
|
|
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
|
|
|
|
|
def _imagedir():
|
|
return os.path.join(current_app.instance_path, 'printedpartsimages')
|
|
|
|
|
|
def _mint_itemcode(item):
|
|
prefix = Setting.get('printedparts_code_prefix') or '3DP'
|
|
item.itemcode = f'{prefix}{item.printeditemid:04d}'
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['POST'])
|
|
@jwt_required()
|
|
def create_item():
|
|
data = request.get_json() or {}
|
|
itemname = (data.get('itemname') or '').strip()
|
|
if not itemname:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
|
|
threshold = data.get('lowstockthreshold')
|
|
if threshold is None:
|
|
threshold = int(Setting.get('printedparts_default_threshold') or 5)
|
|
item = PrintedItem(itemname=itemname,
|
|
itemdescription=data.get('itemdescription'),
|
|
lowstockthreshold=threshold,
|
|
binlocation=data.get('binlocation'),
|
|
printnotes=data.get('printnotes'),
|
|
quantityonhand=0)
|
|
db.session.add(item)
|
|
db.session.flush() # assigns printeditemid
|
|
_mint_itemcode(item)
|
|
db.session.commit()
|
|
return success_response(item.to_dict(), message='Printed item created',
|
|
http_code=201)
|
|
|
|
|
|
@printedparts_bp.route('/items/<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:
|
|
|
|
```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/<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:
|
|
|
|
```python
|
|
def get_permissions(self) -> List:
|
|
return [
|
|
('printedparts.view', 'View 3D printed parts', 'printedparts'),
|
|
('printedparts.create', 'Create printed parts', 'printedparts'),
|
|
('printedparts.edit', 'Edit printed parts', 'printedparts'),
|
|
('printedparts.delete', 'Retire printed parts', 'printedparts'),
|
|
('printedparts.restock', 'Restock and adjust stock counts',
|
|
'printedparts'),
|
|
]
|
|
```
|
|
|
|
Seeded automatically on install/enable. Gate every mutation - the decorator
|
|
stacks under `@jwt_required()`:
|
|
|
|
```python
|
|
@printedparts_bp.route('/items', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('printedparts.create')
|
|
def create_item():
|
|
...
|
|
```
|
|
|
|
(create/update/delete/images = create/edit/delete; restock+adjust = restock;
|
|
`require_permission` comes from `shopdb.api`.)
|
|
|
|
Test with the `member_headers` fixture (authenticated, role-less): 403 where
|
|
admin succeeds - authentication alone is not authorization.
|
|
Commit + tag `lab-stage-06`.
|
|
|
|
---
|
|
|
|
## Stage 7 - the kiosk (the deliberate open write)
|
|
|
|
Read the decision record in the proposal first. `POST /kiosk/take` is the
|
|
product's first UNauthenticated write, held to four criteria: decrement-only,
|
|
badge-attributed server-side, bounded blast radius, physically rate-limited.
|
|
Put the justification in the plugin README, and expect the authz sweep to
|
|
catch you (below).
|
|
|
|
### Backend - both endpoints UNdecorated
|
|
|
|
```python
|
|
def _kiosk_find_item(itemcode):
|
|
"""Resolve a scanned or typed code to an active item.
|
|
|
|
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`
|
|
|
|
```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')">⌫</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):
|
|
|
|
```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
|
|
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
|
|
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
|
|
```
|
|
|
|
```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 net
|
|
against accidentally-open writes. Yours is open on purpose, so exempt it
|
|
EXPLICITLY with a comment pointing at the decision record:
|
|
|
|
```python
|
|
EXEMPT_ENDPOINTS = {...,
|
|
# Deliberately open kiosk write: decrement-only,
|
|
# badge-attributed server-side. Decision record in
|
|
# docs/proposals/printedparts-plugin.md.
|
|
'printedparts.kiosk_take'}
|
|
```
|
|
|
|
See it work: scan/type a code -> item card -> badge -> keypad -> TAKE; stock
|
|
drops with your name in the ledger; over-take and unknown-badge produce
|
|
friendly messages.
|
|
Commit + tag `lab-stage-07`.
|
|
|
|
---
|
|
|
|
## Stage 8 - 1in x 0.5in bin labels
|
|
|
|
A plugin OWNS its label page (the USB precedent) - parts are not assets, so
|
|
they do not join the shared asset-label TYPE_CONFIG. New public route beside
|
|
`/print/usb-labels`, view `frontend/src/views/print/PrintedPartsLabels.vue`.
|
|
The pieces that matter:
|
|
|
|
```javascript
|
|
// CODE128 of the short item code fits 1x0.5in with comfortable scanner
|
|
// tolerance; a QR at this size would be marginal.
|
|
JsBarcode(element, label.itemcode, {
|
|
format: 'CODE128', displayValue: false, width: 1.4, height: 26, margin: 0
|
|
})
|
|
```
|
|
|
|
```css
|
|
/* 1in x 0.5in roll stock: one label per page */
|
|
@media print {
|
|
@page { size: 1in 0.5in; margin: 0; }
|
|
.no-print { display: none; }
|
|
.bin-label { page-break-after: always; break-after: page; }
|
|
}
|
|
.bin-label {
|
|
width: 1in; height: 0.5in;
|
|
display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center;
|
|
}
|
|
.bin-barcode { width: 0.92in; height: 0.3in; }
|
|
.bin-code { font-size: 6.5pt; font-family: monospace; }
|
|
```
|
|
|
|
Multi-select + per-item copies; `?item=<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:
|
|
|
|
```python
|
|
def get_reports(self) -> List[dict]:
|
|
return [
|
|
{'id': 'printedparts-stock', 'name': '3D Parts Stock',
|
|
'description': 'Stock levels with low-stock flags and the '
|
|
'cache-vs-ledger reconcile check',
|
|
'category': 'inventory',
|
|
'endpoint': '/api/printedparts/reports/stock'},
|
|
{'id': 'printedparts-consumption', 'name': '3D Parts Consumption',
|
|
'description': 'Takes per item over a date range',
|
|
'category': 'usage',
|
|
'endpoint': '/api/printedparts/reports/consumption'},
|
|
{'id': 'printedparts-by-person', 'name': '3D Parts by Person',
|
|
'description': 'Takes grouped by employee', 'category': 'usage',
|
|
'endpoint': '/api/printedparts/reports/by-person'},
|
|
]
|
|
```
|
|
|
|
The stock report's heart - the reconcile check:
|
|
|
|
```python
|
|
# int() the sums: MySQL SUM returns Decimal, which JSON-serializes as a
|
|
# string (gotcha hit live).
|
|
ledger = {itemid: int(total) for itemid, total in
|
|
db.session.query(
|
|
PrintedItemTransaction.printeditemid,
|
|
func.coalesce(func.sum(PrintedItemTransaction.quantitychange), 0))
|
|
.group_by(PrintedItemTransaction.printeditemid).all()}
|
|
...
|
|
'ledgerdelta': item.quantityonhand - ledger.get(item.printeditemid, 0),
|
|
```
|
|
|
|
`ledgerdelta` must be 0 for every ledger-driven item; nonzero flags a write
|
|
path that bypassed `_ledger_write` - your stage-3 hand-seeded rows show here,
|
|
proving the check works. Deferred by decision: `get_dashboard_widgets`
|
|
(needs a core component) and a settings card (needs a page - stage 12 adds
|
|
both).
|
|
|
|
Commit + tag `lab-stage-09`.
|
|
|
|
---
|
|
|
|
## Stage 10 - closeout
|
|
|
|
1. Lifecycle: `flask plugin disable printedparts` - nav, reports, grantable
|
|
permissions vanish; API routes only after a RESTART (blueprints register
|
|
at startup). Re-enable.
|
|
2. Fresh-database proof: scratch DATABASE_URL, `flask db upgrade` +
|
|
`install/enable/upgrade-all` - green with zero manual SQL.
|
|
3. Full suite: backend pytest, vitest, frontend build, naming hook.
|
|
4. Walk `PLUGIN-GUIDE.md` section 12's End checklist.
|
|
|
|
Done means: a colleague can clone the repo, enable the plugin, print a bin
|
|
label, and take a part at the kiosk with their badge - without asking you
|
|
anything.
|
|
Commit + tag `lab-stage-10`.
|
|
|
|
---
|
|
|
|
## Field extensions (stages 11-16): how plugins actually finish
|
|
|
|
Each stage below landed after deployment, from a real request or a real
|
|
failure. Summaries here; complete diffs at the tags.
|
|
|
|
- **11 - low-stock email alerts** (`lab-stage-11`): a worked CONTRACT
|
|
ADDITION - `send_email`/`send_alert` join `shopdb.api`,
|
|
`__contract_version__` bumps, PLUGIN-HOOKS.md updates (the docs-drift
|
|
guard fails until it does), the manifest pins the new floor. The alert
|
|
fires inside `_ledger_write` only when a decrement CROSSES the item's
|
|
threshold (crossing = natural debounce; restocking above re-arms),
|
|
best-effort AFTER the commit so mail trouble can never fail a take.
|
|
- **12 - admin settings page** (`lab-stage-12`): a page under
|
|
`settings/printedparts` in the PLUGIN's router file (any `settings/...`
|
|
path auto-nests into the two-pane rail) + `get_settings_cards` for the
|
|
catalog card.
|
|
- **13 - recipients from shopdb users** (`lab-stage-13`): `User` joins the
|
|
surface (0.13.0); checkbox picker; account emails merged + deduped with
|
|
free-text; inactive users skipped.
|
|
- **14 - retire/restore + dashless codes** (`lab-stage-14`): soft-delete
|
|
needs UI; Restore is its own permission-gated POST (the generic update
|
|
cannot flip isactive); item codes are immutable once printed on a bin.
|
|
- **15 - print-file revisions + role recipients** (`lab-stage-15`): the
|
|
plugin's FIRST incremental migration (`0002_printeditemfiles`) - the
|
|
ADR-008 payoff. Append-only revisions (revision = max+1, uploader from the
|
|
JWT, extension allowlist, 100 MB cap, download under the original name).
|
|
Gotcha: a VARCHAR(255) UNIQUE on utf8mb4 dies with error 1071 in the
|
|
per-plugin chain (no core ROW_FORMAT hook) - size unique columns 191 or
|
|
less. `Role` joins the surface; every active member of selected roles is
|
|
folded into the alert recipients.
|
|
- **16a - catalog goes staff-only**: reads move behind
|
|
`require_permission('printedparts.view')`; routes + label page gain
|
|
`requiresAuth`. Structurally still open: image serve and file download
|
|
(`<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).
|
|
|
|
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/<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
|
|
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/<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` |
|