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 <noreply@anthropic.com>
This commit is contained in:
679
docs/PLUGIN-GUIDE.md
Normal file
679
docs/PLUGIN-GUIDE.md
Normal 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.
|
||||
46
frontend/src/router/routes/measuringtools.js
Normal file
46
frontend/src/router/routes/measuringtools.js
Normal 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' }
|
||||
}
|
||||
]
|
||||
191
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
191
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal 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>
|
||||
281
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
281
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal 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>
|
||||
169
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
169
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal 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>
|
||||
101
frontend/src/views/reports/CalibrationReport.vue
Normal file
101
frontend/src/views/reports/CalibrationReport.vue
Normal 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>
|
||||
157
frontend/src/views/settings/MeasuringToolTypesList.vue
Normal file
157
frontend/src/views/settings/MeasuringToolTypesList.vue
Normal 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>
|
||||
5
plugins/measuringtools/__init__.py
Normal file
5
plugins/measuringtools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Measuring-tools plugin package."""
|
||||
|
||||
from .plugin import MeasuringToolsPlugin
|
||||
|
||||
__all__ = ['MeasuringToolsPlugin']
|
||||
5
plugins/measuringtools/api/__init__.py
Normal file
5
plugins/measuringtools/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Measuring-tools plugin API blueprint."""
|
||||
|
||||
from .routes import measuringtools_bp
|
||||
|
||||
__all__ = ['measuringtools_bp']
|
||||
362
plugins/measuringtools/api/routes.py
Normal file
362
plugins/measuringtools/api/routes.py
Normal 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,
|
||||
})
|
||||
13
plugins/measuringtools/manifest.json
Normal file
13
plugins/measuringtools/manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
16
plugins/measuringtools/migrations/env.py
Normal file
16
plugins/measuringtools/migrations/env.py
Normal 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()
|
||||
24
plugins/measuringtools/migrations/script.py.mako
Normal file
24
plugins/measuringtools/migrations/script.py.mako
Normal 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"}
|
||||
@@ -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')
|
||||
17
plugins/measuringtools/models/__init__.py
Normal file
17
plugins/measuringtools/models/__init__.py
Normal 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',
|
||||
]
|
||||
133
plugins/measuringtools/models/measuringtool.py
Normal file
133
plugins/measuringtools/models/measuringtool.py
Normal 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
|
||||
127
plugins/measuringtools/plugin.py
Normal file
127
plugins/measuringtools/plugin.py
Normal 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()
|
||||
254
tests/test_plugins/test_measuringtools.py
Normal file
254
tests/test_plugins/test_measuringtools.py
Normal 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
|
||||
Reference in New Issue
Block a user