Files
shopdb-flask/shopdb/core/api/pluginui.py
cproudlock 24f67d6ac5
Some checks failed
CI / backend (push) Failing after 1m2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
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>
2026-07-11 19:38:32 -04:00

109 lines
4.0 KiB
Python

"""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)