Settings Consolidation (37+ pages to 8 hubs): - Asset Type Hub: Unified type management with plugin descriptors - Model Catalog Hub: Vendors, types, models, operating systems - Asset Configuration Hub: Statuses, relationships, fields, identifiers - Security Hub: Authentication, tokens, users, audit - Site Configuration Hub: Site settings, floor map, branding, imports - Integrations Hub: ServiceNow, Zabbix, Dell warranty - Locations Hub: Locations, business units, employees - Network Infrastructure Hub: VLANs, subnets Plugin Descriptor Pattern: - Add get_asset_type_descriptor hook to BasePlugin - Implement descriptors in 6 plugins (printers, computers, machines, network, measuringtools, locations) - Add /api/pluginui/asset-type-descriptors and core-type-descriptors endpoints - Generic SubtypesTab component renders CRUD UI from descriptor schema Security Fixes: - Fix stored XSS via javascript: URIs in Models and Support Teams - Add URL scheme validation (allow only http/https) in create/update endpoints - Fix plugin contract violations in endpoints plugin (use shopdb.api imports) - Add missing requiresAdmin guard to printer model supplies route - Add user-facing error messages to API token load failures Integration Improvements: - Add persistent tab navigation to ServiceNow, Zabbix, and Dell Warranty settings - Standardize integration settings UI across all three pages - Fix tab visibility when navigating between integrations Tested with 94-agent orchestrated review for ADR compliance and security.
235 lines
9.2 KiB
Python
235 lines
9.2 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
|
|
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)
|