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).
690 lines
30 KiB
Markdown
690 lines
30 KiB
Markdown
# Building a ShopDB plugin: the measuringtools walkthrough
|
|
|
|
This guide builds one real plugin, `measuringtools`, from an empty directory to a
|
|
running feature with its own list, detail, form, settings page, and report. It is
|
|
the companion to [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md): the quickstart gets
|
|
you moving with `flask plugin new`; this guide explains *why* each piece looks the
|
|
way it does by walking the shipped code of the exemplar plugin.
|
|
> **Windows / VS Code:** command examples 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).
|
|
|
|
`measuringtools` was chosen as the exemplar on purpose. It is the first plugin
|
|
built after the framework matured (ADR-005 scoped it; ADR-008 changed how plugin
|
|
migrations work; ADR-009 added frontend route gating). It exercises every current
|
|
framework feature correctly, so it doubles as the reference implementation. When
|
|
in doubt about how a plugin should do something, read `plugins/measuringtools/`.
|
|
|
|
The domain: measuring tools are gage-lab instruments (calipers, micrometers,
|
|
thread gages, bore gages, height gages, indicators, Genspect heads) that *measure*
|
|
parts, as opposed to equipment that *makes* parts. They live inside operation
|
|
Locations (e.g. "0615 Blisk Inspection"). Their lifecycle is CALIBRATION, not
|
|
maintenance: an interval, a last date, a next date, and a status derived from the
|
|
next date. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md) for the split
|
|
between `equipment` and `measuringtools`.
|
|
|
|
---
|
|
|
|
## 1. Plugin anatomy
|
|
|
|
A bundled plugin is a Python package under `plugins/<name>/` plus a frontend that
|
|
(for now) lives in core `frontend/src/` (see section 9 and ADR-009 for why). The
|
|
backend tree for `measuringtools`:
|
|
|
|
```
|
|
plugins/measuringtools/
|
|
__init__.py # exports the plugin class
|
|
manifest.json # metadata: name, version, core_version, api_prefix
|
|
plugin.py # MeasuringToolsPlugin(BasePlugin): hooks + on_install
|
|
models/
|
|
__init__.py # re-exports the models + derive_status helper
|
|
measuringtool.py # MeasuringTool, MeasuringToolType, derive_status
|
|
api/
|
|
__init__.py # exports the blueprint
|
|
routes.py # the Flask blueprint (CRUD, types, report)
|
|
migrations/
|
|
env.py # delegates to the shared plugin Alembic runner
|
|
script.py.mako # migration template (copied from a sibling plugin)
|
|
versions/
|
|
0001_measuringtools_baseline.py # REAL baseline: creates the tables
|
|
```
|
|
|
|
Every plugin implements three required hooks (`meta`, `get_blueprint`,
|
|
`get_models`) and overrides the optional hooks it needs. The base class is
|
|
`shopdb/plugins/base.py`; the full hook catalog is
|
|
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md).
|
|
|
|
---
|
|
|
|
## 2. Manifest and versioning
|
|
|
|
`plugins/measuringtools/manifest.json` is the single source of truth for the
|
|
plugin's identity (ADR-002):
|
|
|
|
```json
|
|
{
|
|
"name": "measuringtools",
|
|
"version": "1.0.0",
|
|
"description": "Metrology and inspection instruments ... derived calibration status.",
|
|
"author": "ShopDB Team",
|
|
"dependencies": [],
|
|
"core_version": ">=0.6.0,<1.0.0",
|
|
"api_prefix": "/api/measuringtools",
|
|
"default_enabled": false
|
|
}
|
|
```
|
|
|
|
Two fields deserve attention.
|
|
|
|
`core_version` is a semver range against the framework's `__contract_version__`
|
|
(declared in `shopdb/__init__.py`, currently `0.6.0`). The loader refuses to load
|
|
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
|
|
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
|
|
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because
|
|
the framework is pre-1.0 and the contract can still shift; a sister site forking
|
|
the plugin gets an explicit failure rather than a silent break if it lands on a
|
|
newer, incompatible core. This is the ADR-002 discipline: consume the lowest
|
|
contract version you actually need, and cap below the next major.
|
|
|
|
`default_enabled: false` means installing the plugin does not turn it on. Sites opt
|
|
in. This is the convention for any plugin that provisions extra tables or that not
|
|
every site wants; a gage lab installs it, a site without one does not.
|
|
|
|
The `plugin.py` `meta` property just reads the manifest, so the manifest stays the
|
|
one place these values live:
|
|
|
|
```python
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name=self._manifest.get('name', 'measuringtools'),
|
|
version=self._manifest.get('version', '1.0.0'),
|
|
...
|
|
core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'),
|
|
api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'),
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Models, naming convention, and PLUGIN_TABLE_OWNERS
|
|
|
|
The plugin owns two tables (`plugins/measuringtools/models/measuringtool.py`):
|
|
|
|
- `measuringtooltypes` - the site-managed lookup (Caliper, Micrometer, ...) with a
|
|
display color, the same shape as `machinetypes` / `computertypes`.
|
|
- `measuringtools` - the one-to-one extension of a core `Asset`, carrying only the
|
|
metrology domain fields.
|
|
|
|
```python
|
|
class MeasuringTool(BaseModel):
|
|
__tablename__ = 'measuringtools'
|
|
|
|
measuringtoolid = db.Column(db.Integer, primary_key=True)
|
|
assetid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
|
unique=True, nullable=False, index=True,
|
|
)
|
|
measuringtooltypeid = db.Column(
|
|
db.Integer, db.ForeignKey('measuringtooltypes.measuringtooltypeid'),
|
|
nullable=True)
|
|
calibrationintervaldays = db.Column(db.Integer, nullable=True)
|
|
lastcalibrationdate = db.Column(db.Date, nullable=True)
|
|
nextcalibrationdate = db.Column(db.Date, nullable=True)
|
|
calibrationprovider = db.Column(db.String(150), nullable=True)
|
|
notes = db.Column(db.Text, nullable=True)
|
|
```
|
|
|
|
Design points:
|
|
|
|
- **Identity lives on the Asset, not here.** Per ADR-001 and ADR-005,
|
|
`Asset.assetnumber` is the gage tag, `Asset.serialnumber` is the vendor serial,
|
|
and `Asset.gaugelabreference` is the gage-lab identifier. The extension table
|
|
carries only what is specific to a measuring tool: its type and its calibration
|
|
lifecycle. One extension row per asset, enforced by `unique=True` on `assetid`.
|
|
|
|
- **Naming convention (CONTRIBUTING.md).** DB tables and columns are lowercase and
|
|
concatenated: `measuringtooltypeid`, `calibrationintervaldays`,
|
|
`nextcalibrationdate`. No underscores, no camelCase, no banned shorthand. The
|
|
pre-commit hook `scripts/check-naming-and-style.sh` fails the build otherwise.
|
|
|
|
- **Derived status is never stored.** Calibration status is computed from
|
|
`nextcalibrationdate` at read time, mirroring the warranty plugin's pattern:
|
|
|
|
```python
|
|
DUESOON_WINDOW_DAYS = 30
|
|
STATUS_COLORS = {'overdue': '#F44336', 'duesoon': '#FF9800',
|
|
'current': '#4CAF50', 'unknown': '#9E9E9E'}
|
|
|
|
def derive_status(nextcalibrationdate, today=None):
|
|
if not nextcalibrationdate:
|
|
return 'unknown'
|
|
today = today or date.today()
|
|
if nextcalibrationdate < today:
|
|
return 'overdue'
|
|
if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS):
|
|
return 'duesoon'
|
|
return 'current'
|
|
```
|
|
|
|
Storing a status column would let it go stale the moment a due date passed with no
|
|
write. Deriving it means the list badge, the detail badge, and the report are
|
|
always correct without a nightly job.
|
|
|
|
`to_dict()` overlays the derived status and the type name onto the row and emits
|
|
the two `Date` columns as `YYYY-MM-DD` (the framework's `BaseModel.to_dict` only
|
|
iso-formats `datetime`, not plain `date`, so the plugin does it explicitly for
|
|
predictable JSON).
|
|
|
|
**Register the tables in `PLUGIN_TABLE_OWNERS`.** The migration engine needs an
|
|
explicit map of which tables each plugin owns
|
|
(`shopdb/plugins/alembic_template.py`):
|
|
|
|
```python
|
|
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
|
...
|
|
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
|
...
|
|
}
|
|
```
|
|
|
|
`tests/test_plugin_migrations.py::test_table_owners_match_declared_models` fails if
|
|
this map drifts from the `__tablename__` declarations in either direction, so you
|
|
cannot forget it.
|
|
|
|
---
|
|
|
|
## 4. Per-plugin migrations: anchor vs real baseline
|
|
|
|
This is the part that differs most from the ten older bundled plugins, so read
|
|
[ADR-008](adr/ADR-008-plugin-migration-ownership.md) alongside this section.
|
|
|
|
Every table-owning plugin carries its own Alembic chain under
|
|
`plugins/<name>/migrations/`, with a per-plugin version table
|
|
`alembic_version_<name>` independent of the core `alembic_version`. The `env.py` is
|
|
a three-line delegate to the shared runner:
|
|
|
|
```python
|
|
import os
|
|
os.environ['PLUGIN_NAME'] = 'measuringtools'
|
|
from shopdb.plugins.alembic_template import run_migrations
|
|
run_migrations()
|
|
```
|
|
|
|
The key distinction is the `0001` revision.
|
|
|
|
**Cutover plugins (the older ten) ship a no-op anchor.** ADR-008 froze a cutover
|
|
at the core-chain head. The core Alembic chain had already created every table
|
|
that existed at that point, including those ten plugins' tables. So their `0001`
|
|
migration is a stamp-only no-op: `upgrade()` does `pass`, because the tables
|
|
already exist. It exists only to give the plugin chain a base revision that
|
|
`flask plugin upgrade-all` can stamp.
|
|
|
|
**`measuringtools` was built after the cutover, so its `0001` is a REAL baseline.**
|
|
The core chain never knew about `measuringtooltypes` / `measuringtools`, so this
|
|
per-plugin chain is their sole authoritative creator. `upgrade()` actually creates
|
|
the tables:
|
|
|
|
```python
|
|
revision = 'measuringtools0001baseline'
|
|
down_revision = None
|
|
|
|
def upgrade():
|
|
op.create_table('measuringtooltypes', ...)
|
|
op.create_table('measuringtools',
|
|
...,
|
|
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
|
|
sa.ForeignKeyConstraint(['measuringtooltypeid'],
|
|
['measuringtooltypes.measuringtooltypeid']),
|
|
...)
|
|
op.create_index('idx_measuringtool_type', 'measuringtools', ['measuringtooltypeid'])
|
|
```
|
|
|
|
Two things to know when you write a post-cutover baseline:
|
|
|
|
- **The shared `create_plugin_tables` helper cannot render a foreign key to a core
|
|
table.** It builds a per-plugin `MetaData` filtered to only the plugin's own
|
|
tables, so a foreign key to `assets` has no `assets` table to resolve against and
|
|
Alembic raises `NoReferencedTableError` at compile time. Because these tables
|
|
reference `assets.assetid`, the baseline uses explicit `op.create_table` ops (the
|
|
same shape Alembic autogenerate produces) instead of the helper. If your plugin's
|
|
tables have no FKs to core tables, `create_plugin_tables` works; if they do, hand
|
|
the DDL to `op.create_table`. (This limitation surfaced building this exemplar
|
|
and is a candidate framework fix: teach `_get_plugin_metadata` to pull in
|
|
FK-referenced core tables.)
|
|
|
|
- **Charset.** The baseline emits plain `CREATE TABLE`, so the tables inherit the
|
|
connection's default charset. On the documented utf8mb4 database that yields
|
|
utf8mb4 tables, matching how the core chain creates its tables. No explicit
|
|
`mysql_charset` is needed, and none of the unique columns exceed the InnoDB index
|
|
prefix limit (the longest is `name VARCHAR(100)` = 400 bytes < 767).
|
|
|
|
The deploy sequence is unchanged: `flask db upgrade` (core chain) then
|
|
`flask plugin upgrade-all` (stamps anchors and runs real baselines like this one).
|
|
Both are idempotent.
|
|
|
|
**Adapting the guard test.** `tests/test_plugin_migrations.py` has a test that
|
|
asserts each `0001` anchor is a pure no-op. That is true for the ten cutover
|
|
plugins but deliberately false for `measuringtools`. The fix is to scope the no-op
|
|
assertion to a frozen `CUTOVER_PLUGINS` list rather than to all discovered plugins,
|
|
and to expect `measuringtools`'s real baseline revision in the upgrade-all test:
|
|
|
|
```python
|
|
CUTOVER_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase',
|
|
'network', 'notifications', 'printers', 'slides', 'usb', 'warranty')
|
|
|
|
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS) # not all plugins
|
|
def test_anchor_migration_is_noop(plugin):
|
|
...
|
|
```
|
|
|
|
Freezing the list (rather than deriving it) is intentional: a newly discovered
|
|
plugin should not silently be treated as a cutover no-op.
|
|
|
|
---
|
|
|
|
## 5. Contract purity: import only from `shopdb.api`
|
|
|
|
A plugin may import from exactly two core places: `shopdb.plugins.base` (for
|
|
`BasePlugin` / `PluginMeta`) and `shopdb.api` (everything else). Importing internal
|
|
paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*` is a
|
|
contract violation. `measuringtools` gets its whole surface from `shopdb.api`:
|
|
|
|
```python
|
|
from shopdb.api import (
|
|
db, Asset, AssetType, AuditLog,
|
|
success_response, error_response, paginated_response, ErrorCodes,
|
|
get_pagination_params, paginate_query,
|
|
require_permission,
|
|
)
|
|
from shopdb.api import db, BaseModel # in the models module
|
|
```
|
|
|
|
`shopdb.api` is the versioned platform contract (ADR-001). Adding a name there is
|
|
an additive minor bump; removing one is a breaking major bump. The guard test
|
|
`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface` scans
|
|
every plugin's imports and fails the build if one reaches past the contract. Build
|
|
against `shopdb.api` and your plugin travels cleanly to a sister site running a
|
|
compatible core.
|
|
|
|
---
|
|
|
|
## 6. Blueprint: authz, responses, pagination
|
|
|
|
`plugins/measuringtools/api/routes.py` is a normal Flask blueprint. Three framework
|
|
conventions run through it.
|
|
|
|
**Reads are `jwt_required(optional=True)`; writes require a permission.** This is
|
|
the app-wide pattern: anonymous kiosks and unauthenticated internal users can read
|
|
the asset catalog, but any state change needs a logged-in user with the right
|
|
permission.
|
|
|
|
```python
|
|
@measuringtools_bp.route('', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def list_tools():
|
|
...
|
|
|
|
@measuringtools_bp.route('', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('measuringtools.create')
|
|
def create_tool():
|
|
...
|
|
```
|
|
|
|
The `measuringtools.*` permissions belong to the plugin, not to core. The plugin
|
|
declares them from the `get_permissions` hook (contract 0.10.0) so core never edits
|
|
its catalog to accommodate a plugin:
|
|
|
|
```python
|
|
class MeasuringToolsPlugin(BasePlugin):
|
|
def get_permissions(self):
|
|
return [
|
|
('measuringtools.view', 'View measuring tools', 'measuringtools'),
|
|
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
|
|
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
|
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
|
|
]
|
|
```
|
|
|
|
Installing or enabling the plugin seeds these rows automatically, and
|
|
`flask seed permissions` (which now seeds core plus every enabled plugin) is
|
|
idempotent, so re-running it just adds any missing rows. The `admin` role bypasses
|
|
every permission check, so an admin can operate the plugin before anyone grants the
|
|
granular permissions. See `get_permissions` in `docs/PLUGIN-HOOKS.md` for the
|
|
disabled-plugin edge case.
|
|
|
|
**Responses use the framework helpers.** `success_response`, `error_response` (with
|
|
`ErrorCodes`), and `paginated_response` produce the standard envelope
|
|
(`{status, data, meta}`) so every plugin's API looks the same to the frontend. The
|
|
list endpoint pages with `get_pagination_params` + `paginate_query`.
|
|
|
|
**The write path merges asset core and extension in one payload**, mirroring
|
|
`equipment` and `computers`. Create builds the `Asset` first, flushes to get the
|
|
`assetid`, then builds the extension row and writes an audit log:
|
|
|
|
```python
|
|
asset = Asset(assetnumber=data['assetnumber'], ..., assettypeid=assettypeid, ...)
|
|
db.session.add(asset); db.session.flush()
|
|
tool = MeasuringTool(assetid=asset.assetid, measuringtooltypeid=..., ...)
|
|
db.session.add(tool); db.session.flush()
|
|
AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid,
|
|
entityname=asset.assetnumber)
|
|
db.session.commit()
|
|
return success_response(_merged(tool), message='Measuring tool created', http_code=201)
|
|
```
|
|
|
|
`_merged()` returns `asset.to_dict()` with the extension nested under
|
|
`measuringtool`, so the GET, POST, and PUT responses all share one shape.
|
|
|
|
**The types resource has an in-use delete guard.** Deleting a type that tools still
|
|
reference is refused with a 409, so a color/name in use cannot vanish out from
|
|
under existing rows:
|
|
|
|
```python
|
|
inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count()
|
|
if inuse:
|
|
return error_response(ErrorCodes.CONFLICT,
|
|
f'Cannot delete: {inuse} tool(s) still use this type', http_code=409)
|
|
```
|
|
|
|
**The calibration report** mirrors the warranty report shape: counts plus lists
|
|
bucketed by derived status.
|
|
|
|
```python
|
|
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def calibration_report():
|
|
buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []}
|
|
for tool in MeasuringTool.query.join(Asset).filter(Asset.isactive == True).all():
|
|
buckets[derive_status(tool.nextcalibrationdate)].append(_merged(tool))
|
|
return success_response({'counts': {k: len(v) for k, v in buckets.items()},
|
|
'buckets': buckets, 'statuscolors': STATUS_COLORS})
|
|
```
|
|
|
|
One deliberate note: the list endpoint's `calibrationstatus` filter is applied to
|
|
the built rows, not as a SQL `WHERE`, because the status is derived and not a
|
|
column. That is fine for a gage lab's worth of tools; it would need rethinking for
|
|
a fleet the size of the PC estate.
|
|
|
|
---
|
|
|
|
## 7. on_install seeding
|
|
|
|
`on_install` runs once, when a site runs `flask plugin install measuringtools`. It
|
|
seeds the plugin's own asset type and a set of starter tool types:
|
|
|
|
```python
|
|
def on_install(self, app: Flask) -> None:
|
|
with app.app_context():
|
|
self._ensure_asset_type() # AssetType 'measuring_tool'
|
|
self._ensure_starter_types() # Caliper, Micrometer, Thread Gage, ...
|
|
db.session.commit()
|
|
```
|
|
|
|
`_ensure_asset_type` registers `AssetType(assettype='measuring_tool',
|
|
pluginname='measuringtools', tablename='measuringtools', icon='ruler')`. The asset
|
|
type is what ties a core `Asset` to this plugin's extension table and drives the
|
|
custom-fields lookup (section 10). The starter types come with colors drawn from
|
|
the shared frontend palette so map markers and badges are legible out of the box.
|
|
|
|
Seeding is idempotent (each `_ensure_*` checks for existence first), so a reinstall
|
|
does not duplicate rows.
|
|
|
|
---
|
|
|
|
## 8. Hooks, one by one
|
|
|
|
`measuringtools` implements four optional hooks and consciously skips two.
|
|
|
|
**`get_navigation_items`** puts "Measuring Tools" in the sidebar. Navigation is
|
|
data-driven: the frontend renders whatever enabled plugins return, so no core file
|
|
lists the link.
|
|
|
|
```python
|
|
def get_navigation_items(self):
|
|
return [{'name': 'Measuring Tools', 'icon': 'ruler',
|
|
'route': '/measuringtools', 'position': 22}]
|
|
```
|
|
|
|
`position: 22` slots it into the Assets band (10-29) after Network and Printers.
|
|
The `icon` is a string key; the frontend maps `'ruler'` to a Lucide component
|
|
(section 9).
|
|
|
|
**`get_reports`** (contract 0.6.0) contributes the calibration card to the Reports
|
|
hub. Each card has a stable `id`, a `name`, a `description`, a `category`, and
|
|
exactly one of `route` (a dedicated page) or `endpoint` (inline render). We use a
|
|
route because the report is a full bucketed page:
|
|
|
|
```python
|
|
def get_reports(self):
|
|
return [{'id': 'calibration', 'name': 'Calibration Due',
|
|
'description': 'Measuring tools bucketed by calibration status',
|
|
'category': 'compliance', 'route': '/reports/calibration'}]
|
|
```
|
|
|
|
`GET /api/reports` merges this after the core reports and drops it when the plugin
|
|
is disabled.
|
|
|
|
**`get_models`** returns `[MeasuringTool, MeasuringToolType]` so migration tooling
|
|
and admin views know the plugin's tables.
|
|
|
|
**`get_config_schema` returns `[]`, on purpose.** The setup wizard reads this to
|
|
know what a plugin needs configured. Measuring tools are tracked by hand: there is
|
|
no external system to authenticate against, no endpoint URL, no API key. So the
|
|
schema is empty and the wizard shows nothing to configure. This is the intentional
|
|
"no external creds" case. Contrast the printers plugin, which declares a Zabbix URL
|
|
field. If your plugin talks to an external service, declare its (non-secret)
|
|
settings here and mark credentials `secret: True` so the wizard emits an `.env`
|
|
line instead of storing them in the database.
|
|
|
|
**Skipped: the collector hooks (`get_collector_schema` /
|
|
`apply_collector_payload`).** These (ADR-006) exist for plugins fed by an automated
|
|
agent pushing to `/api/collector/<name>`, like `computers` fed by the PXE pipeline.
|
|
Measuring tools are entered by hand in the gage lab; there is no collector. Because
|
|
`get_collector_schema` returns `None` (the base-class default), no collector
|
|
endpoint is registered and `apply_collector_payload` is never called. The contract
|
|
test `test_schema_declaring_plugins_implement_apply` only requires the upsert method
|
|
when a schema is declared, so skipping both is clean. If a site later automates
|
|
calibration imports (say from a cal-lab spreadsheet), that is the hook to add.
|
|
|
|
**Not skipped so much as not needed: dashboard widgets, services, CLI commands.**
|
|
`get_dashboard_widgets` could add a "calibration due" tile later; `get_services`
|
|
and `get_cli_commands` have no use here yet. Leaving them at their defaults keeps
|
|
the plugin small.
|
|
|
|
---
|
|
|
|
## 9. Frontend integration
|
|
|
|
There is no frontend plugin system yet (see
|
|
[ADR-009](adr/ADR-009-frontend-plugin-gating.md), "Future direction"). A plugin's
|
|
Vue routes and views ship in the core bundle. The plugin's job is to add them
|
|
correctly and gate them.
|
|
|
|
**Route module with `meta.plugin` gating (ADR-009).** A new file
|
|
`frontend/src/router/routes/measuringtools.js` is auto-discovered by the router's
|
|
`import.meta.glob('./routes/*.js')`. Every route carries `meta.plugin =
|
|
'measuringtools'`:
|
|
|
|
```js
|
|
export default [
|
|
{ path: 'measuringtools', name: 'measuringtools',
|
|
component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
|
|
meta: { plugin: 'measuringtools' } },
|
|
{ path: 'measuringtools/new', name: 'measuringtool-new',
|
|
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
|
meta: { requiresAuth: true, plugin: 'measuringtools' } },
|
|
{ path: 'measuringtools/:id', ..., meta: { plugin: 'measuringtools' } },
|
|
{ path: 'measuringtools/:id/edit', ..., meta: { requiresAuth: true, plugin: 'measuringtools' } },
|
|
{ path: 'reports/calibration', ..., meta: { plugin: 'measuringtools' } },
|
|
{ path: 'settings/measuringtooltypes', ...,
|
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } },
|
|
]
|
|
```
|
|
|
|
`meta.plugin` makes the router guard redirect to the dashboard (instead of loading
|
|
a broken shell) when the backend plugin is disabled. It fetches
|
|
`GET /api/plugins/enabled` once, cached, and fails open. Note the auth pattern that
|
|
matches the rest of the app: list and detail are public (no `requiresAuth`); the
|
|
form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdmin`.
|
|
|
|
**API client, addition only.** `frontend/src/api/index.js` gets a
|
|
`measuringtoolsApi` object appended after `warrantyApi` (`list`, `get`, `create`,
|
|
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
|
|
reorganize the file; just add the block, mirroring `machinesApi`.
|
|
|
|
**Views mirror the master templates.** The frontend has master templates
|
|
(`PrintersList.vue` for lists, `PrinterDetail.vue` for detail pages). `measuringtools` mirrors the equivalent equipment views:
|
|
|
|
- `views/measuringtools/MeasuringToolsList.vue` - table with search, a type filter,
|
|
and a calibration-status filter; the status badge uses `utils/colorStyle` with
|
|
the color the API derived.
|
|
- `views/measuringtools/MeasuringToolDetail.vue` - hero + Identity card +
|
|
Calibration card (with the derived badge) + Location card, plus the shared
|
|
`CustomFieldsSection` and `WarrantyPanel` (section 10).
|
|
- `views/measuringtools/MeasuringToolForm.vue` - asset core fields + type +
|
|
location + the calibration fields, plus `CustomFieldsInputs`.
|
|
- `views/reports/CalibrationReport.vue` - the four buckets (overdue / due soon /
|
|
current / unknown), mirroring `WarrantyReport.vue`.
|
|
|
|
**Settings subtype page.** `views/settings/MeasuringToolTypesList.vue` mirrors
|
|
`PCTypesList.vue`: add / edit / delete with a `ColorSwatchPicker`. It is linked from
|
|
`settingsNav.js` with a "Measuring Tools" card group, so it appears in the settings
|
|
rail and landing overview.
|
|
|
|
**iconMap, entry only.** The sidebar maps backend icon-name strings to Lucide
|
|
components in `AppLayout.vue`. Add the import and one map entry:
|
|
|
|
```js
|
|
import { ..., Ruler } from 'lucide-vue-next'
|
|
const iconMap = { ..., 'ruler': Ruler }
|
|
```
|
|
|
|
That is the only change to `AppLayout.vue`. This is exactly the kind of core-file
|
|
edit ADR-009's future direction wants to replace with a registration API; for now
|
|
it is a one-line addition.
|
|
|
|
---
|
|
|
|
## 10. Custom fields and warranty panel composition
|
|
|
|
Two cross-cutting core features compose onto the plugin's pages for free.
|
|
|
|
**Custom fields.** Sites define extra attributes per asset type in Settings. The
|
|
detail page drops in `<CustomFieldsSection :assetid="tool.assetid" />` and the form
|
|
drops in `<CustomFieldsInputs :assettypeid="assettypeid" :assetid="currentAssetId" />`,
|
|
then calls `customFieldsRef.value.save(assetId)` after the tool saves. The one
|
|
subtlety: `CustomFieldsInputs` needs the asset-type id. Rather than hardcode it (the
|
|
equipment form hardcodes `EQUIPMENT_ASSETTYPEID = 1`), the measuringtools form
|
|
resolves it dynamically from `GET /api/assets/types`, finding the row whose
|
|
`assettype === 'measuring_tool'`. Dynamic lookup is preferred because seeded ids are
|
|
not stable across sites.
|
|
|
|
**Warranty panel.** `<WarrantyPanel :assetid="tool.assetid" :items="warranties" />`
|
|
renders any warranties covering the tool. Warranty is an asset-general plugin, so it
|
|
works on any asset type with no coupling; the measuring-tool detail page composes it
|
|
the same way the equipment and PC detail pages do.
|
|
|
|
**Map.** The asset map is data-driven off asset types and resolved positions
|
|
(ADR-001). Once a measuring tool is typed and given a location or map coordinates,
|
|
it appears on `/assets/map` with its type color, with no plugin-side map code. There
|
|
is nothing to force; giving the type a color and the asset a position is enough.
|
|
|
|
---
|
|
|
|
## 11. Tests
|
|
|
|
`tests/test_plugins/test_measuringtools.py` covers the plugin end to end:
|
|
|
|
- **Derived status**, as pure unit tests against `derive_status`: all four buckets
|
|
plus the 30-day boundary (exactly `+30` is still "due soon"; due today is "due
|
|
soon", not "overdue").
|
|
- **Type CRUD + the in-use delete guard** (409 when a tool references the type).
|
|
- **Tool create/update in one merged payload**, asserting asset core fields land at
|
|
the top level and extension fields nest under `measuringtool`, and that a past
|
|
`nextcalibrationdate` derives `overdue`.
|
|
- **The report shape** (counts and buckets agree; the four keys are present).
|
|
|
|
Because the plugin ships `default_enabled: false`, the shared session app does not
|
|
register its blueprint. The test module builds its own app, registers the blueprint,
|
|
runs `on_install` seeding, and restores the process-wide `plugin_manager` singleton
|
|
afterward, the same snapshot/restore pattern `test_plugin_migrations.py` uses. The
|
|
existing parametrized contract and migration tests pick the plugin up automatically
|
|
from the filesystem and must stay green.
|
|
|
|
Run the suite:
|
|
|
|
```bash
|
|
venv/bin/python -m pytest
|
|
bash scripts/check-naming-and-style.sh
|
|
cd frontend && npm run build
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Install / enable lifecycle
|
|
|
|
The plugin is on disk but does nothing until a site installs it. Two commands, then
|
|
a restart:
|
|
|
|
```bash
|
|
flask plugin install measuringtools # runs the 0001 baseline, then on_install seeding
|
|
flask plugin enable measuringtools # default_enabled is false, so enable explicitly
|
|
```
|
|
|
|
`install` runs the migration (creating the tables), registers the plugin in
|
|
`instance/plugins.json`, and calls `on_install`. Because `default_enabled` is false,
|
|
it installs disabled; `enable` flips the registry flag. The registry entry looks
|
|
like:
|
|
|
|
```json
|
|
"measuringtools": { "name": "measuringtools", "version": "1.0.0",
|
|
"enabled": true, "migrations_applied": [], "config": {} }
|
|
```
|
|
|
|
**Restart is required.** Blueprints are registered at app startup; Flask forbids
|
|
registering one after the app has served a request. So after enabling, restart the
|
|
API so the loader registers `/api/measuringtools`. On a dev box:
|
|
|
|
```bash
|
|
# kill the running server on :5001, then:
|
|
setsid venv/bin/flask run --port 5001 --no-reload >/tmp/flask.log 2>&1 < /dev/null &
|
|
```
|
|
|
|
For a deploy, the sequence is `flask db upgrade` then `flask plugin upgrade-all`
|
|
(which runs this plugin's baseline on a fresh install and is a no-op on an existing
|
|
one), then a service restart. See [PLUGINS.md](PLUGINS.md) and
|
|
[ADR-008](adr/ADR-008-plugin-migration-ownership.md).
|
|
|
|
---
|
|
|
|
## End checklist
|
|
|
|
When you build a plugin, confirm all of this before you call it done:
|
|
|
|
- [ ] `manifest.json`: name, version, `core_version` pinned to the lowest contract
|
|
you need and capped below the next major, `api_prefix`, `default_enabled`.
|
|
- [ ] Models declare tables with the naming convention; identity stays on `Asset`.
|
|
- [ ] Derived state (if any) is computed at read time, never stored.
|
|
- [ ] Tables registered in `PLUGIN_TABLE_OWNERS`.
|
|
- [ ] Per-plugin migration chain: no-op anchor if your tables predate the cutover, a
|
|
real baseline if the plugin is post-cutover. Hand-write `op.create_table` if
|
|
you have FKs to core tables.
|
|
- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (contract test green).
|
|
- [ ] Blueprint: jwt-optional reads, permission-gated writes; framework response and
|
|
pagination helpers; audit logs on writes.
|
|
- [ ] Permissions declared from the `get_permissions` hook; install/enable (or `flask seed permissions`) seeds them.
|
|
- [ ] `on_install` seeds the asset type and any reference data, idempotently.
|
|
- [ ] Hooks: navigation, reports, models implemented; config schema and collector
|
|
implemented or consciously skipped with a reason.
|
|
- [ ] Frontend: route module with `meta.plugin` gating, API client addition, views
|
|
mirroring the master templates, settings subtype page + `settingsNav` entry,
|
|
one `iconMap` entry.
|
|
- [ ] Custom fields and warranty panel composed onto the detail/form pages.
|
|
- [ ] Tests: unit + API, self-contained app if `default_enabled` is false; full
|
|
suite and naming/style check green; `npm run build` green.
|
|
- [ ] Install / enable / restart verified live; a demo record renders on every page.
|