# 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 `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 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 `machinesApi`.

**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.
