Add the measuringtools plugin (ADR-005) and the plugin-system tutorial
Gage-lab instruments as Asset extensions: measuringtooltypes (color-coded lookup) + measuringtools (calibration interval/dates, provider, notes) with calibration status derived at read time (overdue / due soon / current / unknown), never stored. Full CRUD API with permission-gated writes, types management with in-use guard, calibration report, nav/reports/config-schema hooks, and a complete frontend (list/detail/form, types settings page, calibration report page, gated routes per ADR-009). First plugin whose migration chain really creates tables post-cutover (ADR-008), and the working example for docs/PLUGIN-GUIDE.md - a 12-section walkthrough of building a plugin on this framework, linked from PLUGIN-QUICKSTART and PLUGINS. Verified: full suite 323 passing, live E2E on all four pages, fresh scratch-MySQL migration dry-run green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
679
docs/PLUGIN-GUIDE.md
Normal file
679
docs/PLUGIN-GUIDE.md
Normal file
@@ -0,0 +1,679 @@
|
||||
# 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.
|
||||
|
||||
`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 `equipmenttypes` / `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 are seeded exactly the way warranty seeds its
|
||||
own, by adding them to `Permission.PERMISSIONS` in `shopdb/core/models/user.py`:
|
||||
|
||||
```python
|
||||
# Measuring tools
|
||||
('measuringtools.view', 'View measuring tools', 'measuringtools'),
|
||||
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
|
||||
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
|
||||
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
|
||||
```
|
||||
|
||||
`flask seed permissions` is idempotent, so re-running it just adds the four new
|
||||
rows. The `admin` role bypasses every permission check, so an admin can operate the
|
||||
plugin before anyone grants the granular permissions.
|
||||
|
||||
**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 `equipmentApi`.
|
||||
|
||||
**Views mirror the master templates.** The frontend has master templates the
|
||||
frontend CLAUDE.md points to (`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 added to `Permission.PERMISSIONS`; `flask seed permissions` run.
|
||||
- [ ] `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.
|
||||
Reference in New Issue
Block a user