diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md index e69330d..299c7d2 100644 --- a/docs/PROJECT-MAP.md +++ b/docs/PROJECT-MAP.md @@ -85,6 +85,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always ## Size -- test functions defined: **1084** (parametrised cases collect higher) +- test functions defined: **1087** (parametrised cases collect higher) - documented API paths: **276** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) diff --git a/docs/api-inventory.json b/docs/api-inventory.json index d555384..a5cea00 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -1582,7 +1582,7 @@ "method": "GET", "path": "/api/computers", "auth": "jwt-optional", - "params": "page, per_page, active, search (assetnumber/name/serialnumber/hostname ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)", + "params": "page, per_page, active, search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)", "purpose": "List computers (asset + computer + resolved access links), filtered and paginated", "example": "curl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'" }, @@ -1749,7 +1749,7 @@ "path": "/api/measuringtools", "purpose": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.", "auth": "jwt-optional", - "params": "query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage", + "params": "query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage", "example": "curl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'" }, { @@ -1858,7 +1858,7 @@ "path": "/api/machines", "purpose": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with dualpathpartner) when site setting enabled", "auth": "jwt-optional", - "params": "query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)", + "params": "query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)", "example": "curl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'" }, { @@ -2546,7 +2546,7 @@ "path": "/api/network", "purpose": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated", "auth": "jwt-optional", - "params": "page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)", + "params": "page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)", "example": "curl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'" }, { @@ -2852,7 +2852,7 @@ "method": "GET", "path": "/api/printers", "auth": "jwt-optional", - "params": "page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)", + "params": "page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)", "purpose": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.", "example": "curl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'" }, diff --git a/docs/openapi.json b/docs/openapi.json index 19313da..8dc18a2 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -9231,7 +9231,7 @@ "plugin-computers" ], "summary": "List computers (asset + computer + resolved access links), filtered and paginated", - "description": "List computers (asset + computer + resolved access links), filtered and paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, search (assetnumber/name/serialnumber/hostname ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'\n```", + "description": "List computers (asset + computer + resolved access links), filtered and paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'\n```", "security": [ {}, { @@ -10135,7 +10135,7 @@ "plugin-measuringtools" ], "summary": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied...", - "description": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.\n\n**Auth:** jwt-optional\n\n**Params:** query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'\n```", + "description": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.\n\n**Auth:** jwt-optional\n\n**Params:** query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'\n```", "security": [ {}, { @@ -10721,7 +10721,7 @@ "plugin-machines" ], "summary": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row...", - "description": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with dualpathpartner) when site setting enabled\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'\n```", + "description": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with dualpathpartner) when site setting enabled\n\n**Auth:** jwt-optional\n\n**Params:** query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'\n```", "security": [ {}, { @@ -14788,7 +14788,7 @@ "plugin-network" ], "summary": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated", - "description": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'\n```", + "description": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'\n```", "security": [ {}, { @@ -16543,7 +16543,7 @@ "plugin-printers" ], "summary": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.", - "description": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'\n```", + "description": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.\n\n**Auth:** jwt-optional\n\n**Params:** page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'\n```", "security": [ {}, { diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 9031517..79d0a73 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -375,6 +375,11 @@ def list_computers(): Asset.assetnumber.ilike(pattern), Asset.name.ilike(pattern), Asset.serialnumber.ilike(pattern), + # The optional identifiers too (ADR-001). Global search matches + # these, and a tag read off the machine has to find it here as + # well - this box is where someone holding the label looks. + Asset.gaugelabreference.ilike(pattern), + Asset.maintenancereference.ilike(pattern), Computer.hostname.ilike(pattern), ComputerType.computertype.ilike(pattern) ) diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py index a3a1dda..b6af672 100644 --- a/plugins/machines/api/routes.py +++ b/plugins/machines/api/routes.py @@ -189,6 +189,11 @@ def list_machines(): Asset.assetnumber.ilike(pattern), Asset.name.ilike(pattern), Asset.serialnumber.ilike(pattern), + # The optional identifiers too (ADR-001). Global search matches + # these, and a tag read off the machine has to find it here as + # well - this box is where someone holding the label looks. + Asset.gaugelabreference.ilike(pattern), + Asset.maintenancereference.ilike(pattern), MachineType.machinetype.ilike(pattern), Vendor.vendor.ilike(pattern) ) diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py index 7739576..b038445 100644 --- a/plugins/measuringtools/api/routes.py +++ b/plugins/measuringtools/api/routes.py @@ -179,6 +179,11 @@ def list_tools(): Asset.assetnumber.ilike(pattern), Asset.name.ilike(pattern), Asset.serialnumber.ilike(pattern), + # The optional identifiers too (ADR-001). Global search matches + # these, and a tag read off the machine has to find it here as + # well - this box is where someone holding the label looks. + Asset.gaugelabreference.ilike(pattern), + Asset.maintenancereference.ilike(pattern), MeasuringToolType.name.ilike(pattern), )) if type_id := request.args.get('typeid', type=int): diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py index 9e1b80d..4c7780b 100644 --- a/plugins/network/api/routes.py +++ b/plugins/network/api/routes.py @@ -223,6 +223,11 @@ def list_network_devices(): Asset.assetnumber.ilike(pattern), Asset.name.ilike(pattern), Asset.serialnumber.ilike(pattern), + # The optional identifiers too (ADR-001). Global search matches + # these, and a tag read off the machine has to find it here as + # well - this box is where someone holding the label looks. + Asset.gaugelabreference.ilike(pattern), + Asset.maintenancereference.ilike(pattern), NetworkDevice.hostname.ilike(pattern), NetworkDeviceType.networkdevicetype.ilike(pattern), Vendor.vendor.ilike(pattern) diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 12275b8..abe0998 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -254,6 +254,11 @@ def list_printers(): Asset.assetnumber.ilike(pattern), Asset.name.ilike(pattern), Asset.serialnumber.ilike(pattern), + # The optional identifiers too (ADR-001). Global search matches + # these, and a tag read off the machine has to find it here as + # well - this box is where someone holding the label looks. + Asset.gaugelabreference.ilike(pattern), + Asset.maintenancereference.ilike(pattern), Printer.hostname.ilike(pattern), Printer.windowsname.ilike(pattern), PrinterType.printertype.ilike(pattern), diff --git a/tests/test_core/test_list_search_identifiers.py b/tests/test_core/test_list_search_identifiers.py new file mode 100644 index 0000000..ad8c51a --- /dev/null +++ b/tests/test_core/test_list_search_identifiers.py @@ -0,0 +1,84 @@ +"""Every asset list's search box matches the optional identifiers (ADR-001). + +Settings offers `gaugelabreference` and `maintenancereference` on machines, PCs, +printers, network devices and measuring tools. Both were shown on the form and +the detail page and neither was matched by any list's `?search=`, so someone +holding a physical tag could read it, type it into the list they were already +looking at, and get nothing - while global search found it. + +One parametrized test over all five lists on purpose. The search clause is +copy-pasted five times, once per plugin, which is exactly how all five came to +omit a field their forms collect; a per-plugin test would drift the same way. +""" + +import pytest + +from shopdb.core.models import Asset, AssetType + + +# (list endpoint, create endpoint, assettype) +LISTS = [ + ('/api/machines', '/api/machines', 'machine'), + ('/api/computers', '/api/computers', 'computer'), + ('/api/printers', '/api/printers', 'printer'), + ('/api/network', '/api/network', 'network_device'), + ('/api/measuringtools', '/api/measuringtools', 'measuring_tool'), +] + + +def _seed(client, db, auth_headers, createpath, assettype, assetnumber): + """Create one record of this type carrying both identifiers.""" + if not AssetType.query.filter_by(assettype=assettype).first(): + db.session.add(AssetType(assettype=assettype, pluginname=assettype, + tablename=assettype, description=assettype)) + db.session.commit() + + resp = client.post(createpath, json={'assetnumber': assetnumber}, + headers=auth_headers) + assert resp.status_code == 201, (createpath, resp.get_json()) + + asset = Asset.query.filter_by(assetnumber=assetnumber).first() + asset.gaugelabreference = f'ZZG{assetnumber}' + asset.maintenancereference = f'ZZM{assetnumber}' + db.session.commit() + return asset + + +def _numbers(client, auth_headers, listpath, query): + """Asset numbers a list returns for a search term.""" + resp = client.get(f'{listpath}?search={query}', headers=auth_headers) + assert resp.status_code == 200, (listpath, resp.get_json()) + found = [] + for row in resp.get_json()['data']: + # Lists differ in shape; the asset number is the field all of them + # carry, either directly or on a nested asset. + found.append(row.get('assetnumber') + or (row.get('asset') or {}).get('assetnumber')) + return [f for f in found if f] + + +@pytest.mark.parametrize('listpath,createpath,assettype', LISTS) +def test_a_list_search_finds_a_gauge_lab_reference(client, db, auth_headers, + listpath, createpath, assettype): + assetnumber = f'ZZ{assettype[:4].upper()}01' + _seed(client, db, auth_headers, createpath, assettype, assetnumber) + assert assetnumber in _numbers(client, auth_headers, listpath, + f'ZZG{assetnumber}') + + +@pytest.mark.parametrize('listpath,createpath,assettype', LISTS) +def test_a_list_search_finds_a_maintenance_reference(client, db, auth_headers, + listpath, createpath, assettype): + assetnumber = f'ZZ{assettype[:4].upper()}02' + _seed(client, db, auth_headers, createpath, assettype, assetnumber) + assert assetnumber in _numbers(client, auth_headers, listpath, + f'ZZM{assetnumber}') + + +@pytest.mark.parametrize('listpath,createpath,assettype', LISTS) +def test_a_list_search_still_excludes_a_non_match(client, db, auth_headers, + listpath, createpath, assettype): + """Widening the clause must not turn the search box into a pass-through.""" + assetnumber = f'ZZ{assettype[:4].upper()}03' + _seed(client, db, auth_headers, createpath, assettype, assetnumber) + assert _numbers(client, auth_headers, listpath, 'ZZNOTHINGMATCHESTHIS') == []