From the Fable/Opus documentation audit (8 confirmed + verified lab-drift the run's session limit had cut short): - HIGH: the lab's kiosk _kiosk_find_item block showed the pre-stage-17 row-id resolver as current; replace with the shipped gagelabtag / numeric-tail resolver, fix the stale 'resolved by row id' prose and the 'stage-7 code is corrected' note. - MED: the badge _external_lookup block used dict-only row access that breaks on a tuple cursor; use the tuple-or-dict form shipped. Split '&&' command chains (fail in PowerShell 5.1) in the lab. - LOW/link: the Windows note's [DEVELOPMENT-SETUP] link dropped the .md and 404'd in four docs; fix. Correct the stage-6a->16a comment and the lab-stage tag range (..16 -> ..17). - Leaks: drop /home/camp path from ADR-006, the internal gitea host from PLUGINS.md. - Windows: add an mklink junction note for the external-plugin symlink dev loop. - CI: prime root to mysql_native_password so pymysql connects to the MySQL 8 service without the cryptography package (and its kit wheel).
1194 lines
46 KiB
Markdown
1194 lines
46 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`.
|
|
|
|
> **Windows / VS Code:** command examples below use the Linux venv path
|
|
> `venv/bin/python`; on Windows use `venv\Scripts\python` and
|
|
> `$env:FLASK_APP="shopdb"` (not `export`). Full Windows onboarding:
|
|
> [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md).
|
|
|
|
|
|
Know before you start
|
|
- BUNDLED plugin: frontend files live in core `frontend/src/`, and three core
|
|
files get small edits (api client, sidebar icon map, PLUGIN_TABLE_OWNERS).
|
|
Normal for every bundled plugin.
|
|
- Ground rules: import core ONLY via `shopdb.api` (+ `shopdb.plugins.base`);
|
|
DB names lowercase concatenated; run
|
|
`bash scripts/check-naming-and-style.sh` + tests each stage; one commit per
|
|
stage.
|
|
- Three deliberate divergences from the scaffold, each a lesson: no AssetType
|
|
(stage 1), a REAL migration baseline (stage 2), and one deliberately
|
|
unauthenticated write (stage 7 - read the decision record first).
|
|
|
|
---
|
|
|
|
## Stage 0 - orientation (no code)
|
|
|
|
Read the proposal. Tour `plugins/usb/` (checkout ledger + badge contract) and
|
|
`plugins/measuringtools/` (post-cutover migration baseline + hooks) - the two
|
|
reference implementations this build imitates. Get the dev environment
|
|
running and log in.
|
|
|
|
---
|
|
|
|
## Stage 1 - scaffold, minus the AssetType
|
|
|
|
```bash
|
|
flask plugin new printedparts --description "3D-printed parts inventory + kiosk checkout"
|
|
```
|
|
|
|
The scaffold assumes an ASSET-extension plugin and generates AssetType
|
|
seeding. Printed parts are quantity consumables - one row is a KIND of part
|
|
with a count, not a physical thing - so delete `_ensure_asset_type` and its
|
|
`on_install` call, and seed the plugin's settings instead. `plugin.py` after
|
|
the edit (imports/meta boilerplate unchanged from the scaffold):
|
|
|
|
```python
|
|
def on_install(self, app: Flask) -> None:
|
|
with app.app_context():
|
|
self._seed_settings()
|
|
logger.info('Printedparts plugin installed')
|
|
|
|
def on_enable(self, app: Flask) -> None:
|
|
# Idempotent re-seed so settings added in later versions reach sites
|
|
# that installed earlier (enable runs on every upgrade cycle).
|
|
with app.app_context():
|
|
self._seed_settings()
|
|
|
|
def _seed_settings(self) -> None:
|
|
defaults = [
|
|
('printedparts_code_prefix', '3DP', 'string',
|
|
'Prefix for generated item codes'),
|
|
('printedparts_default_threshold', '5', 'integer',
|
|
'Default low-stock threshold for new items'),
|
|
('printedparts_unknown_badge', 'deny', 'string',
|
|
'Kiosk policy when a badge resolves to no employee: allow or deny'),
|
|
]
|
|
for key, value, valuetype, description in defaults:
|
|
if Setting.get(key) is None:
|
|
Setting.set(key, value, valuetype=valuetype,
|
|
category='printedparts', description=description)
|
|
db.session.commit()
|
|
```
|
|
|
|
(`Setting` and `db` come from `shopdb.api`.) `manifest.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "printedparts",
|
|
"version": "0.1.0",
|
|
"description": "3D-printed parts inventory + kiosk checkout",
|
|
"display_name": "3D Printed Parts",
|
|
"dependencies": ["employees"],
|
|
"core_version": ">=0.13.0,<1.0.0",
|
|
"api_prefix": "/api/printedparts",
|
|
"default_enabled": false
|
|
}
|
|
```
|
|
|
|
`dependencies` is enforced (employees must be installed/enabled first - badge
|
|
names come from it); `default_enabled: false` means each site opts in.
|
|
|
|
See it work: `flask plugin list` shows `printedparts [Available]`.
|
|
Commit + tag `lab-stage-01`.
|
|
|
|
---
|
|
|
|
## Stage 2 - models, real migration baseline, tables live
|
|
|
|
### The two models - `plugins/printedparts/models/printeditem.py`
|
|
|
|
The design idea of the whole plugin: the LEDGER is the source of truth;
|
|
`quantityonhand` is a cache moved in the same commit as every ledger write.
|
|
|
|
```python
|
|
from datetime import datetime, timezone
|
|
|
|
from shopdb.api import db, BaseModel
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
TRANSACTION_TYPES = ('take', 'restock', 'adjust')
|
|
|
|
|
|
class PrintedItem(BaseModel):
|
|
"""A printable part the engineers stock in bins."""
|
|
|
|
__tablename__ = 'printeditems'
|
|
|
|
printeditemid = db.Column(db.Integer, primary_key=True)
|
|
itemcode = db.Column(db.String(20), unique=True, index=True,
|
|
comment='Generated bin-label code, e.g. 3DP0042')
|
|
itemname = db.Column(db.String(120), nullable=False)
|
|
itemdescription = db.Column(db.String(500))
|
|
imageurl = db.Column(db.String(255))
|
|
quantityonhand = db.Column(db.Integer, nullable=False, default=0)
|
|
lowstockthreshold = db.Column(db.Integer, nullable=False, default=5)
|
|
binlocation = db.Column(db.String(100))
|
|
printnotes = db.Column(db.Text, comment='Material, print time, slicer file')
|
|
|
|
transactions = db.relationship(
|
|
'PrintedItemTransaction', backref='printeditem',
|
|
cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic')
|
|
|
|
@property
|
|
def islowstock(self):
|
|
return self.quantityonhand <= self.lowstockthreshold
|
|
|
|
|
|
class PrintedItemTransaction(BaseModel):
|
|
"""One signed stock movement, always attributed to an employee."""
|
|
|
|
__tablename__ = 'printeditemtransactions'
|
|
|
|
transactionid = db.Column(db.Integer, primary_key=True)
|
|
printeditemid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
|
|
nullable=False, index=True)
|
|
transactiontype = db.Column(db.String(10), nullable=False)
|
|
quantitychange = db.Column(db.Integer, nullable=False,
|
|
comment='Negative for take, signed for adjust')
|
|
employeesso = db.Column(db.String(20), nullable=False, index=True)
|
|
employeename = db.Column(db.String(120))
|
|
reason = db.Column(db.String(255))
|
|
transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
|
index=True)
|
|
```
|
|
|
|
(Each model also carries a `to_dict()` - see the tag; `BaseModel` supplies
|
|
createddate/modifieddate/isactive.) Export both from `models/__init__.py` and
|
|
return them from `get_models()`.
|
|
|
|
### Register ownership - `shopdb/plugins/alembic_template.py`
|
|
|
|
```python
|
|
'printedparts': ('printeditems', 'printeditemtransactions'),
|
|
```
|
|
|
|
### The migration - `plugins/printedparts/migrations/`
|
|
|
|
`env.py` is three lines (copy `script.py.mako` from measuringtools too):
|
|
|
|
```python
|
|
import os
|
|
|
|
os.environ['PLUGIN_NAME'] = 'printedparts'
|
|
|
|
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
|
|
|
run_migrations()
|
|
```
|
|
|
|
`versions/0001_printedparts_baseline.py` - post-cutover plugins CREATE their
|
|
tables (unlike the ten legacy plugins whose 0001 is a stamp-only anchor):
|
|
|
|
```python
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
revision = 'printedparts0001baseline'
|
|
down_revision = None
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade():
|
|
op.create_table(
|
|
'printeditems',
|
|
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
|
sa.Column('itemcode', sa.String(length=20), nullable=True),
|
|
sa.Column('itemname', sa.String(length=120), nullable=False),
|
|
sa.Column('itemdescription', sa.String(length=500), nullable=True),
|
|
sa.Column('imageurl', sa.String(length=255), nullable=True),
|
|
sa.Column('quantityonhand', sa.Integer(), nullable=False),
|
|
sa.Column('lowstockthreshold', sa.Integer(), nullable=False),
|
|
sa.Column('binlocation', sa.String(length=100), nullable=True),
|
|
sa.Column('printnotes', sa.Text(), nullable=True),
|
|
sa.Column('createddate', sa.DateTime(), nullable=False),
|
|
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
|
sa.Column('isactive', sa.Boolean(), nullable=False),
|
|
sa.PrimaryKeyConstraint('printeditemid'),
|
|
sa.UniqueConstraint('itemcode'),
|
|
)
|
|
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
|
|
|
op.create_table(
|
|
'printeditemtransactions',
|
|
sa.Column('transactionid', sa.Integer(), nullable=False),
|
|
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
|
sa.Column('transactiontype', sa.String(length=10), nullable=False),
|
|
sa.Column('quantitychange', sa.Integer(), nullable=False),
|
|
sa.Column('employeesso', sa.String(length=20), nullable=False),
|
|
sa.Column('employeename', sa.String(length=120), nullable=True),
|
|
sa.Column('reason', sa.String(length=255), nullable=True),
|
|
sa.Column('transactiondate', sa.DateTime(), nullable=False),
|
|
sa.Column('createddate', sa.DateTime(), nullable=False),
|
|
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
|
sa.Column('isactive', sa.Boolean(), nullable=False),
|
|
sa.ForeignKeyConstraint(['printeditemid'],
|
|
['printeditems.printeditemid'],
|
|
ondelete='CASCADE'),
|
|
sa.PrimaryKeyConstraint('transactionid'),
|
|
)
|
|
op.create_index('ix_printeditemtransactions_printeditemid',
|
|
'printeditemtransactions', ['printeditemid'])
|
|
op.create_index('ix_printeditemtransactions_employeesso',
|
|
'printeditemtransactions', ['employeesso'])
|
|
op.create_index('ix_printeditemtransactions_transactiondate',
|
|
'printeditemtransactions', ['transactiondate'])
|
|
|
|
|
|
def downgrade():
|
|
op.drop_table('printeditemtransactions')
|
|
op.drop_table('printeditems')
|
|
```
|
|
|
|
See it work:
|
|
|
|
```
|
|
flask plugin install printedparts; flask plugin enable printedparts
|
|
mysql> SHOW TABLES LIKE 'printed%'; -- both tables
|
|
mysql> SELECT * FROM alembic_version_printedparts; -- printedparts0001baseline
|
|
flask plugin upgrade-all -- printedparts: ok
|
|
```
|
|
|
|
Common errors (both hit for real while building this):
|
|
- Empty `Migration error:` on install = anything breaking the models import
|
|
(the alembic env imports the whole plugin package - here, the scaffold's
|
|
routes.py still importing the deleted scaffold model). Fix the import.
|
|
- `KeyError: 'printedparts'` from `tests/test_plugin_migrations.py` = add
|
|
`EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'`.
|
|
|
|
Commit + tag `lab-stage-02`.
|
|
|
|
---
|
|
|
|
## Stage 3 - read API + list page (the first visible win)
|
|
|
|
### Backend - `plugins/printedparts/api/routes.py`
|
|
|
|
```python
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
from sqlalchemy import or_
|
|
|
|
from shopdb.api import (
|
|
db, success_response, error_response, paginated_response,
|
|
ErrorCodes, get_pagination_params, paginate_query,
|
|
)
|
|
|
|
from ..models import PrintedItem
|
|
|
|
printedparts_bp = Blueprint('printedparts', __name__)
|
|
|
|
|
|
@printedparts_bp.route('/items', methods=['GET'])
|
|
@jwt_required(optional=True) # stage 16a tightens this to view-gated
|
|
def list_items():
|
|
"""List printed items, paginated; search + low-stock filter."""
|
|
page, per_page = get_pagination_params(request)
|
|
query = PrintedItem.query
|
|
if request.args.get('active', 'true').lower() != 'false':
|
|
query = query.filter(PrintedItem.isactive == True)
|
|
if search := request.args.get('search'):
|
|
like = f'%{search}%'
|
|
query = query.filter(or_(
|
|
PrintedItem.itemcode.ilike(like),
|
|
PrintedItem.itemname.ilike(like),
|
|
PrintedItem.itemdescription.ilike(like),
|
|
PrintedItem.binlocation.ilike(like),
|
|
))
|
|
if request.args.get('lowstock', '').lower() == 'true':
|
|
query = query.filter(
|
|
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
|
|
query = query.order_by(PrintedItem.itemname)
|
|
items, total = paginate_query(query, page, per_page)
|
|
return paginated_response(
|
|
[item.to_dict() for item in items], page, per_page, total)
|
|
|
|
|
|
@printedparts_bp.route('/items/<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:
|
|
# pymysql may return a tuple or a dict cursor - handle both.
|
|
sso = str(row[0] if not isinstance(row, dict) else row['SSO'])
|
|
first = row[1] if not isinstance(row, dict) else row['First_Name']
|
|
last = row[2] if not isinstance(row, dict) else row['Last_Name']
|
|
return sso, f"{(first or '').strip()} {(last or '').strip()}".strip()
|
|
except Exception:
|
|
logger.exception('HR directory lookup failed for %s %s', kind, digits)
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def resolve_badge(badge):
|
|
"""Return (sso, name), enforcing the unknown-badge policy."""
|
|
kind, digits = _parse_badge(badge)
|
|
mode = (Setting.get('employee_directory_mode') or 'selfhosted').lower()
|
|
resolved = (_external_lookup(kind, digits) if mode == 'external'
|
|
else _selfhosted_lookup(digits))
|
|
if resolved is None:
|
|
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
|
|
if policy != 'allow':
|
|
raise BadgeError('Badge not recognized - see the parts team')
|
|
return digits, ''
|
|
return resolved
|
|
```
|
|
|
|
### The single-commit invariant + the endpoints (routes.py)
|
|
|
|
```python
|
|
from ..models import PrintedItemTransaction
|
|
from ..services.badges import BadgeError, resolve_badge
|
|
|
|
|
|
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None):
|
|
"""Append a ledger row and move the cached quantity in ONE commit.
|
|
|
|
Every write path must go through here - it is what keeps
|
|
quantityonhand equal to the ledger sum."""
|
|
item.quantityonhand += quantitychange
|
|
db.session.add(PrintedItemTransaction(
|
|
printeditemid=item.printeditemid,
|
|
transactiontype=transactiontype,
|
|
quantitychange=quantitychange,
|
|
employeesso=sso,
|
|
employeename=name,
|
|
reason=reason,
|
|
))
|
|
db.session.commit()
|
|
|
|
|
|
@printedparts_bp.route('/items/<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.
|
|
|
|
Matches the internal code OR the gage-lab tag exactly; bare keypad
|
|
digits match the numeric tail of EITHER identifier, and only when
|
|
exactly one active item matches (see stage 17)."""
|
|
scanned = (itemcode or '').strip().upper()
|
|
item = PrintedItem.query.filter(
|
|
or_(PrintedItem.itemcode == scanned,
|
|
PrintedItem.gagelabtag == scanned),
|
|
PrintedItem.isactive == True).first()
|
|
if not item and scanned.isdigit():
|
|
wanted = int(scanned)
|
|
matches = []
|
|
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
|
for value in (candidate.itemcode, candidate.gagelabtag):
|
|
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
|
|
if tail and int(tail) == wanted:
|
|
matches.append(candidate)
|
|
break
|
|
if len(matches) == 1:
|
|
item = matches[0]
|
|
return item
|
|
|
|
|
|
@printedparts_bp.route('/kiosk/item/<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 matched server-side against the numeric tail of
|
|
either the internal code or the gage-lab tag (unique match only) - no alphanumeric
|
|
on-screen keyboard needed. Success screen auto-resets after a few seconds.
|
|
Full component (~250 lines) at the tag.
|
|
|
|
### The authz sweep catches you - on purpose
|
|
|
|
The full suite fails:
|
|
`test_authz.py::test_mutation_rejects_roleless_member[printedparts.kiosk_take]`.
|
|
That sweep asserts EVERY mutating route rejects a role-less user - the net
|
|
against accidentally-open writes. Yours is open on purpose, so exempt it
|
|
EXPLICITLY with a comment pointing at the decision record:
|
|
|
|
```python
|
|
EXEMPT_ENDPOINTS = {...,
|
|
# Deliberately open kiosk write: decrement-only,
|
|
# badge-attributed server-side. Decision record in
|
|
# docs/proposals/printedparts-plugin.md.
|
|
'printedparts.kiosk_take'}
|
|
```
|
|
|
|
See it work: scan/type a code -> item card -> badge -> keypad -> TAKE; stock
|
|
drops with your name in the ledger; over-take and unknown-badge produce
|
|
friendly messages.
|
|
Commit + tag `lab-stage-07`.
|
|
|
|
---
|
|
|
|
## Stage 8 - 1in x 0.5in bin labels
|
|
|
|
A plugin OWNS its label page (the USB precedent) - parts are not assets, so
|
|
they do not join the shared asset-label TYPE_CONFIG. New public route beside
|
|
`/print/usb-labels`, view `frontend/src/views/print/PrintedPartsLabels.vue`.
|
|
The pieces that matter:
|
|
|
|
```javascript
|
|
// CODE128 of the short item code fits 1x0.5in with comfortable scanner
|
|
// tolerance; a QR at this size would be marginal.
|
|
JsBarcode(element, label.itemcode, {
|
|
format: 'CODE128', displayValue: false, width: 1.4, height: 26, margin: 0
|
|
})
|
|
```
|
|
|
|
```css
|
|
/* 1in x 0.5in roll stock: one label per page */
|
|
@media print {
|
|
@page { size: 1in 0.5in; margin: 0; }
|
|
.no-print { display: none; }
|
|
.bin-label { page-break-after: always; break-after: page; }
|
|
}
|
|
.bin-label {
|
|
width: 1in; height: 0.5in;
|
|
display: flex; flex-direction: column;
|
|
align-items: center; justify-content: center;
|
|
}
|
|
.bin-barcode { width: 0.92in; height: 0.3in; }
|
|
.bin-code { font-size: 6.5pt; font-family: monospace; }
|
|
```
|
|
|
|
Multi-select + per-item copies; `?item=<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 kiosk resolver above is the stage-17
|
|
version).
|
|
|
|
- **17 - the gage-lab asset tag** (`lab-stage-17`): the field team assigns
|
|
real WJRP asset numbers at the gage lab, so identity split in two: the
|
|
internal `itemcode` stays auto-minted (stable, encodes the row id) and a
|
|
new optional UNIQUE `gagelabtag` carries the lab's number (migration
|
|
0003). Search covers it; the kiosk resolves scans and bare keypad digits
|
|
against the numeric tail of EITHER identifier, unique-match only. Lesson:
|
|
when the real world already numbers things, model their identifier
|
|
alongside yours instead of fighting over one field. Also in this stage:
|
|
the print-files table became stacked revision cards after the table
|
|
forced horizontal scrolling in its column.
|
|
|
|
Post-stage polish (untagged commits): the kiosk launches from the sidebar's
|
|
"Displays" section (beside Shopfloor Dashboard / TV Slideshow, plugin-gated,
|
|
new tab), and the keypad was restyled into a terminal-style panel after the
|
|
first hands-on review. Looks are requirements on a kiosk.
|
|
|
|
The closing lesson: the spec carried this build to stage 10; every stage
|
|
after came from deployment and real users. Plugins are finished by the
|
|
floor, not by the spec.
|
|
|
|
---
|
|
|
|
## Contributing your plugin via GitHub
|
|
|
|
The public home is https://github.com/ge-aero/shopdb-flask. Development flow
|
|
for a contributor:
|
|
|
|
1. **Clone and branch** (never work on main):
|
|
```bash
|
|
git clone https://github.com/ge-aero/shopdb-flask.git
|
|
cd shopdb-flask
|
|
git checkout -b feat/<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**, run the three gates CI runs (in VS Code: the
|
|
**Check: naming + tests + build** task). By hand in PowerShell:
|
|
```powershell
|
|
venv\Scripts\python -m pytest tests/ -q
|
|
cd frontend; npx vitest run; npm run build; cd ..
|
|
bash scripts/check-naming-and-style.sh # naming - runs via Git Bash
|
|
```
|
|
4. **Push your branch and open a Pull Request** against `main`:
|
|
```bash
|
|
git push -u origin feat/<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..17` |
|