Accept + implement ADR-010 frontend plugin hooks (contract 0.7.0)
Four data-only hooks on BasePlugin (get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation) with a GET-only /api/pluginui consumer surface copying the dashboard-widgets semantics. Pilots: warranty declares its asset panel; measuringtools supplies its settings card, presentation, and calibration overlay - the last hardcoded settings-nav entry is now hook-sourced. Generic renderers for panels/overlays/presentation deferred per the ADR's incremental adoption plan (documented in CONTRACT-STABILITY.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,10 @@ from .plugins import plugin_manager
|
||||
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
|
||||
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
|
||||
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
|
||||
__contract_version__ = '0.6.0'
|
||||
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
||||
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
||||
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
||||
__contract_version__ = '0.7.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
@@ -119,6 +122,7 @@ CORE_BLUEPRINT_NAMES = (
|
||||
'users',
|
||||
'customfields',
|
||||
'setup',
|
||||
'pluginui',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .auditlogs import auditlogs_bp
|
||||
from .users import users_bp
|
||||
from .customfields import customfields_bp
|
||||
from .setup import setup_bp
|
||||
from .pluginui import pluginui_bp
|
||||
|
||||
__all__ = [
|
||||
'auth_bp',
|
||||
@@ -42,4 +43,5 @@ __all__ = [
|
||||
'users_bp',
|
||||
'customfields_bp',
|
||||
'setup_bp',
|
||||
'pluginui_bp',
|
||||
]
|
||||
|
||||
108
shopdb/core/api/pluginui.py
Normal file
108
shopdb/core/api/pluginui.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Plugin frontend-contribution API endpoints (ADR-010).
|
||||
|
||||
Consumers for the four data-only presentation hooks on BasePlugin:
|
||||
get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation.
|
||||
|
||||
Every endpoint copies the dashboard-widgets consumer semantics: skip disabled
|
||||
plugins, tag each contributed dict with its originating plugin name, fail loud
|
||||
in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional,
|
||||
matching the other read-only core endpoints; they expose data, not mutations.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
pluginui_bp = Blueprint('pluginui', __name__)
|
||||
|
||||
|
||||
def _collect(hookname):
|
||||
"""Aggregate a data-only hook across enabled plugins.
|
||||
|
||||
Same access pattern as dashboard.get_widgets: skip disabled plugins, inject
|
||||
the plugin name on every returned dict, re-raise in dev/test, log-and-isolate
|
||||
a broken plugin in prod. Returns the merged list (unsorted).
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
return []
|
||||
|
||||
merged = []
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
for entry in getattr(plugin, hookname)() or []:
|
||||
entry['plugin'] = name
|
||||
merged.append(entry)
|
||||
except Exception:
|
||||
# fail loud in dev/test, isolate broke plugin in prod
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise
|
||||
current_app.logger.exception('Plugin %s %s failed', name, hookname)
|
||||
return merged
|
||||
|
||||
|
||||
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def settings_cards():
|
||||
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
||||
|
||||
The frontend merges these into the core settingsNav catalog and renders
|
||||
them in the settings rail + landing overview.
|
||||
"""
|
||||
cards = _collect('get_settings_cards')
|
||||
cards.sort(key=lambda card: card.get('position', 99))
|
||||
return success_response(cards)
|
||||
|
||||
|
||||
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def asset_panels():
|
||||
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
||||
|
||||
Query param assetid is required. A panel matches when its assettypes list
|
||||
contains the asset's type key or the wildcard '*'.
|
||||
"""
|
||||
assetid = request.args.get('assetid', type=int)
|
||||
if not assetid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required')
|
||||
|
||||
asset = db.session.get(Asset, assetid)
|
||||
if not asset:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
assettype = asset.assettype.assettype if asset.assettype else None
|
||||
|
||||
matched = []
|
||||
for panel in _collect('get_asset_panels'):
|
||||
assettypes = panel.get('assettypes') or []
|
||||
if '*' in assettypes or (assettype and assettype in assettypes):
|
||||
matched.append(panel)
|
||||
matched.sort(key=lambda panel: panel.get('position', 99))
|
||||
return success_response(matched)
|
||||
|
||||
|
||||
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def map_overlays():
|
||||
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
||||
|
||||
The map fetches each overlay endpoint and decorates already-placed markers.
|
||||
"""
|
||||
overlays = _collect('get_map_overlays')
|
||||
overlays.sort(key=lambda overlay: overlay.get('position', 99))
|
||||
return success_response(overlays)
|
||||
|
||||
|
||||
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def asset_presentation():
|
||||
"""Merge enabled plugins' asset-type presentation entries (hook).
|
||||
|
||||
Search rows and cross-links use these to pick a type's icon and detail route.
|
||||
"""
|
||||
entries = _collect('get_asset_presentation')
|
||||
return success_response(entries)
|
||||
@@ -227,3 +227,90 @@ class BasePlugin(ABC):
|
||||
reports. Disabled plugins are skipped by the consumer.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""
|
||||
Return settings-catalog card definitions (ADR-010).
|
||||
|
||||
Each card contributes an entry to the settings rail + landing overview
|
||||
without a plugin hand-editing the core settingsNav.js catalog.
|
||||
|
||||
Each card: {
|
||||
'group': str, # rail group title (created if new)
|
||||
'to': str, # settings route the card links to
|
||||
'icon': str, # string key, mapped to a Lucide icon core-side
|
||||
'title': str, # card title
|
||||
'description': str, # one-line blurb
|
||||
'position': int, # order within the group
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/settings-cards, which merges enabled
|
||||
plugins' cards into the core catalog. Disabled plugins are skipped;
|
||||
a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
"""
|
||||
Return asset-detail extension-panel definitions (ADR-010).
|
||||
|
||||
A generic core AssetPanel component renders each panel on the asset
|
||||
detail pages whose type matches, fetching the panel's endpoint. This
|
||||
replaces hand-composing a plugin panel component into each detail view.
|
||||
|
||||
Each panel: {
|
||||
'id': str, # stable panel id
|
||||
'title': str, # panel heading
|
||||
'assettypes': List[str], # AssetType keys it appears on; ['*'] = all
|
||||
'endpoint': str, # data endpoint (may contain {assetid})
|
||||
'render': str, # 'keyvalue' | 'table' | 'badge'
|
||||
'position': int, # order among panels
|
||||
}
|
||||
|
||||
Consumed by GET /api/assets/{assetid}/panels via the pluginui consumer,
|
||||
which returns the panels matching that asset's type. Disabled plugins
|
||||
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
A panel needing bespoke UI is out of scope for the data-only hook.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_map_overlays(self) -> List[Dict]:
|
||||
"""
|
||||
Return shop-floor map overlay/decoration definitions (ADR-010).
|
||||
|
||||
The map is data-driven off asset types + positions; an overlay adds
|
||||
decoration data (a badge or ring on already-placed markers) plus a
|
||||
legend entry, without the plugin shipping any map code.
|
||||
|
||||
Each overlay: {
|
||||
'id': str, # stable overlay id
|
||||
'label': str, # legend label
|
||||
'endpoint': str, # returns [{assetid, color, label}] to decorate
|
||||
'style': str, # 'badge' | 'ring'
|
||||
'legend': bool, # True to add a legend entry
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/map-overlays. Disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_presentation(self) -> List[Dict]:
|
||||
"""
|
||||
Return asset-type presentation/routing definitions (ADR-010).
|
||||
|
||||
Declares how a plugin-owned asset type renders in global-search rows
|
||||
and cross-links: which icon to show and where the detail link points,
|
||||
so core never hardcodes a plugin's route or icon.
|
||||
|
||||
Each entry: {
|
||||
'assettype': str, # AssetType.assettype key the plugin owns
|
||||
'icon': str, # string key, mapped to a Lucide icon core-side
|
||||
'label': str, # human label for the type
|
||||
'route': str, # detail-route pattern (may contain {assetid})
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/asset-presentation. Disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user