"""Plugin introspection + enable/disable API.""" from flask import Blueprint, current_app, request from flask_jwt_extended import jwt_required from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.authz import require_role from shopdb.utils.authz import require_permission plugins_bp = Blueprint('plugins', __name__) @plugins_bp.route('', methods=['GET']) @require_permission('settings.view') def list_plugins(): """List all discovered plugins (enabled or not) with their metadata and the framework contract version.""" from shopdb import __contract_version__ pm = current_app.extensions.get('plugin_manager') plugins = pm.discover_available() if pm else [] plugins.sort(key=lambda p: p['name']) return success_response({ 'contract_version': __contract_version__, 'count': len(plugins), 'plugins': plugins, }) @plugins_bp.route('/enabled', methods=['GET']) @jwt_required(optional=True) def list_enabled_plugins(): """Return just the names of enabled plugins as a flat array. Cheap registry read (no DB). Exposed to anonymous callers on purpose: the navigation endpoint already leaks the same enabled/disabled signal, and the frontend needs it (including unauthenticated kiosk routes like /tv) to gate plugin-owned routes. No metadata beyond the names. """ pm = current_app.extensions.get('plugin_manager') names = pm.registry.get_enabled_plugins() if pm else [] return success_response(sorted(names)) @plugins_bp.route('/settings-routes', methods=['GET']) @jwt_required(optional=True) def get_plugin_settings_routes(): """Return settings routes contributed by all enabled plugins. Plugins can register routes to add tabs/pages to settings hubs like Model Catalog, Asset Config, etc. Frontend router merges these dynamically. Returns: { routes: [{hub, path, name, tab, component}, ...] } """ pm = current_app.extensions.get('plugin_manager') if not pm: return success_response({'routes': []}) routes = [] for plugin_name, plugin in pm.get_all_plugins().items(): # Only include routes from enabled plugins if not pm.registry.is_enabled(plugin_name): continue if not hasattr(plugin, 'get_settings_routes'): continue # One broken plugin must not take every settings hub down with it. try: plugin_routes = plugin.get_settings_routes() except Exception: current_app.logger.exception( 'get_settings_routes failed for plugin %s; skipping it', plugin_name) continue if plugin_routes: routes.extend(plugin_routes) return success_response({'routes': routes}) @plugins_bp.route('/', methods=['PUT']) @jwt_required() @require_role('admin') def set_plugin_enabled(name: str): """Enable or disable a plugin. Takes effect on the next app restart for route/navigation changes.""" data = request.get_json() or {} if 'enabled' not in data: return error_response(ErrorCodes.VALIDATION_ERROR, 'enabled is required') pm = current_app.extensions.get('plugin_manager') if not pm: return error_response(ErrorCodes.INTERNAL_ERROR, 'Plugin manager unavailable', http_code=500) want = bool(data['enabled']) ok = pm.enable_plugin(name) if want else pm.disable_plugin(name) if not ok: # enable/disable refused (unknown plugin, or a dependency conflict) return error_response( ErrorCodes.CONFLICT, f"Could not {'enable' if want else 'disable'} '{name}' " f"(check it exists and dependencies allow it)", http_code=409 ) return success_response( {'name': name, 'enabled': want}, message=f"Plugin {'enabled' if want else 'disabled'} " f"(restart to apply route changes)" )