# 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//` 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//migrations/`, with a per-plugin version table `alembic_version_` 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/`, 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 `` and the form drops in ``, 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.** `` 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.