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