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