Add the measuringtools plugin (ADR-005) and the plugin-system tutorial
Some checks failed
CI / backend (push) Failing after 28s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 43s

Gage-lab instruments as Asset extensions: measuringtooltypes (color-coded
lookup) + measuringtools (calibration interval/dates, provider, notes) with
calibration status derived at read time (overdue / due soon / current /
unknown), never stored. Full CRUD API with permission-gated writes, types
management with in-use guard, calibration report, nav/reports/config-schema
hooks, and a complete frontend (list/detail/form, types settings page,
calibration report page, gated routes per ADR-009).

First plugin whose migration chain really creates tables post-cutover
(ADR-008), and the working example for docs/PLUGIN-GUIDE.md - a 12-section
walkthrough of building a plugin on this framework, linked from
PLUGIN-QUICKSTART and PLUGINS.

Verified: full suite 323 passing, live E2E on all four pages, fresh
scratch-MySQL migration dry-run green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 10:02:01 -04:00
parent 22e623c1f6
commit 2efe17b743
18 changed files with 2649 additions and 0 deletions

679
docs/PLUGIN-GUIDE.md Normal file
View File

@@ -0,0 +1,679 @@
# Building a ShopDB plugin: the measuringtools walkthrough
This guide builds one real plugin, `measuringtools`, from an empty directory to a
running feature with its own list, detail, form, settings page, and report. It is
the companion to [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md): the quickstart gets
you moving with `flask plugin new`; this guide explains *why* each piece looks the
way it does by walking the shipped code of the exemplar plugin.
`measuringtools` was chosen as the exemplar on purpose. It is the first plugin
built after the framework matured (ADR-005 scoped it; ADR-008 changed how plugin
migrations work; ADR-009 added frontend route gating). It exercises every current
framework feature correctly, so it doubles as the reference implementation. When
in doubt about how a plugin should do something, read `plugins/measuringtools/`.
The domain: measuring tools are gage-lab instruments (calipers, micrometers,
thread gages, bore gages, height gages, indicators, Genspect heads) that *measure*
parts, as opposed to equipment that *makes* parts. They live inside operation
Locations (e.g. "0615 Blisk Inspection"). Their lifecycle is CALIBRATION, not
maintenance: an interval, a last date, a next date, and a status derived from the
next date. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md) for the split
between `equipment` and `measuringtools`.
---
## 1. Plugin anatomy
A bundled plugin is a Python package under `plugins/<name>/` plus a frontend that
(for now) lives in core `frontend/src/` (see section 9 and ADR-009 for why). The
backend tree for `measuringtools`:
```
plugins/measuringtools/
__init__.py # exports the plugin class
manifest.json # metadata: name, version, core_version, api_prefix
plugin.py # MeasuringToolsPlugin(BasePlugin): hooks + on_install
models/
__init__.py # re-exports the models + derive_status helper
measuringtool.py # MeasuringTool, MeasuringToolType, derive_status
api/
__init__.py # exports the blueprint
routes.py # the Flask blueprint (CRUD, types, report)
migrations/
env.py # delegates to the shared plugin Alembic runner
script.py.mako # migration template (copied from a sibling plugin)
versions/
0001_measuringtools_baseline.py # REAL baseline: creates the tables
```
Every plugin implements three required hooks (`meta`, `get_blueprint`,
`get_models`) and overrides the optional hooks it needs. The base class is
`shopdb/plugins/base.py`; the full hook catalog is
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md).
---
## 2. Manifest and versioning
`plugins/measuringtools/manifest.json` is the single source of truth for the
plugin's identity (ADR-002):
```json
{
"name": "measuringtools",
"version": "1.0.0",
"description": "Metrology and inspection instruments ... derived calibration status.",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.6.0,<1.0.0",
"api_prefix": "/api/measuringtools",
"default_enabled": false
}
```
Two fields deserve attention.
`core_version` is a semver range against the framework's `__contract_version__`
(declared in `shopdb/__init__.py`, currently `0.6.0`). The loader refuses to load
a plugin whose range excludes the running framework. We pin `>=0.6.0` because this
plugin uses the `get_reports` hook, which was added to the contract in 0.6.0
(see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), "get_reports"). We cap at `<1.0.0` because
the framework is pre-1.0 and the contract can still shift; a sister site forking
the plugin gets an explicit failure rather than a silent break if it lands on a
newer, incompatible core. This is the ADR-002 discipline: consume the lowest
contract version you actually need, and cap below the next major.
`default_enabled: false` means installing the plugin does not turn it on. Sites opt
in. This is the convention for any plugin that provisions extra tables or that not
every site wants; a gage lab installs it, a site without one does not.
The `plugin.py` `meta` property just reads the manifest, so the manifest stays the
one place these values live:
```python
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'measuringtools'),
version=self._manifest.get('version', '1.0.0'),
...
core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'),
)
```
---
## 3. Models, naming convention, and PLUGIN_TABLE_OWNERS
The plugin owns two tables (`plugins/measuringtools/models/measuringtool.py`):
- `measuringtooltypes` - the site-managed lookup (Caliper, Micrometer, ...) with a
display color, the same shape as `equipmenttypes` / `computertypes`.
- `measuringtools` - the one-to-one extension of a core `Asset`, carrying only the
metrology domain fields.
```python
class MeasuringTool(BaseModel):
__tablename__ = 'measuringtools'
measuringtoolid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True, nullable=False, index=True,
)
measuringtooltypeid = db.Column(
db.Integer, db.ForeignKey('measuringtooltypes.measuringtooltypeid'),
nullable=True)
calibrationintervaldays = db.Column(db.Integer, nullable=True)
lastcalibrationdate = db.Column(db.Date, nullable=True)
nextcalibrationdate = db.Column(db.Date, nullable=True)
calibrationprovider = db.Column(db.String(150), nullable=True)
notes = db.Column(db.Text, nullable=True)
```
Design points:
- **Identity lives on the Asset, not here.** Per ADR-001 and ADR-005,
`Asset.assetnumber` is the gage tag, `Asset.serialnumber` is the vendor serial,
and `Asset.gaugelabreference` is the gage-lab identifier. The extension table
carries only what is specific to a measuring tool: its type and its calibration
lifecycle. One extension row per asset, enforced by `unique=True` on `assetid`.
- **Naming convention (CONTRIBUTING.md).** DB tables and columns are lowercase and
concatenated: `measuringtooltypeid`, `calibrationintervaldays`,
`nextcalibrationdate`. No underscores, no camelCase, no banned shorthand. The
pre-commit hook `scripts/check-naming-and-style.sh` fails the build otherwise.
- **Derived status is never stored.** Calibration status is computed from
`nextcalibrationdate` at read time, mirroring the warranty plugin's pattern:
```python
DUESOON_WINDOW_DAYS = 30
STATUS_COLORS = {'overdue': '#F44336', 'duesoon': '#FF9800',
'current': '#4CAF50', 'unknown': '#9E9E9E'}
def derive_status(nextcalibrationdate, today=None):
if not nextcalibrationdate:
return 'unknown'
today = today or date.today()
if nextcalibrationdate < today:
return 'overdue'
if nextcalibrationdate <= today + timedelta(days=DUESOON_WINDOW_DAYS):
return 'duesoon'
return 'current'
```
Storing a status column would let it go stale the moment a due date passed with no
write. Deriving it means the list badge, the detail badge, and the report are
always correct without a nightly job.
`to_dict()` overlays the derived status and the type name onto the row and emits
the two `Date` columns as `YYYY-MM-DD` (the framework's `BaseModel.to_dict` only
iso-formats `datetime`, not plain `date`, so the plugin does it explicitly for
predictable JSON).
**Register the tables in `PLUGIN_TABLE_OWNERS`.** The migration engine needs an
explicit map of which tables each plugin owns
(`shopdb/plugins/alembic_template.py`):
```python
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
...
'measuringtools': ('measuringtooltypes', 'measuringtools'),
...
}
```
`tests/test_plugin_migrations.py::test_table_owners_match_declared_models` fails if
this map drifts from the `__tablename__` declarations in either direction, so you
cannot forget it.
---
## 4. Per-plugin migrations: anchor vs real baseline
This is the part that differs most from the ten older bundled plugins, so read
[ADR-008](adr/ADR-008-plugin-migration-ownership.md) alongside this section.
Every table-owning plugin carries its own Alembic chain under
`plugins/<name>/migrations/`, with a per-plugin version table
`alembic_version_<name>` independent of the core `alembic_version`. The `env.py` is
a three-line delegate to the shared runner:
```python
import os
os.environ['PLUGIN_NAME'] = 'measuringtools'
from shopdb.plugins.alembic_template import run_migrations
run_migrations()
```
The key distinction is the `0001` revision.
**Cutover plugins (the older ten) ship a no-op anchor.** ADR-008 froze a cutover
at the core-chain head. The core Alembic chain had already created every table
that existed at that point, including those ten plugins' tables. So their `0001`
migration is a stamp-only no-op: `upgrade()` does `pass`, because the tables
already exist. It exists only to give the plugin chain a base revision that
`flask plugin upgrade-all` can stamp.
**`measuringtools` was built after the cutover, so its `0001` is a REAL baseline.**
The core chain never knew about `measuringtooltypes` / `measuringtools`, so this
per-plugin chain is their sole authoritative creator. `upgrade()` actually creates
the tables:
```python
revision = 'measuringtools0001baseline'
down_revision = None
def upgrade():
op.create_table('measuringtooltypes', ...)
op.create_table('measuringtools',
...,
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['measuringtooltypeid'],
['measuringtooltypes.measuringtooltypeid']),
...)
op.create_index('idx_measuringtool_type', 'measuringtools', ['measuringtooltypeid'])
```
Two things to know when you write a post-cutover baseline:
- **The shared `create_plugin_tables` helper cannot render a foreign key to a core
table.** It builds a per-plugin `MetaData` filtered to only the plugin's own
tables, so a foreign key to `assets` has no `assets` table to resolve against and
Alembic raises `NoReferencedTableError` at compile time. Because these tables
reference `assets.assetid`, the baseline uses explicit `op.create_table` ops (the
same shape Alembic autogenerate produces) instead of the helper. If your plugin's
tables have no FKs to core tables, `create_plugin_tables` works; if they do, hand
the DDL to `op.create_table`. (This limitation surfaced building this exemplar
and is a candidate framework fix: teach `_get_plugin_metadata` to pull in
FK-referenced core tables.)
- **Charset.** The baseline emits plain `CREATE TABLE`, so the tables inherit the
connection's default charset. On the documented utf8mb4 database that yields
utf8mb4 tables, matching how the core chain creates its tables. No explicit
`mysql_charset` is needed, and none of the unique columns exceed the InnoDB index
prefix limit (the longest is `name VARCHAR(100)` = 400 bytes < 767).
The deploy sequence is unchanged: `flask db upgrade` (core chain) then
`flask plugin upgrade-all` (stamps anchors and runs real baselines like this one).
Both are idempotent.
**Adapting the guard test.** `tests/test_plugin_migrations.py` has a test that
asserts each `0001` anchor is a pure no-op. That is true for the ten cutover
plugins but deliberately false for `measuringtools`. The fix is to scope the no-op
assertion to a frozen `CUTOVER_PLUGINS` list rather than to all discovered plugins,
and to expect `measuringtools`'s real baseline revision in the upgrade-all test:
```python
CUTOVER_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase',
'network', 'notifications', 'printers', 'slides', 'usb', 'warranty')
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS) # not all plugins
def test_anchor_migration_is_noop(plugin):
...
```
Freezing the list (rather than deriving it) is intentional: a newly discovered
plugin should not silently be treated as a cutover no-op.
---
## 5. Contract purity: import only from `shopdb.api`
A plugin may import from exactly two core places: `shopdb.plugins.base` (for
`BasePlugin` / `PluginMeta`) and `shopdb.api` (everything else). Importing internal
paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*` is a
contract violation. `measuringtools` gets its whole surface from `shopdb.api`:
```python
from shopdb.api import (
db, Asset, AssetType, AuditLog,
success_response, error_response, paginated_response, ErrorCodes,
get_pagination_params, paginate_query,
require_permission,
)
from shopdb.api import db, BaseModel # in the models module
```
`shopdb.api` is the versioned platform contract (ADR-001). Adding a name there is
an additive minor bump; removing one is a breaking major bump. The guard test
`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface` scans
every plugin's imports and fails the build if one reaches past the contract. Build
against `shopdb.api` and your plugin travels cleanly to a sister site running a
compatible core.
---
## 6. Blueprint: authz, responses, pagination
`plugins/measuringtools/api/routes.py` is a normal Flask blueprint. Three framework
conventions run through it.
**Reads are `jwt_required(optional=True)`; writes require a permission.** This is
the app-wide pattern: anonymous kiosks and unauthenticated internal users can read
the asset catalog, but any state change needs a logged-in user with the right
permission.
```python
@measuringtools_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_tools():
...
@measuringtools_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('measuringtools.create')
def create_tool():
...
```
The `measuringtools.*` permissions are seeded exactly the way warranty seeds its
own, by adding them to `Permission.PERMISSIONS` in `shopdb/core/models/user.py`:
```python
# Measuring tools
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
```
`flask seed permissions` is idempotent, so re-running it just adds the four new
rows. The `admin` role bypasses every permission check, so an admin can operate the
plugin before anyone grants the granular permissions.
**Responses use the framework helpers.** `success_response`, `error_response` (with
`ErrorCodes`), and `paginated_response` produce the standard envelope
(`{status, data, meta}`) so every plugin's API looks the same to the frontend. The
list endpoint pages with `get_pagination_params` + `paginate_query`.
**The write path merges asset core and extension in one payload**, mirroring
`equipment` and `computers`. Create builds the `Asset` first, flushes to get the
`assetid`, then builds the extension row and writes an audit log:
```python
asset = Asset(assetnumber=data['assetnumber'], ..., assettypeid=assettypeid, ...)
db.session.add(asset); db.session.flush()
tool = MeasuringTool(assetid=asset.assetid, measuringtooltypeid=..., ...)
db.session.add(tool); db.session.flush()
AuditLog.log('created', 'MeasuringTool', entityid=tool.measuringtoolid,
entityname=asset.assetnumber)
db.session.commit()
return success_response(_merged(tool), message='Measuring tool created', http_code=201)
```
`_merged()` returns `asset.to_dict()` with the extension nested under
`measuringtool`, so the GET, POST, and PUT responses all share one shape.
**The types resource has an in-use delete guard.** Deleting a type that tools still
reference is refused with a 409, so a color/name in use cannot vanish out from
under existing rows:
```python
inuse = MeasuringTool.query.filter_by(measuringtooltypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f'Cannot delete: {inuse} tool(s) still use this type', http_code=409)
```
**The calibration report** mirrors the warranty report shape: counts plus lists
bucketed by derived status.
```python
@measuringtools_bp.route('/report/calibration', methods=['GET'])
@jwt_required(optional=True)
def calibration_report():
buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []}
for tool in MeasuringTool.query.join(Asset).filter(Asset.isactive == True).all():
buckets[derive_status(tool.nextcalibrationdate)].append(_merged(tool))
return success_response({'counts': {k: len(v) for k, v in buckets.items()},
'buckets': buckets, 'statuscolors': STATUS_COLORS})
```
One deliberate note: the list endpoint's `calibrationstatus` filter is applied to
the built rows, not as a SQL `WHERE`, because the status is derived and not a
column. That is fine for a gage lab's worth of tools; it would need rethinking for
a fleet the size of the PC estate.
---
## 7. on_install seeding
`on_install` runs once, when a site runs `flask plugin install measuringtools`. It
seeds the plugin's own asset type and a set of starter tool types:
```python
def on_install(self, app: Flask) -> None:
with app.app_context():
self._ensure_asset_type() # AssetType 'measuring_tool'
self._ensure_starter_types() # Caliper, Micrometer, Thread Gage, ...
db.session.commit()
```
`_ensure_asset_type` registers `AssetType(assettype='measuring_tool',
pluginname='measuringtools', tablename='measuringtools', icon='ruler')`. The asset
type is what ties a core `Asset` to this plugin's extension table and drives the
custom-fields lookup (section 10). The starter types come with colors drawn from
the shared frontend palette so map markers and badges are legible out of the box.
Seeding is idempotent (each `_ensure_*` checks for existence first), so a reinstall
does not duplicate rows.
---
## 8. Hooks, one by one
`measuringtools` implements four optional hooks and consciously skips two.
**`get_navigation_items`** puts "Measuring Tools" in the sidebar. Navigation is
data-driven: the frontend renders whatever enabled plugins return, so no core file
lists the link.
```python
def get_navigation_items(self):
return [{'name': 'Measuring Tools', 'icon': 'ruler',
'route': '/measuringtools', 'position': 22}]
```
`position: 22` slots it into the Assets band (10-29) after Network and Printers.
The `icon` is a string key; the frontend maps `'ruler'` to a Lucide component
(section 9).
**`get_reports`** (contract 0.6.0) contributes the calibration card to the Reports
hub. Each card has a stable `id`, a `name`, a `description`, a `category`, and
exactly one of `route` (a dedicated page) or `endpoint` (inline render). We use a
route because the report is a full bucketed page:
```python
def get_reports(self):
return [{'id': 'calibration', 'name': 'Calibration Due',
'description': 'Measuring tools bucketed by calibration status',
'category': 'compliance', 'route': '/reports/calibration'}]
```
`GET /api/reports` merges this after the core reports and drops it when the plugin
is disabled.
**`get_models`** returns `[MeasuringTool, MeasuringToolType]` so migration tooling
and admin views know the plugin's tables.
**`get_config_schema` returns `[]`, on purpose.** The setup wizard reads this to
know what a plugin needs configured. Measuring tools are tracked by hand: there is
no external system to authenticate against, no endpoint URL, no API key. So the
schema is empty and the wizard shows nothing to configure. This is the intentional
"no external creds" case. Contrast the printers plugin, which declares a Zabbix URL
field. If your plugin talks to an external service, declare its (non-secret)
settings here and mark credentials `secret: True` so the wizard emits an `.env`
line instead of storing them in the database.
**Skipped: the collector hooks (`get_collector_schema` /
`apply_collector_payload`).** These (ADR-006) exist for plugins fed by an automated
agent pushing to `/api/collector/<name>`, like `computers` fed by the PXE pipeline.
Measuring tools are entered by hand in the gage lab; there is no collector. Because
`get_collector_schema` returns `None` (the base-class default), no collector
endpoint is registered and `apply_collector_payload` is never called. The contract
test `test_schema_declaring_plugins_implement_apply` only requires the upsert method
when a schema is declared, so skipping both is clean. If a site later automates
calibration imports (say from a cal-lab spreadsheet), that is the hook to add.
**Not skipped so much as not needed: dashboard widgets, services, CLI commands.**
`get_dashboard_widgets` could add a "calibration due" tile later; `get_services`
and `get_cli_commands` have no use here yet. Leaving them at their defaults keeps
the plugin small.
---
## 9. Frontend integration
There is no frontend plugin system yet (see
[ADR-009](adr/ADR-009-frontend-plugin-gating.md), "Future direction"). A plugin's
Vue routes and views ship in the core bundle. The plugin's job is to add them
correctly and gate them.
**Route module with `meta.plugin` gating (ADR-009).** A new file
`frontend/src/router/routes/measuringtools.js` is auto-discovered by the router's
`import.meta.glob('./routes/*.js')`. Every route carries `meta.plugin =
'measuringtools'`:
```js
export default [
{ path: 'measuringtools', name: 'measuringtools',
component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
meta: { plugin: 'measuringtools' } },
{ path: 'measuringtools/new', name: 'measuringtool-new',
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
meta: { requiresAuth: true, plugin: 'measuringtools' } },
{ path: 'measuringtools/:id', ..., meta: { plugin: 'measuringtools' } },
{ path: 'measuringtools/:id/edit', ..., meta: { requiresAuth: true, plugin: 'measuringtools' } },
{ path: 'reports/calibration', ..., meta: { plugin: 'measuringtools' } },
{ path: 'settings/measuringtooltypes', ...,
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' } },
]
```
`meta.plugin` makes the router guard redirect to the dashboard (instead of loading
a broken shell) when the backend plugin is disabled. It fetches
`GET /api/plugins/enabled` once, cached, and fails open. Note the auth pattern that
matches the rest of the app: list and detail are public (no `requiresAuth`); the
form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdmin`.
**API client, addition only.** `frontend/src/api/index.js` gets a
`measuringtoolsApi` object appended after `warrantyApi` (`list`, `get`, `create`,
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
reorganize the file; just add the block, mirroring `equipmentApi`.
**Views mirror the master templates.** The frontend has master templates the
frontend CLAUDE.md points to (`PrintersList.vue` for lists, `PrinterDetail.vue`
for detail pages). `measuringtools` mirrors the equivalent equipment views:
- `views/measuringtools/MeasuringToolsList.vue` - table with search, a type filter,
and a calibration-status filter; the status badge uses `utils/colorStyle` with
the color the API derived.
- `views/measuringtools/MeasuringToolDetail.vue` - hero + Identity card +
Calibration card (with the derived badge) + Location card, plus the shared
`CustomFieldsSection` and `WarrantyPanel` (section 10).
- `views/measuringtools/MeasuringToolForm.vue` - asset core fields + type +
location + the calibration fields, plus `CustomFieldsInputs`.
- `views/reports/CalibrationReport.vue` - the four buckets (overdue / due soon /
current / unknown), mirroring `WarrantyReport.vue`.
**Settings subtype page.** `views/settings/MeasuringToolTypesList.vue` mirrors
`PCTypesList.vue`: add / edit / delete with a `ColorSwatchPicker`. It is linked from
`settingsNav.js` with a "Measuring Tools" card group, so it appears in the settings
rail and landing overview.
**iconMap, entry only.** The sidebar maps backend icon-name strings to Lucide
components in `AppLayout.vue`. Add the import and one map entry:
```js
import { ..., Ruler } from 'lucide-vue-next'
const iconMap = { ..., 'ruler': Ruler }
```
That is the only change to `AppLayout.vue`. This is exactly the kind of core-file
edit ADR-009's future direction wants to replace with a registration API; for now
it is a one-line addition.
---
## 10. Custom fields and warranty panel composition
Two cross-cutting core features compose onto the plugin's pages for free.
**Custom fields.** Sites define extra attributes per asset type in Settings. The
detail page drops in `<CustomFieldsSection :assetid="tool.assetid" />` and the form
drops in `<CustomFieldsInputs :assettypeid="assettypeid" :assetid="currentAssetId" />`,
then calls `customFieldsRef.value.save(assetId)` after the tool saves. The one
subtlety: `CustomFieldsInputs` needs the asset-type id. Rather than hardcode it (the
equipment form hardcodes `EQUIPMENT_ASSETTYPEID = 1`), the measuringtools form
resolves it dynamically from `GET /api/assets/types`, finding the row whose
`assettype === 'measuring_tool'`. Dynamic lookup is preferred because seeded ids are
not stable across sites.
**Warranty panel.** `<WarrantyPanel :assetid="tool.assetid" :items="warranties" />`
renders any warranties covering the tool. Warranty is an asset-general plugin, so it
works on any asset type with no coupling; the measuring-tool detail page composes it
the same way the equipment and PC detail pages do.
**Map.** The asset map is data-driven off asset types and resolved positions
(ADR-001). Once a measuring tool is typed and given a location or map coordinates,
it appears on `/assets/map` with its type color, with no plugin-side map code. There
is nothing to force; giving the type a color and the asset a position is enough.
---
## 11. Tests
`tests/test_plugins/test_measuringtools.py` covers the plugin end to end:
- **Derived status**, as pure unit tests against `derive_status`: all four buckets
plus the 30-day boundary (exactly `+30` is still "due soon"; due today is "due
soon", not "overdue").
- **Type CRUD + the in-use delete guard** (409 when a tool references the type).
- **Tool create/update in one merged payload**, asserting asset core fields land at
the top level and extension fields nest under `measuringtool`, and that a past
`nextcalibrationdate` derives `overdue`.
- **The report shape** (counts and buckets agree; the four keys are present).
Because the plugin ships `default_enabled: false`, the shared session app does not
register its blueprint. The test module builds its own app, registers the blueprint,
runs `on_install` seeding, and restores the process-wide `plugin_manager` singleton
afterward, the same snapshot/restore pattern `test_plugin_migrations.py` uses. The
existing parametrized contract and migration tests pick the plugin up automatically
from the filesystem and must stay green.
Run the suite:
```bash
venv/bin/python -m pytest
bash scripts/check-naming-and-style.sh
cd frontend && npm run build
```
---
## 12. Install / enable lifecycle
The plugin is on disk but does nothing until a site installs it. Two commands, then
a restart:
```bash
flask plugin install measuringtools # runs the 0001 baseline, then on_install seeding
flask plugin enable measuringtools # default_enabled is false, so enable explicitly
```
`install` runs the migration (creating the tables), registers the plugin in
`instance/plugins.json`, and calls `on_install`. Because `default_enabled` is false,
it installs disabled; `enable` flips the registry flag. The registry entry looks
like:
```json
"measuringtools": { "name": "measuringtools", "version": "1.0.0",
"enabled": true, "migrations_applied": [], "config": {} }
```
**Restart is required.** Blueprints are registered at app startup; Flask forbids
registering one after the app has served a request. So after enabling, restart the
API so the loader registers `/api/measuringtools`. On a dev box:
```bash
# kill the running server on :5001, then:
setsid venv/bin/flask run --port 5001 --no-reload >/tmp/flask.log 2>&1 < /dev/null &
```
For a deploy, the sequence is `flask db upgrade` then `flask plugin upgrade-all`
(which runs this plugin's baseline on a fresh install and is a no-op on an existing
one), then a service restart. See [PLUGINS.md](PLUGINS.md) and
[ADR-008](adr/ADR-008-plugin-migration-ownership.md).
---
## End checklist
When you build a plugin, confirm all of this before you call it done:
- [ ] `manifest.json`: name, version, `core_version` pinned to the lowest contract
you need and capped below the next major, `api_prefix`, `default_enabled`.
- [ ] Models declare tables with the naming convention; identity stays on `Asset`.
- [ ] Derived state (if any) is computed at read time, never stored.
- [ ] Tables registered in `PLUGIN_TABLE_OWNERS`.
- [ ] Per-plugin migration chain: no-op anchor if your tables predate the cutover, a
real baseline if the plugin is post-cutover. Hand-write `op.create_table` if
you have FKs to core tables.
- [ ] Imports only from `shopdb.api` and `shopdb.plugins.base` (contract test green).
- [ ] Blueprint: jwt-optional reads, permission-gated writes; framework response and
pagination helpers; audit logs on writes.
- [ ] Permissions added to `Permission.PERMISSIONS`; `flask seed permissions` run.
- [ ] `on_install` seeds the asset type and any reference data, idempotently.
- [ ] Hooks: navigation, reports, models implemented; config schema and collector
implemented or consciously skipped with a reason.
- [ ] Frontend: route module with `meta.plugin` gating, API client addition, views
mirroring the master templates, settings subtype page + `settingsNav` entry,
one `iconMap` entry.
- [ ] Custom fields and warranty panel composed onto the detail/form pages.
- [ ] Tests: unit + API, self-contained app if `default_enabled` is false; full
suite and naming/style check green; `npm run build` green.
- [ ] Install / enable / restart verified live; a demo record renders on every page.

View File

@@ -0,0 +1,46 @@
/**
* Measuring-tools plugin routes.
*
* Every route carries meta.plugin = 'measuringtools' so the ADR-009 router
* guard redirects to the dashboard when the backend plugin is disabled. List
* and detail are public (no requiresAuth, matching the other asset types); the
* form requires auth; the settings subtype page requires admin.
*/
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',
name: 'measuringtool-detail',
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
meta: { plugin: 'measuringtools' }
},
{
path: 'measuringtools/:id/edit',
name: 'measuringtool-edit',
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
meta: { requiresAuth: true, plugin: 'measuringtools' }
},
{
path: 'reports/calibration',
name: 'calibration-report',
component: () => import('../../views/reports/CalibrationReport.vue'),
meta: { plugin: 'measuringtools' }
},
{
path: 'settings/measuringtooltypes',
name: 'measuringtooltypes',
component: () => import('../../views/settings/MeasuringToolTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' }
}
]

View File

@@ -0,0 +1,191 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Measuring Tool Details</h2>
<div class="header-actions">
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit
</router-link>
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="tool">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ tool.assetnumber }}</h1>
<span v-if="tool.name" class="hero-alias">{{ tool.name }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-primary">
{{ tool.assettypename || 'Measuring Tool' }}
</span>
<span class="badge badge-lg" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="tool.measuringtool?.measuringtooltypename">
<span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ tool.measuringtool.measuringtooltypename }}</span>
</div>
<div class="hero-detail" v-if="tool.measuringtool?.nextcalibrationdate">
<span class="hero-detail-label">Next Calibration</span>
<span class="hero-detail-value">{{ formatDate(tool.measuringtool.nextcalibrationdate) }}</span>
</div>
<div class="hero-detail" v-if="tool.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ tool.locationname }}</span>
</div>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
<div class="section-card">
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number (Gage Tag)</span>
<span class="info-value">{{ tool.assetnumber }}</span>
</div>
<div class="info-row" v-if="tool.name">
<span class="info-label">Name</span>
<span class="info-value">{{ tool.name }}</span>
</div>
<div class="info-row" v-if="tool.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ tool.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="tool.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ tool.serialnumber }}</span>
</div>
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ tool.measuringtool?.measuringtooltypename || '-' }}</span>
</div>
</div>
</div>
<!-- Calibration Section -->
<div class="section-card">
<h3 class="section-title">Calibration</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Last Calibration</span>
<span class="info-value">{{ tool.measuringtool?.lastcalibrationdate ? formatDate(tool.measuringtool.lastcalibrationdate) : '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Next Calibration</span>
<span class="info-value">{{ tool.measuringtool?.nextcalibrationdate ? formatDate(tool.measuringtool.nextcalibrationdate) : '-' }}</span>
</div>
<div class="info-row" v-if="tool.measuringtool?.calibrationintervaldays">
<span class="info-label">Interval</span>
<span class="info-value">{{ tool.measuringtool.calibrationintervaldays }} days</span>
</div>
<div class="info-row" v-if="tool.measuringtool?.calibrationprovider">
<span class="info-label">Provider</span>
<span class="info-value">{{ tool.measuringtool.calibrationprovider }}</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">{{ tool.locationname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ tool.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="tool.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
<!-- Notes -->
<div class="section-card" v-if="tool.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ tool.notes }}</p>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDateTime(tool.createddate) }}<template v-if="tool.createdby"> by {{ tool.createdby }}</template></span>
<span>Modified {{ formatDateTime(tool.modifieddate) }}<template v-if="tool.modifiedby"> by {{ tool.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Measuring tool not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '../../api'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
const route = useRoute()
const loading = ref(true)
const tool = ref(null)
const { warranties } = useWarrantyBadge(() => tool.value?.assetid)
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
function formatDate(d) { if (!d) return '-'; return new Date(d + 'T00:00:00').toLocaleDateString() }
function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleString() }
onMounted(async () => {
try {
const response = await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data
} catch (err) {
console.error('Error loading measuring tool:', err)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Measuring Tool' : 'New Measuring Tool' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveTool">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number (Gage Tag) *</label>
<input id="assetnumber" v-model="form.assetnumber" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="name">Name / Alias</label>
<input id="name" v-model="form.name" type="text" class="form-control" />
<small class="form-help">Layperson-friendly label</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number (vendor)</label>
<input id="serialnumber" v-model="form.serialnumber" type="text" class="form-control" />
</div>
<div class="form-group">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input id="gaugelabreference" v-model="form.gaugelabreference" type="text" class="form-control" />
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="measuringtooltypeid">Type</label>
<select id="measuringtooltypeid" v-model="form.measuringtooltypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
{{ t.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="statusid">Status</label>
<select id="statusid" v-model="form.statusid" class="form-control">
<option value="">Select status...</option>
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">{{ s.status }}</option>
</select>
</div>
</div>
<!-- Calibration Section -->
<h3 class="form-section-title">Calibration</h3>
<div class="form-row">
<div class="form-group">
<label for="lastcalibrationdate">Last Calibration</label>
<input id="lastcalibrationdate" v-model="form.lastcalibrationdate" type="date" class="form-control" />
</div>
<div class="form-group">
<label for="nextcalibrationdate">Next Calibration</label>
<input id="nextcalibrationdate" v-model="form.nextcalibrationdate" type="date" class="form-control" />
<small class="form-help">Drives the derived calibration status (overdue / due soon / current)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="calibrationintervaldays">Interval (days)</label>
<input id="calibrationintervaldays" v-model.number="form.calibrationintervaldays" type="number" min="0" class="form-control" />
</div>
<div class="form-group">
<label for="calibrationprovider">Calibration Provider</label>
<input id="calibrationprovider" v-model="form.calibrationprovider" type="text" class="form-control" />
<small class="form-help">Lab or in-house team that performs the calibration</small>
</div>
</div>
<!-- Location Section -->
<h3 class="form-section-title">Location & Organization</h3>
<div class="form-row">
<div class="form-group">
<label for="locationid">Location</label>
<select id="locationid" v-model="form.locationid" class="form-control">
<option value="">Select location...</option>
<option v-for="l in locations" :key="l.locationid" :value="l.locationid">{{ l.locationname }}</option>
</select>
<small class="form-help">Operation locations (e.g. "0615 Blisk Inspection") hold measuring tools</small>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
<option value="">Select business unit...</option>
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">{{ bu.businessunit }}</option>
</select>
</div>
</div>
<!-- Notes Section -->
<h3 class="form-section-title">Notes</h3>
<div class="form-group">
<textarea id="notes" v-model="form.notes" class="form-control" rows="3" placeholder="Additional notes..."></textarea>
</div>
<!-- Site-defined custom fields for measuring tools -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="assettypeid" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Measuring Tool' }}
</button>
<router-link to="/measuringtools" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { measuringtoolsApi, assetsApi, locationsApi, businessunitsApi } from '../../api'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { apiError } from '../../utils/apiError'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Resolved dynamically from /api/assets/types (no hardcoded id) so custom
// fields load for the measuring_tool asset type.
const assettypeid = ref(null)
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const form = ref({
assetnumber: '',
name: '',
gaugelabreference: '',
serialnumber: '',
statusid: '',
measuringtooltypeid: '',
lastcalibrationdate: '',
nextcalibrationdate: '',
calibrationintervaldays: null,
calibrationprovider: '',
locationid: '',
businessunitid: '',
notes: '',
})
const types = ref([])
const statuses = ref([])
const locations = ref([])
const businessunits = ref([])
onMounted(async () => {
try {
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
measuringtoolsApi.types.list({ perpage: 200 }),
assetsApi.statuses.list(),
locationsApi.list({ perpage: 500 }),
businessunitsApi.list({ perpage: 500 }),
assetsApi.types.list(),
])
types.value = typesResponse.data.data || []
statuses.value = statusResponse.data.data || []
locations.value = locResponse.data.data || []
businessunits.value = buResponse.data.data || []
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
assettypeid.value = assetType ? assetType.assettypeid : null
if (isEdit.value) {
const response = await measuringtoolsApi.get(route.params.id)
const data = response.data.data
currentAssetId.value = data.assetid || null
const ext = data.measuringtool || {}
form.value = {
assetnumber: data.assetnumber || '',
name: data.name || '',
gaugelabreference: data.gaugelabreference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || '',
measuringtooltypeid: ext.measuringtooltypeid || '',
lastcalibrationdate: ext.lastcalibrationdate || '',
nextcalibrationdate: ext.nextcalibrationdate || '',
calibrationintervaldays: ext.calibrationintervaldays ?? null,
calibrationprovider: ext.calibrationprovider || '',
locationid: data.locationid || '',
businessunitid: data.businessunitid || '',
notes: data.notes || '',
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveTool() {
error.value = ''
saving.value = true
try {
const data = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
gaugelabreference: form.value.gaugelabreference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || null,
measuringtooltypeid: form.value.measuringtooltypeid || null,
lastcalibrationdate: form.value.lastcalibrationdate || null,
nextcalibrationdate: form.value.nextcalibrationdate || null,
calibrationintervaldays: form.value.calibrationintervaldays ?? null,
calibrationprovider: form.value.calibrationprovider || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
notes: form.value.notes || null,
}
let saved
if (isEdit.value) {
const response = await measuringtoolsApi.update(route.params.id, data)
saved = response.data.data
} else {
const response = await measuringtoolsApi.create(data)
saved = response.data.data
}
const assetId = saved.assetid
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (customFieldsErr) {
console.error('Error saving custom fields:', customFieldsErr)
}
}
router.push(`/measuringtools/${saved.measuringtool?.measuringtoolid || route.params.id}`)
} catch (err) {
console.error('Error saving measuring tool:', err)
error.value = apiError(err, 'Failed to save measuring tool')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.form-section-title:first-of-type {
margin-top: 0;
}
.form-help {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div>
<div class="page-header">
<h2>Measuring Tools</h2>
<router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search measuring tools..."
@input="debouncedSearch"
/>
<select v-model="typeid" class="form-control" @change="reload">
<option value="">All types</option>
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
{{ t.name }}
</option>
</select>
<select v-model="calibrationstatus" class="form-control" @change="reload">
<option value="">All calibration statuses</option>
<option value="overdue">Overdue</option>
<option value="duesoon">Due soon</option>
<option value="current">Current</option>
<option value="unknown">Unknown</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Name</th>
<th>Serial Number</th>
<th>Type</th>
<th>Next Calibration</th>
<th>Calibration</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in tools" :key="item.assetid">
<td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.measuringtool?.measuringtooltypename || '-' }}</td>
<td>{{ item.measuringtool?.nextcalibrationdate ? formatDate(item.measuringtool.nextcalibrationdate) : '-' }}</td>
<td>
<span class="badge" :style="colorStyle(item.measuringtool?.calibrationstatuscolor)">
{{ statusLabel(item.measuringtool?.calibrationstatus) }}
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<router-link
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="tools.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No measuring tools found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const tools = ref([])
const types = ref([])
const loading = ref(true)
const search = ref('')
const typeid = ref('')
const calibrationstatus = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
onMounted(async () => {
try {
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
types.value = typesResponse.data.data || []
} catch (err) {
console.error('Error loading types:', err)
}
loadTools()
})
async function loadTools() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (typeid.value) params.typeid = typeid.value
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
const response = await measuringtoolsApi.list(params)
tools.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading measuring tools:', err)
} finally {
loading.value = false
}
}
function reload() {
page.value = 1
loadTools()
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(reload, 300)
}
function goToPage(p) {
page.value = p
loadTools()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadTools()
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<div class="page-header">
<h1>Calibration Due</h1>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="summary-row">
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
<span class="summary-label">{{ b.label }}</span>
</div>
</div>
<template v-for="b in bucketOrder" :key="b.key">
<div class="bucket card" v-if="(buckets[b.key] || []).length">
<h3 class="bucket-title">
<span class="dot" :style="{ background: b.color }"></span>
{{ b.label }} ({{ (buckets[b.key] || []).length }})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Name</th>
<th>Type</th>
<th>Next Calibration</th>
<th>Provider</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets[b.key]" :key="row.assetid">
<td>
<router-link :to="`/measuringtools/${row.measuringtool.measuringtoolid}`" class="asset-chip">
{{ row.assetnumber }}
</router-link>
</td>
<td>{{ row.name || '-' }}</td>
<td>{{ row.measuringtool.measuringtooltypename || '-' }}</td>
<td>{{ row.measuringtool.nextcalibrationdate ? formatDate(row.measuringtool.nextcalibrationdate) : '-' }}</td>
<td>{{ row.measuringtool.calibrationprovider || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { measuringtoolsApi } from '../../api'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const bucketOrder = [
{ key: 'overdue', label: 'Overdue', color: '#F44336' },
{ key: 'duesoon', label: 'Due Soon', color: '#FF9800' },
{ key: 'current', label: 'Current', color: '#4CAF50' },
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
]
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
onMounted(async () => {
try {
const response = await measuringtoolsApi.calibrationReport()
counts.value = response.data.data.counts || {}
buckets.value = response.data.data.buckets || {}
} catch (err) {
console.error('Error loading calibration report:', err)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.summary-card {
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
}
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.summary-label { font-size: 0.85rem; color: var(--text-light); }
.bucket { margin-bottom: 1.25rem; }
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.asset-chip {
display: inline-block; padding: 0.15rem 0.55rem;
background: var(--bg); border-radius: 12px; font-size: 0.85rem; text-decoration: none; color: var(--text);
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<div>
<div class="page-header">
<h2>Measuring Tool Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="mt in visibleTypes" :key="mt.measuringtooltypeid">
<td>{{ mt.name }}</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(mt.color)">{{ mt.color || 'auto' }}</span>
</td>
<td class="actions">
<span v-if="mt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(mt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(mt)">Delete</button>
</td>
</tr>
<tr v-if="visibleTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No measuring tool types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Type' : 'Add Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="name">Type *</label>
<input id="name" v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { measuringtoolsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const types = ref([])
const showInactive = ref(false)
const visibleTypes = computed(() => showInactive.value ? types.value : types.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', description: '', color: '', isactive: true })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await measuringtoolsApi.types.list({ perpage: 200, active: false })
types.value = response.data.data || []
} catch (err) {
console.error('Error loading measuring tool types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { name: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function deleteType(mt) {
if (!confirm(`Delete measuring tool type "${mt.name}"?`)) return
try {
await measuringtoolsApi.types.remove(mt.measuringtooltypeid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await measuringtoolsApi.types.update(editing.value.measuringtooltypeid, form.value)
} else {
await measuringtoolsApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,5 @@
"""Measuring-tools plugin package."""
from .plugin import MeasuringToolsPlugin
__all__ = ['MeasuringToolsPlugin']

View File

@@ -0,0 +1,5 @@
"""Measuring-tools plugin API blueprint."""
from .routes import measuringtools_bp
__all__ = ['measuringtools_bp']

View File

@@ -0,0 +1,362 @@
"""Measuring-tools plugin API.
Two resources: the measuringtooltypes lookup (CRUD with an in-use delete guard)
and the measuring tools themselves (an Asset core row plus a measuringtools
extension row, written in one payload). Reads are jwt-optional per the app
convention; writes require the measuringtools.* permissions.
Calibration status is derived at read time in the model (see
models.derive_status), never stored, so the report and list badges are always
current.
"""
from datetime import date, datetime
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
db, Asset, AssetType, AuditLog,
success_response, error_response, paginated_response, ErrorCodes,
get_pagination_params, paginate_query,
require_permission,
)
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
measuringtools_bp = Blueprint('measuringtools', __name__)
def _parse_date(value):
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
if not value:
return None
try:
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _merged(tool, today=None):
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
result = tool.asset.to_dict() if tool.asset else {}
result['measuringtool'] = tool.to_dict(today)
return result
# =============================================================================
# Measuring-tool types
# =============================================================================
@measuringtools_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_types():
"""List measuring-tool types."""
page, per_page = get_pagination_params(request)
query = MeasuringToolType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(MeasuringToolType.isactive == True)
if search := request.args.get('search'):
query = query.filter(MeasuringToolType.name.ilike(f'%{search}%'))
query = query.order_by(MeasuringToolType.name)
items, total = paginate_query(query, page, per_page)
return paginated_response([t.to_dict() for t in items], page, per_page, total)
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_type(type_id: int):
"""Get one measuring-tool type."""
tool_type = db.session.get(MeasuringToolType, type_id)
if not tool_type:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring-tool type {type_id} not found', http_code=404)
return success_response(tool_type.to_dict())
@measuringtools_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('measuringtools.create')
def create_type():
"""Create a measuring-tool type (reactivates a soft-deleted same-named one)."""
data = request.get_json() or {}
name = (data.get('name') or '').strip()
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
existing = MeasuringToolType.query.filter_by(name=name).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(ErrorCodes.CONFLICT,
f"Measuring-tool type '{name}' already exists", http_code=409)
tool_type = MeasuringToolType(
name=name,
description=data.get('description'),
color=data.get('color'),
)
db.session.add(tool_type)
db.session.commit()
return success_response(tool_type.to_dict(), message='Measuring-tool type created',
http_code=201)
@measuringtools_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('measuringtools.edit')
def update_type(type_id: int):
"""Update a measuring-tool type."""
tool_type = db.session.get(MeasuringToolType, type_id)
if not tool_type:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring-tool type {type_id} not found', http_code=404)
data = request.get_json() or {}
if 'name' in data and data['name'] != tool_type.name:
if MeasuringToolType.query.filter_by(name=data['name']).first():
return error_response(ErrorCodes.CONFLICT,
f"Measuring-tool type '{data['name']}' already exists",
http_code=409)
for key in ('name', 'description', 'color', 'isactive'):
if key in data:
setattr(tool_type, key, data[key])
db.session.commit()
return success_response(tool_type.to_dict(), message='Measuring-tool type updated')
@measuringtools_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('measuringtools.delete')
def delete_type(type_id: int):
"""Delete a measuring-tool type. Refused if any tool still uses it."""
tool_type = db.session.get(MeasuringToolType, type_id)
if not tool_type:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring-tool type {type_id} not found', http_code=404)
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)
db.session.delete(tool_type)
db.session.commit()
return success_response(message='Measuring-tool type deleted')
# =============================================================================
# Measuring tools
# =============================================================================
@measuringtools_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_tools():
"""List measuring tools with filters + pagination.
Query params: typeid, locationid, statusid, calibrationstatus, search,
active, page, perpage.
"""
page, per_page = get_pagination_params(request)
query = db.session.query(MeasuringTool).join(Asset)
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
if search := request.args.get('search'):
query = query.filter(db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%'),
))
if type_id := request.args.get('typeid', type=int):
query = query.filter(MeasuringTool.measuringtooltypeid == type_id)
if location_id := request.args.get('locationid', type=int):
query = query.filter(Asset.locationid == location_id)
if status_id := request.args.get('statusid', type=int):
query = query.filter(Asset.statusid == status_id)
query = query.order_by(Asset.assetnumber)
items, total = paginate_query(query, page, per_page)
today = date.today()
data = [_merged(tool, today) for tool in items]
# Calibration status is derived, so it cannot be a SQL filter - apply it
# to the built rows. Filtering post-pagination is acceptable here because
# the dataset is a gage lab's worth of tools, not a fleet.
calibrationstatus = request.args.get('calibrationstatus')
if calibrationstatus:
data = [d for d in data
if d['measuringtool']['calibrationstatus'] == calibrationstatus]
return paginated_response(data, page, per_page, total)
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
@jwt_required(optional=True)
def get_tool(tool_id: int):
"""Get one measuring tool (asset core merged with the extension)."""
tool = db.session.get(MeasuringTool, tool_id)
if not tool:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring tool {tool_id} not found', http_code=404)
return success_response(_merged(tool))
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_tool_by_asset(asset_id: int):
"""Get a measuring tool by its core asset id."""
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
if not tool:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring tool for asset {asset_id} not found', http_code=404)
return success_response(_merged(tool))
def _asset_type_id():
"""Resolve the seeded 'measuring_tool' asset-type id, or None."""
asset_type = AssetType.query.filter_by(assettype='measuring_tool').first()
return asset_type.assettypeid if asset_type else None
@measuringtools_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('measuringtools.create')
def create_tool():
"""Create a measuring tool: one Asset core row + one extension row."""
data = request.get_json() or {}
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409)
assettypeid = _asset_type_id()
if not assettypeid:
return error_response(ErrorCodes.INTERNAL_ERROR,
'measuring_tool asset type not found. Plugin may not be '
'properly installed.', http_code=500)
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
gaugelabreference=data.get('gaugelabreference'),
serialnumber=data.get('serialnumber'),
assettypeid=assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
mapy=data.get('mapy'),
notes=data.get('notes'),
)
db.session.add(asset)
db.session.flush() # assign assetid
tool = MeasuringTool(
assetid=asset.assetid,
measuringtooltypeid=data.get('measuringtooltypeid'),
calibrationintervaldays=data.get('calibrationintervaldays'),
lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')),
nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')),
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
notes=(data.get('notes') or '').strip() or None,
)
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)
# Asset core fields writable through this plugin's write path.
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference', 'serialnumber',
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy',
'notes', 'isactive')
# Extension fields with plain assignment (dates handled separately).
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
'calibrationprovider', 'notes')
@measuringtools_bp.route('/<int:tool_id>', methods=['PUT'])
@jwt_required()
@require_permission('measuringtools.edit')
def update_tool(tool_id: int):
"""Update a measuring tool (asset core fields + extension in one payload)."""
tool = db.session.get(MeasuringTool, tool_id)
if not tool:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring tool {tool_id} not found', http_code=404)
data = request.get_json() or {}
asset = tool.asset
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409)
changes = {}
for key in _ASSET_FIELDS:
if key in data:
if getattr(asset, key) != data[key]:
changes[key] = {'old': getattr(asset, key), 'new': data[key]}
setattr(asset, key, data[key])
for key in _TOOL_FIELDS:
if key in data:
if getattr(tool, key) != data[key]:
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
setattr(tool, key, data[key])
for key in ('lastcalibrationdate', 'nextcalibrationdate'):
if key in data:
setattr(tool, key, _parse_date(data[key]))
if changes:
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,
entityname=asset.assetnumber, changes=changes)
db.session.commit()
return success_response(_merged(tool), message='Measuring tool updated')
@measuringtools_bp.route('/<int:tool_id>', methods=['DELETE'])
@jwt_required()
@require_permission('measuringtools.delete')
def delete_tool(tool_id: int):
"""Soft delete a measuring tool (deactivates its asset)."""
tool = db.session.get(MeasuringTool, tool_id)
if not tool:
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring tool {tool_id} not found', http_code=404)
tool.asset.isactive = False
AuditLog.log('deleted', 'MeasuringTool', entityid=tool.measuringtoolid,
entityname=tool.asset.assetnumber)
db.session.commit()
return success_response(message='Measuring tool deleted')
# =============================================================================
# Calibration report (for the Reports hub)
# =============================================================================
@measuringtools_bp.route('/report/calibration', methods=['GET'])
@jwt_required(optional=True)
def calibration_report():
"""Counts + lists bucketed by derived calibration status."""
today = date.today()
buckets = {'overdue': [], 'duesoon': [], 'current': [], 'unknown': []}
query = db.session.query(MeasuringTool).join(Asset).filter(Asset.isactive == True)
for tool in query.all():
buckets.setdefault(derive_status(tool.nextcalibrationdate, today), []).append(
_merged(tool, today))
return success_response({
'counts': {key: len(value) for key, value in buckets.items()},
'buckets': buckets,
'statuscolors': STATUS_COLORS,
})

View File

@@ -0,0 +1,13 @@
{
"name": "measuringtools",
"version": "1.0.0",
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.6.0,<1.0.0",
"api_prefix": "/api/measuringtools",
"default_enabled": false,
"provides": {
"features": ["measuring-tools", "calibration-tracking"]
}
}

View File

@@ -0,0 +1,16 @@
"""Alembic environment for the measuring-tools plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
the metadata to this plugin's tables and drives Alembic against the per-plugin
version table alembic_version_measuringtools. See ADR-008 for the ownership
model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin
is NEW: its 0001 baseline really CREATES its tables, because the core chain
never built them.
"""
import os
os.environ['PLUGIN_NAME'] = 'measuringtools'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,69 @@
"""measuringtools plugin baseline (real create).
This plugin was built AFTER the ADR-008 ownership cutover, so unlike the ten
bundled plugins whose 0001 is a stamp-only no-op anchor, this baseline actually
CREATES the plugin's tables. The core Alembic chain never knew about
measuringtooltypes / measuringtools, so this per-plugin chain is their sole
authoritative creator. It runs from `flask plugin install measuringtools`
(and `flask plugin upgrade-all`) after `flask db upgrade` builds the core schema.
Note on create_plugin_tables: the shared helper in
shopdb.plugins.alembic_template builds a per-plugin MetaData filtered to only the
plugin's own tables, which means a foreign key to a core table (assets) cannot
be resolved at CreateTable-compile time (NoReferencedTableError). Since these
tables reference assets.assetid, the baseline emits explicit Alembic ops (the
same shape Alembic autogenerate produces) instead. Tables inherit the
connection's default charset, so on a utf8mb4 database they are utf8mb4, matching
how the core chain creates its tables.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'measuringtools0001baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'measuringtooltypes',
sa.Column('measuringtooltypeid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('measuringtooltypeid'),
sa.UniqueConstraint('name'),
)
op.create_table(
'measuringtools',
sa.Column('measuringtoolid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('measuringtooltypeid', sa.Integer(), nullable=True),
sa.Column('calibrationintervaldays', sa.Integer(), nullable=True),
sa.Column('lastcalibrationdate', sa.Date(), nullable=True),
sa.Column('nextcalibrationdate', sa.Date(), nullable=True),
sa.Column('calibrationprovider', sa.String(length=150), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['measuringtooltypeid'],
['measuringtooltypes.measuringtooltypeid']),
sa.PrimaryKeyConstraint('measuringtoolid'),
sa.UniqueConstraint('assetid'),
)
op.create_index('idx_measuringtool_type', 'measuringtools',
['measuringtooltypeid'])
def downgrade():
op.drop_index('idx_measuringtool_type', table_name='measuringtools')
op.drop_table('measuringtools')
op.drop_table('measuringtooltypes')

View File

@@ -0,0 +1,17 @@
"""Measuring-tools plugin models."""
from .measuringtool import (
MeasuringTool,
MeasuringToolType,
derive_status,
STATUS_COLORS,
DUESOON_WINDOW_DAYS,
)
__all__ = [
'MeasuringTool',
'MeasuringToolType',
'derive_status',
'STATUS_COLORS',
'DUESOON_WINDOW_DAYS',
]

View File

@@ -0,0 +1,133 @@
"""Measuring-tools models.
Measuring tools are gage-lab instruments (calipers, micrometers, thread gages,
bore gages, height gages, Genspect heads, ...) that measure parts, as opposed
to equipment that makes parts (see ADR-005). Each tool is a core Asset plus a
one-to-one measuringtools extension row.
The lifecycle a measuring tool cares about is CALIBRATION, not maintenance:
an interval, a last date, and a next date. Calibration STATUS is DERIVED from
nextcalibrationdate at read time (see derive_status), never stored, so it is
always current no matter how long since the last write. This mirrors the
warranty plugin's derive-at-read pattern.
"""
from datetime import date, timedelta
from shopdb.api import db, BaseModel
# Window before nextcalibrationdate where a tool counts as "due soon".
DUESOON_WINDOW_DAYS = 30
# Derived status -> display color (hex). Reused by the frontend status badge.
STATUS_COLORS = {
'overdue': '#F44336',
'duesoon': '#FF9800',
'current': '#4CAF50',
'unknown': '#9E9E9E',
}
def derive_status(nextcalibrationdate, today=None):
"""Calibration status from a next-calibration date. Never stored.
overdue - the next date is in the past
duesoon - the next date is within DUESOON_WINDOW_DAYS from today
current - the next date is further out than the window
unknown - no next date recorded
"""
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'
class MeasuringToolType(BaseModel):
"""Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...).
Site-managed lookup with a display color for badges and map markers,
the same shape as the equipment/computer/printer type tables.
"""
__tablename__ = 'measuringtooltypes'
measuringtooltypeid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<MeasuringToolType {self.name}>"
class MeasuringTool(BaseModel):
"""Measuring-tool extension data, one-to-one with a core Asset.
Identity lives on the Asset (assetnumber = gage tag, serialnumber = vendor
serial, gaugelabreference identifier). This row carries only the metrology
domain fields: the tool type and the calibration lifecycle.
"""
__tablename__ = 'measuringtools'
measuringtoolid = db.Column(db.Integer, primary_key=True)
# Link to the core asset (one extension row per asset).
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,
)
# Calibration lifecycle. Status is DERIVED from nextcalibrationdate, so
# none of these columns store a status.
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)
asset = db.relationship(
'Asset',
backref=db.backref('measuringtool', uselist=False, lazy='joined'),
)
measuringtooltype = db.relationship('MeasuringToolType', backref='tools')
__table_args__ = (
db.Index('idx_measuringtool_type', 'measuringtooltypeid'),
)
def __repr__(self):
return f"<MeasuringTool {self.assetid}>"
def calibrationstatus(self, today=None):
return derive_status(self.nextcalibrationdate, today)
def to_dict(self, today=None):
"""Extension dict with the type name and derived calibration status."""
result = super().to_dict()
# BaseModel.to_dict only isoformats datetime, not plain date columns.
# Emit calibration dates as 'YYYY-MM-DD' so the frontend parses them
# the same way it parses warranty dates.
for field in ('lastcalibrationdate', 'nextcalibrationdate'):
value = getattr(self, field)
result[field] = value.isoformat() if value else None
if self.measuringtooltype:
result['measuringtooltypename'] = self.measuringtooltype.name
result['measuringtooltypecolor'] = self.measuringtooltype.color
status = self.calibrationstatus(today)
result['calibrationstatus'] = status
result['calibrationstatuscolor'] = STATUS_COLORS.get(
status, STATUS_COLORS['unknown'])
return result

View File

@@ -0,0 +1,127 @@
"""Measuring-tools plugin main class.
The first plugin built on the matured framework scaffold (ADR-005). It owns two
tables (measuringtooltypes, measuringtools), a blueprint under /api/measuringtools,
a sidebar entry, and a calibration report card. It seeds its own asset type and
a set of starter tool types on install.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .api import measuringtools_bp
from .models import MeasuringTool, MeasuringToolType
logger = logging.getLogger(__name__)
# Starter tool types seeded on install: (name, description, color).
# Colors are drawn from the shared frontend PALETTE (utils/colorStyle.js).
STARTER_TYPES = [
('Caliper', 'Vernier / digital caliper', '#14abef'),
('Micrometer', 'Outside / inside / depth micrometer', '#2dce89'),
('Thread Gage', 'Go / no-go thread gage', '#fb6340'),
('Bore Gage', 'Bore / hole diameter gage', '#7934f3'),
('Height Gage', 'Height / vertical measuring gage', '#ffc107'),
('Indicator', 'Dial / test indicator', '#11cdef'),
('Gage Block Set', 'Reference gage block set', '#e83e8c'),
('Other', 'Other measuring tool', '#6c757d'),
]
class MeasuringToolsPlugin(BasePlugin):
"""Metrology and inspection instruments (calibration lifecycle)."""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'measuringtools'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description', 'Metrology and inspection instruments'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.6.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/measuringtools'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return measuringtools_bp
def get_models(self) -> List[Type]:
return [MeasuringTool, MeasuringToolType]
def get_navigation_items(self) -> List[Dict]:
return [
{
'name': 'Measuring Tools',
'icon': 'ruler',
'route': '/measuringtools',
'position': 22,
},
]
def get_reports(self) -> List[Dict]:
return [
{
'id': 'calibration',
'name': 'Calibration Due',
'description': 'Measuring tools bucketed by calibration status',
'category': 'compliance',
'route': '/reports/calibration',
},
]
def get_config_schema(self) -> List[Dict]:
# No external credentials or endpoints: calibration is tracked by hand,
# so the setup wizard shows nothing to configure. Documented in the
# plugin guide as the intentional empty-schema case.
return []
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
with app.app_context():
self._ensure_asset_type()
self._ensure_starter_types()
db.session.commit()
logger.info("Measuring-tools plugin installed")
def _ensure_asset_type(self) -> None:
existing = AssetType.query.filter_by(assettype='measuring_tool').first()
if not existing:
db.session.add(AssetType(
assettype='measuring_tool',
pluginname='measuringtools',
tablename='measuringtools',
description='Metrology and inspection instruments (gauges, '
'calipers, thread gages, bore gages, ...)',
icon='ruler',
))
logger.debug("Created asset type: measuring_tool")
db.session.commit()
def _ensure_starter_types(self) -> None:
for name, description, color in STARTER_TYPES:
if not MeasuringToolType.query.filter_by(name=name).first():
db.session.add(MeasuringToolType(
name=name, description=description, color=color))
logger.debug(f"Created measuring-tool type: {name}")
db.session.commit()

View File

@@ -0,0 +1,254 @@
"""Tests for the measuringtools plugin.
The plugin ships default-disabled, so the shared session app does not register
its blueprint. This module builds its own app, registers the blueprint, runs the
plugin's on_install seeding, and restores the process-wide plugin_manager
singleton afterwards (create_app repoints it) so other test modules are
unaffected - the same snapshot/restore dance used by test_plugin_migrations.
Coverage: derived calibration status (all four buckets + the 30-day boundary),
type CRUD with the in-use delete guard, tool create/update in one merged
payload, and the calibration report shape.
"""
from datetime import date, timedelta
import pytest
from werkzeug.security import generate_password_hash
from shopdb import create_app
from shopdb.extensions import db as _db
from shopdb.plugins import plugin_manager
from plugins.measuringtools.models import derive_status, DUESOON_WINDOW_DAYS
# =============================================================================
# Pure unit tests: derived status (no app needed)
# =============================================================================
def test_derive_status_unknown_when_no_date():
assert derive_status(None) == 'unknown'
def test_derive_status_overdue_in_past():
assert derive_status(date.today() - timedelta(days=1)) == 'overdue'
def test_derive_status_current_far_future():
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS + 1)) == 'current'
def test_derive_status_duesoon_within_window():
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS - 1)) == 'duesoon'
def test_derive_status_duesoon_on_boundary():
"""Exactly DUESOON_WINDOW_DAYS out is still 'due soon' (inclusive)."""
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
def test_derive_status_duesoon_today():
"""Due today is not yet overdue - it falls in the due-soon window."""
assert derive_status(date.today()) == 'duesoon'
# =============================================================================
# API tests (self-contained app with the blueprint registered)
# =============================================================================
@pytest.fixture(scope='module')
def mt_app():
"""A testing app with the measuringtools blueprint registered + seeded."""
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
application = create_app('testing')
from plugins.measuringtools.plugin import MeasuringToolsPlugin
plugin = MeasuringToolsPlugin()
pm = application.extensions['plugin_manager']
if plugin.meta.api_prefix not in pm._registered_prefixes:
pm._register_plugin_components(plugin)
with application.app_context():
_db.create_all()
_seed(application, plugin)
yield application
_db.session.remove()
_db.drop_all()
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
def _seed(application, plugin):
"""Seed permissions, an admin, a status, and the plugin's own reference data."""
from shopdb.core.models import Permission, User, Role, AssetStatus
Permission.seed()
role = Role(rolename='admin', description='Administrator')
_db.session.add(role)
_db.session.add(AssetStatus(status='In Use', description='In use'))
_db.session.flush()
user = User(username='testadmin', email='admin@test.local',
passwordhash=generate_password_hash('testpass'))
user.roles.append(role)
_db.session.add(user)
_db.session.commit()
# Seeds AssetType 'measuring_tool' + the starter tool types.
plugin.on_install(application)
@pytest.fixture
def client(mt_app):
return mt_app.test_client()
@pytest.fixture
def auth_headers(client):
response = client.post('/api/auth/login',
json={'username': 'testadmin', 'password': 'testpass'})
assert response.status_code == 200, response.get_json()
token = response.get_json()['data']['access_token']
return {'Authorization': f'Bearer {token}'}
def _status_id(client):
return 1 # only status seeded
# -- Type CRUD + in-use guard -------------------------------------------------
def test_starter_types_seeded(client):
response = client.get('/api/measuringtools/types')
assert response.status_code == 200
names = {t['name'] for t in response.get_json()['data']}
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
def test_type_create_requires_auth(client):
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
assert response.status_code == 401
def test_type_crud_lifecycle(client, auth_headers):
created = client.post('/api/measuringtools/types', headers=auth_headers,
json={'name': 'Pin Gage', 'description': 'Pin gage set',
'color': '#123456'})
assert created.status_code == 201, created.get_json()
type_id = created.get_json()['data']['measuringtooltypeid']
updated = client.put(f'/api/measuringtools/types/{type_id}', headers=auth_headers,
json={'description': 'Updated'})
assert updated.status_code == 200
assert updated.get_json()['data']['description'] == 'Updated'
dup = client.post('/api/measuringtools/types', headers=auth_headers,
json={'name': 'Pin Gage'})
assert dup.status_code == 409
deleted = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
assert deleted.status_code == 200
def test_type_delete_blocked_when_in_use(client, auth_headers):
type_created = client.post('/api/measuringtools/types', headers=auth_headers,
json={'name': 'Depth Gage'})
type_id = type_created.get_json()['data']['measuringtooltypeid']
tool_created = client.post('/api/measuringtools', headers=auth_headers,
json={'assetnumber': 'MT-INUSE-1',
'measuringtooltypeid': type_id,
'statusid': _status_id(client)})
assert tool_created.status_code == 201, tool_created.get_json()
blocked = client.delete(f'/api/measuringtools/types/{type_id}', headers=auth_headers)
assert blocked.status_code == 409
assert 'still use this type' in blocked.get_json()['data']['error']['message']
# -- Tool create/update merged payload ----------------------------------------
def test_tool_create_and_get_merged(client, auth_headers):
caliper = client.get('/api/measuringtools/types').get_json()['data']
caliper_id = next(t['measuringtooltypeid'] for t in caliper if t['name'] == 'Caliper')
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-001',
'name': 'Bench caliper',
'serialnumber': 'SN-CAL-9',
'gaugelabreference': 'GL-42',
'measuringtooltypeid': caliper_id,
'statusid': _status_id(client),
'calibrationintervaldays': 365,
'lastcalibrationdate': '2026-01-01',
'nextcalibrationdate': '2027-01-01',
'calibrationprovider': 'Metro Cal Lab',
})
assert created.status_code == 201, created.get_json()
payload = created.get_json()['data']
# Asset core fields at the top level, extension nested.
assert payload['assetnumber'] == 'MT-001'
assert payload['serialnumber'] == 'SN-CAL-9'
assert payload['gaugelabreference'] == 'GL-42'
ext = payload['measuringtool']
assert ext['measuringtooltypename'] == 'Caliper'
assert ext['calibrationprovider'] == 'Metro Cal Lab'
assert ext['calibrationstatus'] == 'current'
tool_id = ext['measuringtoolid']
fetched = client.get(f'/api/measuringtools/{tool_id}')
assert fetched.status_code == 200
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
def test_tool_update_merged_payload(client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-UPD-1', 'statusid': _status_id(client),
'nextcalibrationdate': '2030-01-01',
})
tool_id = created.get_json()['data']['measuringtool']['measuringtoolid']
updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, json={
'name': 'Renamed tool', # asset core field
'calibrationprovider': 'In-house', # extension field
'nextcalibrationdate': str(date.today() - timedelta(days=5)), # -> overdue
})
assert updated.status_code == 200, updated.get_json()
data = updated.get_json()['data']
assert data['name'] == 'Renamed tool'
assert data['measuringtool']['calibrationprovider'] == 'In-house'
assert data['measuringtool']['calibrationstatus'] == 'overdue'
def test_tool_create_duplicate_assetnumber_conflicts(client, auth_headers):
body = {'assetnumber': 'MT-DUP', 'statusid': _status_id(client)}
first = client.post('/api/measuringtools', headers=auth_headers, json=body)
assert first.status_code == 201
second = client.post('/api/measuringtools', headers=auth_headers, json=body)
assert second.status_code == 409
def test_list_filter_by_calibrationstatus(client, auth_headers):
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
})
response = client.get('/api/measuringtools?calibrationstatus=overdue')
assert response.status_code == 200
rows = response.get_json()['data']
assert rows, 'expected at least one overdue tool'
assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows)
# -- Report shape -------------------------------------------------------------
def test_calibration_report_shape(client, auth_headers):
response = client.get('/api/measuringtools/report/calibration')
assert response.status_code == 200
data = response.get_json()['data']
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
assert set(data['buckets']) == {'overdue', 'duesoon', 'current', 'unknown'}
# Buckets and counts agree.
for key, rows in data['buckets'].items():
assert data['counts'][key] == len(rows)
assert 'statuscolors' in data