"""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 from shopdb.utils.authz import require_permission pluginui_bp = Blueprint('pluginui', __name__) # The tabs an asset detail page offers, and so the only values a panel's # 'group' may take. Kept here rather than in the frontend because the server # resolves the group before the client sees it - see asset_panels below. # # 'details' is first and is the fallback: it holds what an asset IS, which is # what a panel declaring nothing almost always contributes. # # This tuple is a THIRD copy of the tab vocabulary - RENDERED_GROUPS in # PluginAssetPanels.vue and the tab list in PCDetail.vue are the other two. # Adding a group means touching all three: a value missing here is rewritten to # 'details' before the client ever sees it, so the panels quietly render in the # wrong tab and the intended tab never appears. No error, no log line. ASSET_PANEL_GROUPS = ('details', 'monitors', 'serviceprocess', 'reports', 'software', 'activity') 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']) @require_permission('assets.view') 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']) @require_permission('assets.view') 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 '*'. Every panel comes back carrying a 'group', naming the detail-page tab it belongs to. It is resolved HERE rather than in the client so a plugin that predates tabs, or names a group that does not exist, still lands somewhere a person can see instead of vanishing from a page that only renders known groups. Unknown and absent both mean 'details'. """ 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): panel = dict(panel) group = str(panel.get('group') or '').strip().lower() panel['group'] = group if group in ASSET_PANEL_GROUPS else 'details' matched.append(panel) matched.sort(key=lambda panel: panel.get('position', 99)) return success_response(matched) @pluginui_bp.route('/map-overlays', methods=['GET']) @require_permission('assets.view') 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']) @require_permission('assets.view') 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) @pluginui_bp.route('/asset-type-descriptors', methods=['GET']) @require_permission('assets.view') def asset_type_descriptors(): """Merge enabled plugins' asset-type subtype descriptors. The Asset Type Hub uses these to render generic CRUD interfaces for each plugin's subtype table (printertypes, machinetypes, etc.) without importing plugin models or views. Each descriptor maps to ONE asset type (from assettypes table) and tells core: - Which API endpoint handles the subtype CRUD - Field names for ID, label, description, color - Required permission - Optional plugin-specific tabs (e.g., 'supplies' for printers) Returns: List[Dict] sorted by assettype name """ pm = current_app.extensions.get('plugin_manager') if not pm: return success_response([]) descriptors = [] for name, plugin in pm.get_all_plugins().items(): if not pm.registry.is_enabled(name): continue try: desc = plugin.get_asset_type_descriptor() if desc is None: continue desc['plugin'] = name descriptors.append(desc) except Exception: if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): raise current_app.logger.exception('Plugin %s get_asset_type_descriptor failed', name) # Validation: each descriptor must have required keys required_keys = {'assettype', 'subtype_endpoint', 'subtype_id_field', 'subtype_label_field', 'subtype_description_field', 'subtype_color_field', 'edit_permission'} validated = [] for desc in descriptors: missing = required_keys - set(desc.keys()) if missing: current_app.logger.error( 'Plugin %s descriptor missing keys: %s', desc.get('plugin', 'unknown'), missing ) if current_app.config.get('DEBUG') or current_app.config.get('TESTING'): raise ValueError(f"Descriptor missing keys: {missing}") continue validated.append(desc) validated.sort(key=lambda d: d.get('assettype', '')) return success_response(validated) @pluginui_bp.route('/core-type-descriptors', methods=['GET']) @require_permission('assets.view') def core_type_descriptors(): """Return type descriptors for core-owned non-asset types. These are types managed by core (not plugins) that fit the same management pattern as asset subtypes: location types, notification types, etc. Returns same descriptor format as asset-type-descriptors, with optional 'custom_component' field for types that need specialized UI. """ descriptors = [ { 'category': 'location', 'type_name': 'Location Type', 'type_name_plural': 'Location Types', 'subtype_endpoint': '/locations/types', 'subtype_id_field': 'locationtypeid', 'subtype_label_field': 'locationtype', 'subtype_description_field': 'description', 'subtype_color_field': 'color', 'optional_tabs': [], 'edit_permission': 'admin', 'group': 'other', }, { 'category': 'notification', 'type_name': 'Notification Type', 'type_name_plural': 'Notification Types', 'subtype_endpoint': '/notifications/types', 'subtype_id_field': 'notificationtypeid', 'subtype_label_field': 'typename', 'subtype_description_field': 'typedescription', 'subtype_color_field': 'typecolor', 'optional_tabs': [], 'edit_permission': 'admin', 'group': 'other', 'custom_component': 'NotificationTypesTab', }, ] return success_response(descriptors)