diff --git a/CHANGELOG.md b/CHANGELOG.md index b4311b8..887e11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,14 @@ ADR-007 and ADR-002. ### Fixed +- Measuring tools are now wired into the asset-identifier matrix and global + search. The Settings identifier matrix gains a Measuring Tool column and the + gauge-lab and maintenance reference inputs/rows on the measuring-tool form + and detail pages honor those per-type toggles (a maintenance-reference field + was added, matching the other asset types). Global search gains a Measuring + Tools domain toggle and filter chip, routes measuring-tool hits to + `/measuringtools/` (previously the generic `/assets/` fallback), and + matches on `gaugelabreference` so a gage-tag lookup resolves the tool. - Site & Facility settings page renders booleans as toggles and the directory-mode settings as dropdowns, with labels and help text for every field (no more raw keys or type-true/false boxes). diff --git a/frontend/src/composables/identifierSettings.js b/frontend/src/composables/identifierSettings.js index d50b203..b386446 100644 --- a/frontend/src/composables/identifierSettings.js +++ b/frontend/src/composables/identifierSettings.js @@ -14,7 +14,7 @@ const state = reactive({ let inflight = null -const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device))?_enabled$/ +const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/ function applySetting(key, value) { const match = KEY_RE.exec(key) diff --git a/frontend/src/composables/systemSettings.js b/frontend/src/composables/systemSettings.js index 7c21e94..073b072 100644 --- a/frontend/src/composables/systemSettings.js +++ b/frontend/src/composables/systemSettings.js @@ -36,7 +36,8 @@ export const assetTypeCols = [ { key: 'machine', label: 'Machine' }, { key: 'computer', label: 'PC' }, { key: 'printer', label: 'Printer' }, - { key: 'network_device', label: 'Network' } + { key: 'network_device', label: 'Network' }, + { key: 'measuring_tool', label: 'Measuring Tool' } ] // Global-search domain toggles: keys follow search__enabled. @@ -49,6 +50,7 @@ export const searchDomains = [ { key: 'computer', label: 'PCs' }, { key: 'printer', label: 'Printers' }, { key: 'network_device', label: 'Network Devices' }, + { key: 'measuring_tool', label: 'Measuring Tools' }, { key: 'notification', label: 'Notifications' }, { key: 'subnet', label: 'Subnets' } ] @@ -153,7 +155,7 @@ export function useSystemSettings() { for (const setting of data.data) { if (setting.key in settings) { settings[setting.key] = setting.value - } else if (/^identifier_.+_(machine|computer|printer|network_device)_enabled$/.test(setting.key)) { + } else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) { identifierMatrix[setting.key] = setting.value !== false } else if (/^search_.+_enabled$/.test(setting.key)) { searchMatrix[setting.key] = setting.value !== false diff --git a/frontend/src/utils/assetTypes.js b/frontend/src/utils/assetTypes.js index f110a54..79b62d4 100644 --- a/frontend/src/utils/assetTypes.js +++ b/frontend/src/utils/assetTypes.js @@ -9,6 +9,7 @@ const ASSET_TYPE_LABELS = { 'printer': 'Printers', 'network_device': 'Network Devices', 'network device': 'Network Devices', + 'measuring_tool': 'Measuring Tools', } const ASSET_TYPE_ROUTES = { @@ -17,6 +18,7 @@ const ASSET_TYPE_ROUTES = { 'printer': '/printers', 'network_device': '/network', 'network device': '/network', + 'measuring_tool': '/measuringtools', } // Plugin-specific id field inside asset.typedata for each asset type. @@ -26,6 +28,7 @@ const ASSET_TYPE_ID_KEYS = { 'printer': 'printerid', 'network_device': 'networkdeviceid', 'network device': 'networkdeviceid', + 'measuring_tool': 'measuringtoolid', } function titleCase(text) { diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue index 67f198b..532965a 100644 --- a/frontend/src/views/SearchResults.vue +++ b/frontend/src/views/SearchResults.vue @@ -116,6 +116,7 @@ const typeLabels = { knowledgebase: 'KB', printer: 'Printer', network_device: 'Network', + measuring_tool: 'Measuring Tool', employee: 'Employee', notification: 'Notice', subnet: 'Subnet' @@ -127,6 +128,7 @@ const filterTypeMap = { computers: ['computer'], printers: ['printer'], network: ['network_device', 'subnet'], + measuringtools: ['measuring_tool'], applications: ['application'], knowledgebase: ['knowledgebase'], notifications: ['notification'], @@ -139,6 +141,7 @@ const filterList = [ { key: 'computers', label: 'PCs' }, { key: 'printers', label: 'Printers' }, { key: 'network', label: 'Network' }, + { key: 'measuringtools', label: 'Measuring Tools' }, { key: 'applications', label: 'Apps' }, { key: 'knowledgebase', label: 'KB' }, { key: 'notifications', label: 'Notices' }, @@ -413,6 +416,11 @@ watch(results, () => { color: #f57f17; } +.result-type.measuring_tool { + background: #e0f7fa; + color: #00838f; +} + .result-type.employee { background: #e0f2f1; color: #00695c; @@ -513,6 +521,11 @@ watch(results, () => { color: #ffd54f; } + .result-type.measuring_tool { + background: rgba(0, 131, 143, 0.2); + color: #80deea; + } + .result-type.employee { background: rgba(0, 105, 92, 0.2); color: #80cbc4; diff --git a/frontend/src/views/measuringtools/MeasuringToolDetail.vue b/frontend/src/views/measuringtools/MeasuringToolDetail.vue index 43d49c0..67a4cb5 100644 --- a/frontend/src/views/measuringtools/MeasuringToolDetail.vue +++ b/frontend/src/views/measuringtools/MeasuringToolDetail.vue @@ -64,10 +64,14 @@ Name {{ tool.name }} -
+
Gauge Lab Reference {{ tool.gaugelabreference }}
+
+ Maintenance Reference + {{ tool.maintenancereference }} +
Serial Number {{ tool.serialnumber }} @@ -167,9 +171,12 @@ import CustomFieldsSection from '../../components/CustomFieldsSection.vue' import WarrantyPanel from '../../components/WarrantyPanel.vue' import AssetRelationships from '../../components/AssetRelationships.vue' import { useWarrantyBadge } from '../../composables/warrantyBadge' +import { useIdentifierFlags } from '../../composables/identifierSettings' const route = useRoute() +const { isEnabled } = useIdentifierFlags() + const loading = ref(true) const tool = ref(null) const { warranties } = useWarrantyBadge(() => tool.value?.assetid) diff --git a/frontend/src/views/measuringtools/MeasuringToolForm.vue b/frontend/src/views/measuringtools/MeasuringToolForm.vue index 83b958d..5af746b 100644 --- a/frontend/src/views/measuringtools/MeasuringToolForm.vue +++ b/frontend/src/views/measuringtools/MeasuringToolForm.vue @@ -27,13 +27,21 @@
-
+
Authoritative gauge lab asset reference (if tracked)
+
+
+ + + Maintenance system asset reference (if tracked) +
+
+
@@ -126,11 +134,14 @@ 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 { useIdentifierFlags } from '../../composables/identifierSettings' import { apiError } from '../../utils/apiError' const route = useRoute() const router = useRouter() +const { isEnabled } = useIdentifierFlags() + const isEdit = computed(() => !!route.params.id) // Resolved dynamically from /api/assets/types (no hardcoded id) so custom @@ -147,6 +158,7 @@ const form = ref({ assetnumber: '', name: '', gaugelabreference: '', + maintenancereference: '', serialnumber: '', statusid: '', measuringtooltypeid: '', @@ -190,6 +202,7 @@ onMounted(async () => { assetnumber: data.assetnumber || '', name: data.name || '', gaugelabreference: data.gaugelabreference || '', + maintenancereference: data.maintenancereference || '', serialnumber: data.serialnumber || '', statusid: data.statusid || '', measuringtooltypeid: ext.measuringtooltypeid || '', @@ -218,6 +231,7 @@ async function saveTool() { assetnumber: form.value.assetnumber, name: form.value.name || null, gaugelabreference: form.value.gaugelabreference || null, + maintenancereference: form.value.maintenancereference || null, serialnumber: form.value.serialnumber || null, statusid: form.value.statusid || null, measuringtooltypeid: form.value.measuringtooltypeid || null, diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py index cf2e60d..2d3e9a2 100644 --- a/plugins/measuringtools/api/routes.py +++ b/plugins/measuringtools/api/routes.py @@ -249,6 +249,7 @@ def create_tool(): assetnumber=data['assetnumber'], name=data.get('name'), gaugelabreference=data.get('gaugelabreference'), + maintenancereference=data.get('maintenancereference'), serialnumber=data.get('serialnumber'), assettypeid=assettypeid, statusid=data.get('statusid', 1), @@ -281,7 +282,8 @@ def create_tool(): # Asset core fields writable through this plugin's write path. -_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference', 'serialnumber', +_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference', + 'maintenancereference', 'serialnumber', 'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive') # Extension fields with plain assignment (dates handled separately). diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index 9fc8439..f504948 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -118,12 +118,15 @@ def _get_asset_result(asset, query, relevance=None): plugin_id = asset.network_device.networkdeviceid elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer: plugin_id = asset.printer.printerid + elif asset_type_name == 'measuring_tool' and hasattr(asset, 'measuringtool') and asset.measuringtool: + plugin_id = asset.measuringtool.measuringtoolid url_map = { 'machine': f"/machines/{plugin_id}", 'computer': f"/pcs/{plugin_id}", 'network_device': f"/network/{plugin_id}", 'printer': f"/printers/{plugin_id}", + 'measuring_tool': f"/measuringtools/{plugin_id}", } url = url_map.get(asset_type_name, f"/assets/{asset.assetid}") @@ -296,6 +299,50 @@ def _search_assets(query, search_term): return results +def _search_measuringtools(query, search_term): + """Search measuring tools, including the gaugelabreference identifier. + + The generic asset search already matches number/name/serial across all asset + types; this gated searcher adds gaugelabreference (the gage tag the gage lab + searches by) and drops out when the measuringtools plugin is disabled. + """ + results = [] + try: + _require_enabled('measuringtools') + from plugins.measuringtools.models import MeasuringTool + assets = db.session.query(Asset).join( + MeasuringTool, MeasuringTool.assetid == Asset.assetid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + db.or_( + Asset.assetnumber.ilike(search_term), + Asset.name.ilike(search_term), + Asset.serialnumber.ilike(search_term), + Asset.gaugelabreference.ilike(search_term), + ) + ).limit(15).all() + + for asset in assets: + relevance = 15 + if asset.assetnumber and query.lower() == asset.assetnumber.lower(): + relevance = 100 + elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower(): + relevance = 90 + elif asset.serialnumber and query.lower() == asset.serialnumber.lower(): + relevance = 85 + elif asset.name and query.lower() in asset.name.lower(): + relevance = 50 + results.append(_get_asset_result(asset, query, relevance)) + except ImportError: + pass # measuringtools plugin absent or disabled + except Exception as e: + logger.error(f"Measuring tool search failed: {e}") + return results + + def _search_by_ip(query, search_term): """Search Communications table for IP address matches.""" results = [] @@ -751,6 +798,7 @@ def global_search(): results.extend(_search_knowledgebase(query, search_term)) results.extend(_search_employees(query, search_term)) results.extend(_search_assets(query, search_term)) + results.extend(_search_measuringtools(query, search_term)) results.extend(_search_notifications(query, search_term)) results.extend(_search_hostnames(query, search_term)) results.extend(_search_vendor_model_type(query, search_term)) diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 34c173d..72c021c 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -53,7 +53,8 @@ IDENTIFIER_LABELS = { 'maintenancereference': 'Maintenance Reference', 'fqdn': 'FQDN / hostname', } -IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device'] +IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device', + 'measuring_tool'] # Global-search result types that can be toggled on/off independently of whether # the owning plugin is enabled. Keys match the `type` field on search results; @@ -67,6 +68,7 @@ SEARCH_DOMAINS = { 'computer': 'PCs', 'printer': 'Printers', 'network_device': 'Network Devices', + 'measuring_tool': 'Measuring Tools', 'notification': 'Notifications', 'subnet': 'Subnets', } diff --git a/tests/test_plugins/test_measuringtools.py b/tests/test_plugins/test_measuringtools.py index f45d718..6367a3f 100644 --- a/tests/test_plugins/test_measuringtools.py +++ b/tests/test_plugins/test_measuringtools.py @@ -240,6 +240,74 @@ def test_list_filter_by_calibrationstatus(client, auth_headers): assert all(r['measuringtool']['calibrationstatus'] == 'overdue' for r in rows) +# -- Maintenance reference identifier ----------------------------------------- + +def test_maintenancereference_roundtrip(client, auth_headers): + """Maintenance reference persists on create and update.""" + created = client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-MNT-1', 'statusid': _status_id(client), + 'maintenancereference': 'MNT-9', + }) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['maintenancereference'] == 'MNT-9' + tool_id = created.get_json()['data']['measuringtool']['measuringtoolid'] + + updated = client.put(f'/api/measuringtools/{tool_id}', headers=auth_headers, + json={'maintenancereference': 'MNT-10'}) + assert updated.status_code == 200 + assert updated.get_json()['data']['maintenancereference'] == 'MNT-10' + + +# -- Global search ------------------------------------------------------------ + +def _mt_search_hits(client, auth_headers, term): + resp = client.get(f'/api/search?q={term}', headers=auth_headers) + assert resp.status_code == 200, resp.get_json() + return [r for r in resp.get_json()['data']['results'] + if r.get('type') == 'measuring_tool'] + + +def test_search_finds_tool_by_assetnumber(mt_app, client, auth_headers, monkeypatch): + """A measuring tool appears in global search by asset number, routed right.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-AN', 'statusid': _status_id(client)}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + hits = _mt_search_hits(client, auth_headers, 'MT-SEARCH-AN') + assert hits + assert hits[0]['url'].startswith('/measuringtools/') + + +def test_search_finds_tool_by_gaugelabreference(mt_app, client, auth_headers, monkeypatch): + """A gage-tag lookup by gaugelabreference finds the tool.""" + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-GL', 'statusid': _status_id(client), + 'gaugelabreference': 'GLREF-778'}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + hits = _mt_search_hits(client, auth_headers, 'GLREF-778') + assert hits + assert hits[0]['url'].startswith('/measuringtools/') + + +def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers, monkeypatch): + """search_measuring_tool_enabled=false hides measuring tools from search.""" + from shopdb.core.models import Setting + client.post('/api/measuringtools', headers=auth_headers, json={ + 'assetnumber': 'MT-SEARCH-OFF', 'statusid': _status_id(client), + 'gaugelabreference': 'GLREF-OFF-1'}) + pm = mt_app.extensions['plugin_manager'] + monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) + Setting.set('search_measuring_tool_enabled', False, valuetype='boolean', + category='search') + try: + assert _mt_search_hits(client, auth_headers, 'MT-SEARCH-OFF') == [] + assert _mt_search_hits(client, auth_headers, 'GLREF-OFF-1') == [] + finally: + Setting.set('search_measuring_tool_enabled', True, valuetype='boolean', + category='search') + + # -- Report shape ------------------------------------------------------------- def test_calibration_report_shape(client, auth_headers):