Files
shopdb-flask/shopdb/plugins/base.py
cproudlock 919e0b8600 Plugin system: fix PluginMeta core_version default + add /api/plugins
- PluginMeta.core_version defaulted to >=1.0.0, which would reject the current
  0.2.0 framework for any plugin relying on the default. Set to >=0.2.0,<1.0.0.
- Add GET /api/plugins introspection: lists loaded plugins (name, version,
  core_version, api_prefix, dependencies) + the framework contract version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:45:18 -04:00

194 lines
6.2 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_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 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_searchable_fields(self) -> List[Dict]:
"""
Return fields this plugin contributes to global search.
Each field: {
'model': Type, # SQLAlchemy model class
'field': str, # Column name to search
'result_type': str, # Type identifier for search results
'url_template': str, # URL template with {id} placeholder
'title_field': str, # Field to use for result title
'subtitle_field': str, # Optional field for subtitle
'relevance_boost': int # Optional relevance score multiplier
}
Example for equipment plugin:
return [{
'model': Equipment,
'join_model': Asset,
'join_condition': Equipment.assetid == Asset.assetid,
'search_fields': ['assetnumber', 'name', 'serialnumber'],
'result_type': 'equipment',
'url_template': '/equipment/{id}',
'title_field': 'assetnumber',
'subtitle_field': 'name',
}]
"""
return []