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:
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)
|
||||
Reference in New Issue
Block a user