Hardens the plugin framework so sister-site adoption is safe. Loader rewrite (shopdb/plugins/loader.py): - Reads manifest.json directly. Dependency sort and version checks no longer instantiate plugin classes (avoids __init__ side effects). - Fail-loud policy: in dev/test (DEBUG or TESTING true), plugin errors re-raise. In production, errors log with full context and the plugin is excluded from registration. Framework keeps booting. - Contract-version range check via packaging.SpecifierSet. Plugin's manifest.core_version must include the framework's __contract_version__ or load fails per the policy above. - Manifest validation: required fields (name, version, description), name matches directory, JSON parseable. Exceptions (shopdb/exceptions.py): - PluginNotFoundError, PluginContractError, PluginVersionError, PluginDependencyError. Specific types replace generic Exception swallowing. Auto-register core blueprints (shopdb/__init__.py): - CORE_BLUEPRINT_NAMES tuple drives registration. Adding a core resource is one entry, not three lines (import + register call). - Replaces 27 hand-coded register_blueprint calls. - Asserts each blueprint is exported by shopdb.core.api at boot. Public API namespace (shopdb/api/__init__.py): - audit_log: thin wrapper over AuditLog.log() with stable signature. - resolve_asset_position: implements ADR-001 position resolution (asset > related > location). Asset.mapx/mapy and AssetRelationship.inheritsposition columns are part of the locked contract surface but not yet in models; helper degrades gracefully to location-only fallback until the migration lands. BasePlugin helpers (shopdb/plugins/base.py): - get_setting(key, default), set_setting(key, value, ...). Settings namespaced as plugin.<pluginname>.<key> so two plugins can use the same key without colliding. Manifest version compatibility (plugins/*/manifest.json): - Bumped core_version from ">=1.0.0" to ">=0.1.0,<1.0.0" so all bundled plugins satisfy the new range check. Contract version bump (shopdb/__init__.py): - 0.1.0 -> 0.2.0. Additive surface change (Setting helpers, shopdb.api namespace) per ADR-002 minor-bump rules. Tests (tests/test_plugin_loader.py, tests/test_api_namespace.py): - 13 loader tests: manifest validation failures, version range checks, plugin.py import errors, strict-vs-isolate behavior under TESTING vs production-like config, manifest-first dependency sort. - 8 api-namespace tests: audit_log roundtrip, resolve position fallback chain, plugin.get_setting/set_setting roundtrip with per-plugin namespacing. Test count: 66 -> 87 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
6.1 KiB
Python
193 lines
6.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)
|
|
core_version: str = ">=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 []
|