Files
shopdb-flask/shopdb/plugins/base.py
cproudlock 0e0bbc0604 Employees + USB default-disabled with an enable-time provisioning note
Both plugins provision extra tables, so they now install disabled and explain
themselves before a site opts in.

- Plugin contract gains get_provisioning_note() -> {tables, note, docs}.
  Employees and USB implement it (what tables get created in shopdb, how they
  are referenced, link to the schema README; USB references the captured
  DLP/reminder plans).
- Manifest default_enabled=false for employees + usb; the plugins list API
  returns provisioning_note + default_enabled; install now registers a plugin
  disabled when default_enabled is false.
- Setup wizard Features step renders the provisioning note the moment a plugin
  with one is enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:53:43 -04:00

211 lines
7.1 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 []