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).
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 usevenv\Scripts\pythonand$env:FLASK_APP="shopdb"(notexport). 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 asmachinetypes/computertypes.measuringtools- the one-to-one extension of a coreAsset, 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.assetnumberis the gage tag,Asset.serialnumberis the vendor serial, andAsset.gaugelabreferenceis 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 byunique=Trueonassetid. -
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 hookscripts/check-naming-and-style.shfails the build otherwise. -
Derived status is never stored. Calibration status is computed from
nextcalibrationdateat 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_tableshelper cannot render a foreign key to a core table. It builds a per-pluginMetaDatafiltered to only the plugin's own tables, so a foreign key toassetshas noassetstable to resolve against and Alembic raisesNoReferencedTableErrorat compile time. Because these tables referenceassets.assetid, the baseline uses explicitop.create_tableops (the same shape Alembic autogenerate produces) instead of the helper. If your plugin's tables have no FKs to core tables,create_plugin_tablesworks; if they do, hand the DDL toop.create_table. (This limitation surfaced building this exemplar and is a candidate framework fix: teach_get_plugin_metadatato 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 explicitmysql_charsetis needed, and none of the unique columns exceed the InnoDB index prefix limit (the longest isname 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 usesutils/colorStylewith the color the API derived.views/measuringtools/MeasuringToolDetail.vue- hero + Identity card + Calibration card (with the derived badge) + Location card, plus the sharedCustomFieldsSectionandWarrantyPanel(section 10).views/measuringtools/MeasuringToolForm.vue- asset core fields + type + location + the calibration fields, plusCustomFieldsInputs.views/reports/CalibrationReport.vue- the four buckets (overdue / due soon / current / unknown), mirroringWarrantyReport.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+30is 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 pastnextcalibrationdatederivesoverdue. - 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_versionpinned 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_tableif you have FKs to core tables. - Imports only from
shopdb.apiandshopdb.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_permissionshook; install/enable (orflask seed permissions) seeds them. on_installseeds 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.plugingating, API client addition, views mirroring the master templates, settings subtype page +settingsNaventry, oneiconMapentry. - Custom fields and warranty panel composed onto the detail/form pages.
- Tests: unit + API, self-contained app if
default_enabledis false; full suite and naming/style check green;npm run buildgreen. - Install / enable / restart verified live; a demo record renders on every page.