Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
350
shopdb/plugins/base.py
Normal file
350
shopdb/plugins/base.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""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'
|
||||
'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 []
|
||||
Reference in New Issue
Block a user