Wires the ADR-010 get_asset_panels hook to a generic frontend renderer so a plugin adds detail-page UI as JSON, no Vue. This is the Path A foundation that lets simple plugins ship UI without a frontend build. - components/PluginAssetPanels.vue + pluginAssetPanels.js: fetches /api/pluginui/asset-panels for an asset, then each panel's data endpoint, and renders by mode: list (title + status badge + meta lines via a field map), keyvalue, table (declared or inferred columns), badge. Pure mapping logic is in the .js module and unit tested (9 specs), same pattern as entryForm.js. - New 'list' render mode with a declarative field map (title/badge/meta), documented on the hook in base.py. - Warranty migrated to it: get_asset_panels now declares a 'list' panel + map that reproduces WarrantyPanel's output (vendor title, status badge with color + label map, servicelevel/ends/tag meta, manage link) with zero warranty-specific frontend code. - MachineDetail swapped from <WarrantyPanel> to <PluginAssetPanels> (pilot); the hero warranty badge is unchanged. Verified end to end: the API serves the list panel + map and the warranty rows; the page renders without error. Rollout of the other 4 detail pages (PCDetail, PrinterDetail, NetworkDeviceDetail, MeasuringToolDetail) and the map-overlays / asset-presentation renderers are follow-up Phase 3 commits. 58 vitest, build clean, 1067 backend pass, naming green.
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""Base plugin class that all plugins must inherit from."""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from typing import List, Dict, Optional, Type
|
|
from dataclasses import dataclass, field
|
|
from flask import Flask, Blueprint
|
|
|
|
|
|
@dataclass
|
|
class PluginMeta:
|
|
"""Plugin metadata container."""
|
|
|
|
name: str
|
|
version: str
|
|
description: str
|
|
author: str = ""
|
|
dependencies: List[str] = field(default_factory=list)
|
|
# Default to the current pre-1.0 contract range; tighten when 1.0 lands
|
|
core_version: str = ">=0.2.0,<1.0.0"
|
|
api_prefix: str = None
|
|
|
|
def __post_init__(self):
|
|
if self.api_prefix is None:
|
|
self.api_prefix = f"/api/{self.name.replace('_', '-')}"
|
|
|
|
|
|
class BasePlugin(ABC):
|
|
"""
|
|
Base class for all ShopDB plugins.
|
|
|
|
Plugins must implement:
|
|
- meta: PluginMeta instance
|
|
- get_blueprint(): Return Flask Blueprint for API routes
|
|
- get_models(): Return list of SQLAlchemy model classes
|
|
|
|
Optionally implement:
|
|
- init_app(app, db): Custom initialization
|
|
- get_cli_commands(): Return Click commands
|
|
- get_services(): Return service classes
|
|
- on_install(): Called when plugin is installed
|
|
- on_uninstall(): Called when plugin is uninstalled
|
|
- on_enable(): Called when plugin is enabled
|
|
- on_disable(): Called when plugin is disabled
|
|
"""
|
|
|
|
@property
|
|
@abstractmethod
|
|
def meta(self) -> PluginMeta:
|
|
"""Return plugin metadata."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
"""Return Flask Blueprint with API routes."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_models(self) -> List[Type]:
|
|
"""Return list of SQLAlchemy model classes."""
|
|
pass
|
|
|
|
def init_app(self, app: Flask, db) -> None:
|
|
"""
|
|
Initialize plugin with Flask app.
|
|
Override for custom initialization.
|
|
"""
|
|
pass
|
|
|
|
def get_cli_commands(self) -> List:
|
|
"""Return list of Click command groups/commands."""
|
|
return []
|
|
|
|
def get_services(self) -> Dict[str, Type]:
|
|
"""Return dict of service name -> service class."""
|
|
return {}
|
|
|
|
def get_provisioning_note(self) -> Optional[Dict]:
|
|
"""Transparency note shown when a site enables this plugin.
|
|
|
|
Return None for plugins that need no special setup. For plugins that
|
|
create extra tables (e.g. a self-hosted directory or USB tables), return:
|
|
{
|
|
'tables': ['directoryemployees', ...], # created in the shopdb DB
|
|
'note': 'Plain-language what/why.',
|
|
'docs': 'plugins/<name>/README.md', # where the schema lives
|
|
}
|
|
The setup wizard shows this the moment the plugin is checked.
|
|
"""
|
|
return None
|
|
|
|
def get_config_schema(self) -> List[Dict]:
|
|
"""Declare the config fields this plugin needs, for the setup wizard.
|
|
|
|
Each field is a dict:
|
|
key - the Setting key (non-secret) it maps to
|
|
label - human label
|
|
type - 'text' | 'number' | 'password'
|
|
secret - True for credentials; these are NOT stored in the DB, the
|
|
wizard emits an .env line for the operator to paste instead
|
|
envvar - (secret only) the .env variable name to emit
|
|
default - optional default shown as a placeholder
|
|
help - optional hint
|
|
Return [] (default) if the plugin needs no configuration.
|
|
"""
|
|
return []
|
|
|
|
def get_setting(self, key: str, default=None):
|
|
"""Read a plugin-scoped setting from the core Setting store.
|
|
|
|
Settings are namespaced by plugin name to avoid collisions
|
|
across plugins. The on-disk key is `plugin.<pluginname>.<key>`.
|
|
|
|
Returns the typed value (string, integer, boolean, etc.) or
|
|
the default if not set.
|
|
"""
|
|
from shopdb.core.models import Setting
|
|
namespaced_key = f'plugin.{self.meta.name}.{key}'
|
|
return Setting.get(namespaced_key, default)
|
|
|
|
def set_setting(self, key: str, value, valuetype: str = 'string',
|
|
description: str = None) -> None:
|
|
"""Write a plugin-scoped setting to the core Setting store.
|
|
|
|
Settings are namespaced by plugin name. Persists immediately and
|
|
survives restarts.
|
|
"""
|
|
from shopdb.core.models import Setting
|
|
namespaced_key = f'plugin.{self.meta.name}.{key}'
|
|
Setting.set(
|
|
namespaced_key,
|
|
value,
|
|
valuetype=valuetype,
|
|
category=f'plugin.{self.meta.name}',
|
|
description=description,
|
|
)
|
|
|
|
def get_collector_schema(self) -> Optional[Dict]:
|
|
"""Return JSON Schema describing the collector payload for this plugin.
|
|
|
|
Return None if the plugin does not accept collector input.
|
|
|
|
See ADR-006 for the contract. The schema must include:
|
|
- 'identityfield': name of the field that uniquely identifies an
|
|
asset across submissions (e.g., 'hostname' for PCs,
|
|
'macaddress' for network devices). Used for idempotent upsert.
|
|
- 'fields': JSON Schema definitions for the rest of the payload.
|
|
|
|
Plugins returning a non-None schema have an endpoint at
|
|
/api/collector/<pluginname> auto-registered by the loader.
|
|
"""
|
|
return None
|
|
|
|
def apply_collector_payload(self, payload: Dict) -> Dict:
|
|
"""Idempotently upsert an asset from a validated collector payload.
|
|
|
|
Called by the generic /api/collector/<pluginname> endpoint after the
|
|
payload passed schema validation. Plugins that return a schema from
|
|
get_collector_schema must implement this. Return a dict with at least:
|
|
- 'action': 'created' | 'updated' | 'noop'
|
|
- 'assetid': the affected asset id (or None)
|
|
- 'warnings': list[str]
|
|
"""
|
|
raise NotImplementedError(
|
|
f"{self.meta.name} declares a collector schema but does not "
|
|
f"implement apply_collector_payload"
|
|
)
|
|
|
|
def on_install(self, app: Flask) -> None:
|
|
"""Called when plugin is installed via CLI."""
|
|
pass
|
|
|
|
def on_uninstall(self, app: Flask) -> None:
|
|
"""Called when plugin is uninstalled via CLI."""
|
|
pass
|
|
|
|
def on_enable(self, app: Flask) -> None:
|
|
"""Called when plugin is enabled."""
|
|
pass
|
|
|
|
def on_disable(self, app: Flask) -> None:
|
|
"""Called when plugin is disabled."""
|
|
pass
|
|
|
|
def get_dashboard_widgets(self) -> List[Dict]:
|
|
"""
|
|
Return dashboard widget definitions.
|
|
|
|
Each widget: {
|
|
'name': str,
|
|
'component': str, # Frontend component name
|
|
'endpoint': str, # API endpoint for data
|
|
'size': str, # 'small', 'medium', 'large'
|
|
'position': int # Order on dashboard
|
|
}
|
|
"""
|
|
return []
|
|
|
|
def get_navigation_items(self) -> List[Dict]:
|
|
"""
|
|
Return navigation menu items.
|
|
|
|
Each item: {
|
|
'name': str,
|
|
'icon': str,
|
|
'route': str,
|
|
'position': int,
|
|
'children': []
|
|
}
|
|
"""
|
|
return []
|
|
|
|
def get_permissions(self) -> List:
|
|
"""
|
|
Return the RBAC permissions this plugin owns.
|
|
|
|
Each entry is a (name, description, category) tuple, matching the core
|
|
permission catalog shape (dicts with those keys are also accepted):
|
|
|
|
[
|
|
('machines.view', 'View machines', 'machines'),
|
|
('machines.create', 'Create machines', 'machines'),
|
|
('machines.edit', 'Edit machines', 'machines'),
|
|
('machines.delete', 'Delete machines', 'machines'),
|
|
]
|
|
|
|
A plugin owns the permission names its own routes enforce via
|
|
require_permission; core no longer accumulates them. The names must
|
|
follow the naming convention (lowercase dotted, e.g. `machines.edit`).
|
|
|
|
Consumed by full_permission_catalog(): core permissions plus every
|
|
ENABLED plugin's get_permissions(). That catalog backs `flask seed
|
|
permissions`, the role-management grid (GET /api/users/permissions),
|
|
and API-token scope validation. Plugin install/enable also seeds the
|
|
plugin's own permissions idempotently.
|
|
|
|
Disabled plugins are skipped by the catalog, so their permissions are
|
|
no longer offered for new scope grants or new role assignments. The
|
|
Permission ROWS already in the database are NOT deleted, so roles that
|
|
already reference them keep working until an admin edits the role. A
|
|
broken plugin is isolated in prod and re-raised in dev/test.
|
|
|
|
Return [] (the default) if the plugin needs no permissions.
|
|
"""
|
|
return []
|
|
|
|
def get_reports(self) -> List[Dict]:
|
|
"""
|
|
Return report card definitions for the Reports hub.
|
|
|
|
Each entry: {
|
|
'id': str, # stable report id
|
|
'name': str, # card title
|
|
'description': str, # one-line blurb
|
|
'category': str, # grouping key (lowercase)
|
|
# plus EXACTLY ONE of:
|
|
'route': str, # frontend path for a dedicated report page
|
|
'endpoint': str, # API endpoint for generic inline rendering
|
|
}
|
|
|
|
Consumed by GET /api/reports, which merges these after the static core
|
|
reports. Disabled plugins are skipped by the consumer.
|
|
"""
|
|
return []
|
|
|
|
def get_settings_cards(self) -> List[Dict]:
|
|
"""
|
|
Return settings-catalog card definitions (ADR-010).
|
|
|
|
Each card contributes an entry to the settings rail + landing overview
|
|
without a plugin hand-editing the core settingsNav.js catalog.
|
|
|
|
Each card: {
|
|
'group': str, # rail group title (created if new)
|
|
'to': str, # settings route the card links to
|
|
'icon': str, # string key, mapped to a Lucide icon core-side
|
|
'title': str, # card title
|
|
'description': str, # one-line blurb
|
|
'position': int, # order within the group
|
|
}
|
|
|
|
Consumed by GET /api/pluginui/settings-cards, which merges enabled
|
|
plugins' cards into the core catalog. Disabled plugins are skipped;
|
|
a broken plugin is isolated in prod, re-raised in dev/test.
|
|
"""
|
|
return []
|
|
|
|
def get_asset_panels(self) -> List[Dict]:
|
|
"""
|
|
Return asset-detail extension-panel definitions (ADR-010).
|
|
|
|
A generic core AssetPanel component renders each panel on the asset
|
|
detail pages whose type matches, fetching the panel's endpoint. This
|
|
replaces hand-composing a plugin panel component into each detail view.
|
|
|
|
Each panel: {
|
|
'id': str, # stable panel id
|
|
'title': str, # panel heading
|
|
'assettypes': List[str], # AssetType keys it appears on; ['*'] = all
|
|
'endpoint': str, # data endpoint (may contain {assetid})
|
|
'render': str, # 'keyvalue' | 'table' | 'badge' | 'list'
|
|
# ('list' takes a 'map' of title/badge/meta
|
|
# keys, rendered generically - see warranty)
|
|
'position': int, # order among panels
|
|
}
|
|
|
|
Consumed by GET /api/pluginui/asset-panels?assetid=<id>, which returns
|
|
the panels matching that asset's type. Disabled plugins
|
|
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
|
A panel needing bespoke UI is out of scope for the data-only hook.
|
|
"""
|
|
return []
|
|
|
|
def get_map_overlays(self) -> List[Dict]:
|
|
"""
|
|
Return shop-floor map overlay/decoration definitions (ADR-010).
|
|
|
|
The map is data-driven off asset types + positions; an overlay adds
|
|
decoration data (a badge or ring on already-placed markers) plus a
|
|
legend entry, without the plugin shipping any map code.
|
|
|
|
Each overlay: {
|
|
'id': str, # stable overlay id
|
|
'label': str, # legend label
|
|
'endpoint': str, # returns [{assetid, color, label}] to decorate
|
|
'style': str, # 'badge' | 'ring'
|
|
'legend': bool, # True to add a legend entry
|
|
}
|
|
|
|
Consumed by GET /api/pluginui/map-overlays. Disabled plugins are
|
|
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
|
"""
|
|
return []
|
|
|
|
def get_asset_presentation(self) -> List[Dict]:
|
|
"""
|
|
Return asset-type presentation/routing definitions (ADR-010).
|
|
|
|
Declares how a plugin-owned asset type renders in global-search rows
|
|
and cross-links: which icon to show and where the detail link points,
|
|
so core never hardcodes a plugin's route or icon.
|
|
|
|
Each entry: {
|
|
'assettype': str, # AssetType.assettype key the plugin owns
|
|
'icon': str, # string key, mapped to a Lucide icon core-side
|
|
'label': str, # human label for the type
|
|
'route': str, # detail-route pattern (may contain {assetid})
|
|
}
|
|
|
|
Consumed by GET /api/pluginui/asset-presentation. Disabled plugins are
|
|
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
|
"""
|
|
return []
|