From 8a2f984393f7d556544028b12391ca0223251769 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 18 Jul 2026 22:56:05 -0400 Subject: [PATCH] ADR-013 Phase 3: search routes via get_asset_presentation, not a hardcoded map Global-search rows built the plugin detail URL from a hardcoded url_map of plugin routes in core. Now core prefers a plugin's declared get_asset_presentation route (ADR-010), substituting the core assetid via the plugin's by-asset resolver; types that have not declared fall back to the legacy id-keyed map, so nothing breaks. Measuring tools (which declare the route) link through it now; machines/PCs/printers/network migrate off the hardcode as they add a by-asset route + declaration. Presentation map is collected once per search (cached on flask.g). 2 consumer tests; 26 search tests green. --- shopdb/core/api/search.py | 54 +++++++++++++++++---- tests/test_core/test_search_presentation.py | 47 ++++++++++++++++++ 2 files changed, 92 insertions(+), 9 deletions(-) create mode 100644 tests/test_core/test_search_presentation.py diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index 638db01..f02f944 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -106,6 +106,32 @@ def _classify_query(query, integrations): } +def _asset_presentation_map(): + """assettype -> route from enabled plugins' get_asset_presentation (ADR-010). + + Lets core route a plugin-owned asset type in search rows without hardcoding + the route. Cached per request (built once per search, not per result). + """ + from flask import g + cached = getattr(g, '_asset_presentation_map', None) + if cached is not None: + return cached + result = {} + pm = current_app.extensions.get('plugin_manager') + if pm: + for plugin_name, plugin in pm.get_all_plugins().items(): + if not pm.registry.is_enabled(plugin_name): + continue + try: + for entry in plugin.get_asset_presentation() or []: + if entry.get('assettype') and entry.get('route'): + result[entry['assettype']] = entry['route'] + except Exception: + continue + g._asset_presentation_map = result + return result + + def _get_asset_result(asset, query, relevance=None): """Build a search result dict from an Asset object.""" asset_type_name = asset.assettype.assettype if asset.assettype else 'asset' @@ -122,14 +148,24 @@ def _get_asset_result(asset, query, relevance=None): 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}") + # Prefer a plugin's declared get_asset_presentation route (ADR-010): core + # stops hardcoding the plugin's route as each plugin declares one. Those + # routes take the core assetid (via a by-asset resolver). Types that have + # not declared fall back to the legacy id-keyed map below. + presentation = _asset_presentation_map() + if asset_type_name in presentation: + url = presentation[asset_type_name].replace('{assetid}', str(asset.assetid)) + result_id = asset.assetid + else: + 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}") + result_id = plugin_id display_name = asset.display_name subtitle = None @@ -143,7 +179,7 @@ def _get_asset_result(asset, query, relevance=None): return { 'type': asset_type_name, - 'id': plugin_id, + 'id': result_id, 'title': display_name, 'subtitle': subtitle, 'location': location_name, diff --git a/tests/test_core/test_search_presentation.py b/tests/test_core/test_search_presentation.py new file mode 100644 index 0000000..af97dce --- /dev/null +++ b/tests/test_core/test_search_presentation.py @@ -0,0 +1,47 @@ +"""Search consumes get_asset_presentation for plugin-owned routes (ADR-010). + +Core no longer hardcodes a plugin's search route once the plugin declares one: +measuring tools (which declare a by-asset presentation route) link through it, +while a type that has not declared falls back to the legacy id-keyed route. +""" + +from types import SimpleNamespace + +from shopdb.core.api.search import _get_asset_result + + +def _fake_asset(assettype, assetid, **extra): + asset = SimpleNamespace( + assettype=SimpleNamespace(assettype=assettype), + assetid=assetid, + display_name=f'{assettype}-{assetid}', + name=None, + assetnumber=f'AN{assetid}', + location=None, + machine=None, computer=None, network_device=None, printer=None, + measuringtool=None, + ) + for key, value in extra.items(): + setattr(asset, key, value) + return asset + + +def test_measuring_tool_uses_declared_presentation_route(app): + """A declared by-asset route is used, keyed on the core assetid.""" + with app.app_context(): + tool = _fake_asset( + 'measuring_tool', 641, + measuringtool=SimpleNamespace(measuringtoolid=99)) + result = _get_asset_result(tool, 'x') + assert result['url'] == '/measuringtools/by-asset/641' + assert result['id'] == 641 + + +def test_machine_falls_back_to_legacy_route(app): + """A type with no declared presentation keeps the legacy id-keyed route.""" + with app.app_context(): + machine = _fake_asset( + 'machine', 80, machine=SimpleNamespace(machineid=12)) + result = _get_asset_result(machine, 'x') + assert result['url'] == '/machines/12' + assert result['id'] == 12