Files
shopdb-flask/docs/PLUGIN-GUIDE.md
cproudlock f77f0a8d90
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Add GitHub Actions CI + Windows notes on the developer docs
GitHub had no CI, so naming/tests/build were unenforced on the public
mirror. Add .github/workflows/ci.yml mirroring the internal pipeline:
backend pytest, the naming gate, frontend vitest+build, and the
migrations-mysql job that proves a fresh flask db upgrade + every
plugin chain on utf8mb4 MySQL 8 is idempotent. Flip the dev-setup CI
note to reflect it. Add an identical Windows/VS Code convention note to
the four developer docs (venv\Scripts vs venv/bin, $env: vs export,
pointer to DEVELOPMENT-SETUP).
2026-07-17 15:12:36 -04:00

30 KiB

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: the quickstart gets you moving with flask plugin new; this guide explains why each piece looks the way it does by walking the shipped code of the exemplar plugin.

Windows / VS Code: command examples use the Linux venv path venv/bin/python; on Windows use venv\Scripts\python and $env:FLASK_APP="shopdb" (not export). Full Windows onboarding: DEVELOPMENT-SETUP.

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


2. Manifest and versioning

plugins/measuringtools/manifest.json is the single source of truth for the plugin's identity (ADR-002):

{
  "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, "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:

@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.
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:

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):

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 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:

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:

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:

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:

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.

@measuringtools_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_tools():
    ...

@measuringtools_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('measuringtools.create')
def create_tool():
    ...

The measuringtools.* permissions belong to the plugin, not to core. The plugin declares them from the get_permissions hook (contract 0.10.0) so core never edits its catalog to accommodate a plugin:

class MeasuringToolsPlugin(BasePlugin):
    def get_permissions(self):
        return [
            ('measuringtools.view', 'View measuring tools', 'measuringtools'),
            ('measuringtools.create', 'Create measuring tools', 'measuringtools'),
            ('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
            ('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
        ]

Installing or enabling the plugin seeds these rows automatically, and flask seed permissions (which now seeds core plus every enabled plugin) is idempotent, so re-running it just adds any missing rows. The admin role bypasses every permission check, so an admin can operate the plugin before anyone grants the granular permissions. See get_permissions in docs/PLUGIN-HOOKS.md for the disabled-plugin edge case.

Responses use the framework helpers. success_response, error_response (with ErrorCodes), and paginated_response produce the standard envelope ({status, data, meta}) so every plugin's API looks the same to the frontend. The list endpoint pages with get_pagination_params + paginate_query.

The write path merges asset core and extension in one payload, mirroring equipment and computers. Create builds the Asset first, flushes to get the assetid, then builds the extension row and writes an audit log:

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:

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.

@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:

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.

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:

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, "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':

export default [
  { path: 'measuringtools', name: 'measuringtools',
    component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
    meta: { plugin: 'measuringtools' } },
  { path: 'measuringtools/new', name: 'measuringtool-new',
    component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
    meta: { requiresAuth: true, plugin: 'measuringtools' } },
  { path: 'measuringtools/:id', ..., meta: { plugin: 'measuringtools' } },
  { path: 'measuringtools/:id/edit', ..., meta: { requiresAuth: true, plugin: 'measuringtools' } },
  { path: 'reports/calibration', ..., meta: { plugin: 'measuringtools' } },
  { path: 'settings/measuringtooltypes', ...,
    meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } },
]

meta.plugin makes the router guard redirect to the dashboard (instead of loading a broken shell) when the backend plugin is disabled. It fetches GET /api/plugins/enabled once, cached, and fails open. Note the auth pattern that matches the rest of the app: list and detail are public (no requiresAuth); the form is requiresAuth; the settings subtype page is requiresAuth + requiresAdmin.

API client, addition only. frontend/src/api/index.js gets a measuringtoolsApi object appended after warrantyApi (list, get, create, update, remove, calibrationReport, and a nested types CRUD). Do not reorganize the file; just add the block, mirroring machinesApi.

Views mirror the master templates. The frontend has master templates (PrintersList.vue for lists, PrinterDetail.vue for detail pages). measuringtools mirrors the equivalent equipment views:

  • views/measuringtools/MeasuringToolsList.vue - table with search, a type filter, and a calibration-status filter; the status badge uses utils/colorStyle with the color the API derived.
  • views/measuringtools/MeasuringToolDetail.vue - hero + Identity card + Calibration card (with the derived badge) + Location card, plus the shared CustomFieldsSection and WarrantyPanel (section 10).
  • views/measuringtools/MeasuringToolForm.vue - asset core fields + type + location + the calibration fields, plus CustomFieldsInputs.
  • views/reports/CalibrationReport.vue - the four buckets (overdue / due soon / current / unknown), mirroring WarrantyReport.vue.

Settings subtype page. views/settings/MeasuringToolTypesList.vue mirrors PCTypesList.vue: add / edit / delete with a ColorSwatchPicker. It is linked from settingsNav.js with a "Measuring Tools" card group, so it appears in the settings rail and landing overview.

iconMap, entry only. The sidebar maps backend icon-name strings to Lucide components in AppLayout.vue. Add the import and one map entry:

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:

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:

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:

"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:

# 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 and ADR-008.


End checklist

When you build a plugin, confirm all of this before you call it done:

  • manifest.json: name, version, core_version pinned to the lowest contract you need and capped below the next major, api_prefix, default_enabled.
  • Models declare tables with the naming convention; identity stays on Asset.
  • Derived state (if any) is computed at read time, never stored.
  • Tables registered in PLUGIN_TABLE_OWNERS.
  • Per-plugin migration chain: no-op anchor if your tables predate the cutover, a real baseline if the plugin is post-cutover. Hand-write op.create_table if you have FKs to core tables.
  • Imports only from shopdb.api and shopdb.plugins.base (contract test green).
  • Blueprint: jwt-optional reads, permission-gated writes; framework response and pagination helpers; audit logs on writes.
  • Permissions declared from the get_permissions hook; install/enable (or flask seed permissions) seeds them.
  • on_install seeds the asset type and any reference data, idempotently.
  • Hooks: navigation, reports, models implemented; config schema and collector implemented or consciously skipped with a reason.
  • Frontend: route module with meta.plugin gating, API client addition, views mirroring the master templates, settings subtype page + settingsNav entry, one iconMap entry.
  • Custom fields and warranty panel composed onto the detail/form pages.
  • Tests: unit + API, self-contained app if default_enabled is false; full suite and naming/style check green; npm run build green.
  • Install / enable / restart verified live; a demo record renders on every page.