Initial commit: Shop Database Flask Application
Flask backend with Vue 3 frontend for shop floor machine management. Includes database schema export for MySQL shopdb_flask database. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
276
shopdb/plugins/__init__.py
Normal file
276
shopdb/plugins/__init__.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Plugin manager - main entry point for plugin system."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from flask import Flask
|
||||
import logging
|
||||
|
||||
from .base import BasePlugin, PluginMeta
|
||||
from .registry import PluginRegistry, PluginState
|
||||
from .loader import PluginLoader
|
||||
from .migrations import PluginMigrationManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
'PluginManager',
|
||||
'BasePlugin',
|
||||
'PluginMeta',
|
||||
'PluginRegistry',
|
||||
'PluginState',
|
||||
'plugin_manager'
|
||||
]
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""
|
||||
Central manager for all plugin operations.
|
||||
|
||||
Usage:
|
||||
plugin_manager = PluginManager()
|
||||
plugin_manager.init_app(app, db)
|
||||
|
||||
# In CLI:
|
||||
plugin_manager.install_plugin('printers')
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.registry: Optional[PluginRegistry] = None
|
||||
self.loader: Optional[PluginLoader] = None
|
||||
self.migration_manager: Optional[PluginMigrationManager] = None
|
||||
self._app: Optional[Flask] = None
|
||||
self._db = None
|
||||
|
||||
def init_app(self, app: Flask, db) -> None:
|
||||
"""Initialize plugin manager with Flask app."""
|
||||
self._app = app
|
||||
self._db = db
|
||||
|
||||
# Setup paths
|
||||
instance_path = Path(app.instance_path)
|
||||
plugins_dir = Path(app.root_path).parent / 'plugins'
|
||||
|
||||
# Initialize components
|
||||
self.registry = PluginRegistry(instance_path / 'plugins.json')
|
||||
self.loader = PluginLoader(plugins_dir, self.registry)
|
||||
self.migration_manager = PluginMigrationManager(
|
||||
plugins_dir,
|
||||
app.config.get('SQLALCHEMY_DATABASE_URI')
|
||||
)
|
||||
|
||||
# Load enabled plugins
|
||||
self._load_enabled_plugins()
|
||||
|
||||
# Store on app for access
|
||||
app.extensions['plugin_manager'] = self
|
||||
|
||||
def _load_enabled_plugins(self) -> None:
|
||||
"""Load and register all enabled plugins."""
|
||||
plugins = self.loader.load_enabled_plugins(self._app, self._db)
|
||||
|
||||
for name, plugin in plugins.items():
|
||||
self._register_plugin_components(plugin)
|
||||
|
||||
def _register_plugin_components(self, plugin: BasePlugin) -> None:
|
||||
"""Register plugin's blueprint, models, CLI commands, etc."""
|
||||
# Register blueprint
|
||||
blueprint = plugin.get_blueprint()
|
||||
if blueprint:
|
||||
self._app.register_blueprint(
|
||||
blueprint,
|
||||
url_prefix=plugin.meta.api_prefix
|
||||
)
|
||||
logger.debug(f"Registered blueprint: {plugin.meta.api_prefix}")
|
||||
|
||||
# Register CLI commands
|
||||
for cmd in plugin.get_cli_commands():
|
||||
self._app.cli.add_command(cmd)
|
||||
|
||||
def discover_available(self) -> List[Dict]:
|
||||
"""
|
||||
Get list of all available plugins (installed or not).
|
||||
Returns list of plugin info dicts.
|
||||
"""
|
||||
available = []
|
||||
|
||||
for name in self.loader.discover_plugins():
|
||||
plugin_class = self.loader.load_plugin_class(name)
|
||||
if plugin_class:
|
||||
try:
|
||||
temp = plugin_class()
|
||||
meta = temp.meta
|
||||
state = self.registry.get(name)
|
||||
|
||||
available.append({
|
||||
'name': meta.name,
|
||||
'version': meta.version,
|
||||
'description': meta.description,
|
||||
'author': meta.author,
|
||||
'dependencies': meta.dependencies,
|
||||
'installed': state is not None,
|
||||
'enabled': state.enabled if state else False,
|
||||
'installed_at': state.installed_at if state else None
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error inspecting plugin {name}: {e}")
|
||||
|
||||
return available
|
||||
|
||||
def install_plugin(self, name: str, run_migrations: bool = True) -> bool:
|
||||
"""
|
||||
Install a plugin.
|
||||
|
||||
Steps:
|
||||
1. Verify plugin exists
|
||||
2. Check dependencies
|
||||
3. Run database migrations
|
||||
4. Register in registry
|
||||
5. Call plugin's on_install hook
|
||||
"""
|
||||
# Check if already installed
|
||||
if self.registry.is_installed(name):
|
||||
logger.warning(f"Plugin {name} is already installed")
|
||||
return False
|
||||
|
||||
# Load plugin class
|
||||
plugin_class = self.loader.load_plugin_class(name)
|
||||
if not plugin_class:
|
||||
logger.error(f"Plugin {name} not found")
|
||||
return False
|
||||
|
||||
temp_plugin = plugin_class()
|
||||
meta = temp_plugin.meta
|
||||
|
||||
# Check dependencies
|
||||
for dep in meta.dependencies:
|
||||
if not self.registry.is_installed(dep):
|
||||
logger.error(
|
||||
f"Plugin {name} requires {dep} to be installed first"
|
||||
)
|
||||
return False
|
||||
|
||||
# Run migrations
|
||||
if run_migrations:
|
||||
success = self.migration_manager.run_plugin_migrations(name)
|
||||
if not success:
|
||||
logger.error(f"Failed to run migrations for {name}")
|
||||
return False
|
||||
|
||||
# Register plugin
|
||||
self.registry.register(name, meta.version)
|
||||
|
||||
# Load the plugin
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._register_plugin_components(plugin)
|
||||
plugin.on_install(self._app)
|
||||
|
||||
logger.info(f"Installed plugin: {name} v{meta.version}")
|
||||
return True
|
||||
|
||||
def uninstall_plugin(self, name: str, remove_data: bool = False) -> bool:
|
||||
"""
|
||||
Uninstall a plugin.
|
||||
|
||||
Args:
|
||||
name: Plugin name
|
||||
remove_data: If True, run downgrade migrations to remove tables
|
||||
"""
|
||||
if not self.registry.is_installed(name):
|
||||
logger.warning(f"Plugin {name} is not installed")
|
||||
return False
|
||||
|
||||
# Check if other plugins depend on this one
|
||||
for other_name in self.registry.get_enabled_plugins():
|
||||
if other_name == name:
|
||||
continue
|
||||
other_plugin = self.loader.get_loaded_plugin(other_name)
|
||||
if other_plugin and name in other_plugin.meta.dependencies:
|
||||
logger.error(
|
||||
f"Cannot uninstall {name}: {other_name} depends on it"
|
||||
)
|
||||
return False
|
||||
|
||||
# Get plugin instance
|
||||
plugin = self.loader.get_loaded_plugin(name)
|
||||
|
||||
# Call on_uninstall hook
|
||||
if plugin:
|
||||
plugin.on_uninstall(self._app)
|
||||
|
||||
# Optionally remove data
|
||||
if remove_data:
|
||||
self.migration_manager.downgrade_plugin(name)
|
||||
|
||||
# Unregister
|
||||
self.registry.unregister(name)
|
||||
|
||||
logger.info(f"Uninstalled plugin: {name}")
|
||||
return True
|
||||
|
||||
def enable_plugin(self, name: str) -> bool:
|
||||
"""Enable a disabled plugin."""
|
||||
if not self.registry.is_installed(name):
|
||||
logger.error(f"Plugin {name} is not installed")
|
||||
return False
|
||||
|
||||
if self.registry.is_enabled(name):
|
||||
logger.info(f"Plugin {name} is already enabled")
|
||||
return True
|
||||
|
||||
# Check dependencies are enabled
|
||||
plugin_class = self.loader.load_plugin_class(name)
|
||||
if plugin_class:
|
||||
temp = plugin_class()
|
||||
for dep in temp.meta.dependencies:
|
||||
if not self.registry.is_enabled(dep):
|
||||
logger.error(f"Cannot enable {name}: {dep} is not enabled")
|
||||
return False
|
||||
|
||||
self.registry.enable(name)
|
||||
|
||||
# Load the plugin
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._register_plugin_components(plugin)
|
||||
plugin.on_enable(self._app)
|
||||
|
||||
logger.info(f"Enabled plugin: {name}")
|
||||
return True
|
||||
|
||||
def disable_plugin(self, name: str) -> bool:
|
||||
"""Disable an enabled plugin."""
|
||||
if not self.registry.is_enabled(name):
|
||||
logger.info(f"Plugin {name} is already disabled")
|
||||
return True
|
||||
|
||||
# Check if other plugins depend on this one
|
||||
for other_name in self.registry.get_enabled_plugins():
|
||||
if other_name == name:
|
||||
continue
|
||||
other_plugin = self.loader.get_loaded_plugin(other_name)
|
||||
if other_plugin and name in other_plugin.meta.dependencies:
|
||||
logger.error(
|
||||
f"Cannot disable {name}: {other_name} depends on it"
|
||||
)
|
||||
return False
|
||||
|
||||
plugin = self.loader.get_loaded_plugin(name)
|
||||
if plugin:
|
||||
plugin.on_disable(self._app)
|
||||
|
||||
self.registry.disable(name)
|
||||
logger.info(f"Disabled plugin: {name}")
|
||||
return True
|
||||
|
||||
def get_plugin(self, name: str) -> Optional[BasePlugin]:
|
||||
"""Get a loaded plugin instance."""
|
||||
return self.loader.get_loaded_plugin(name)
|
||||
|
||||
def get_all_plugins(self) -> Dict[str, BasePlugin]:
|
||||
"""Get all loaded plugins."""
|
||||
return self.loader.get_all_loaded()
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
plugin_manager = PluginManager()
|
||||
122
shopdb/plugins/base.py
Normal file
122
shopdb/plugins/base.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""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_event_handlers(self) -> Dict[str, callable]:
|
||||
"""Return dict of event name -> handler function."""
|
||||
return {}
|
||||
|
||||
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 []
|
||||
203
shopdb/plugins/cli.py
Normal file
203
shopdb/plugins/cli.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Flask CLI commands for plugin management."""
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
|
||||
@click.group('plugin')
|
||||
def plugin_cli():
|
||||
"""Plugin management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@plugin_cli.command('list')
|
||||
@with_appcontext
|
||||
def list_plugins():
|
||||
"""List all available plugins."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
return
|
||||
|
||||
plugins = pm.discover_available()
|
||||
|
||||
if not plugins:
|
||||
click.echo("No plugins found in plugins directory.")
|
||||
return
|
||||
|
||||
# Format output
|
||||
click.echo("")
|
||||
click.echo(click.style("Available Plugins:", fg='cyan', bold=True))
|
||||
click.echo("-" * 60)
|
||||
|
||||
for p in plugins:
|
||||
if p['enabled']:
|
||||
status = click.style("[Enabled]", fg='green')
|
||||
elif p['installed']:
|
||||
status = click.style("[Disabled]", fg='yellow')
|
||||
else:
|
||||
status = click.style("[Available]", fg='white')
|
||||
|
||||
click.echo(f" {p['name']:20} v{p['version']:10} {status}")
|
||||
if p['description']:
|
||||
click.echo(f" {p['description'][:55]}...")
|
||||
if p['dependencies']:
|
||||
deps = ', '.join(p['dependencies'])
|
||||
click.echo(f" Dependencies: {deps}")
|
||||
|
||||
click.echo("")
|
||||
|
||||
|
||||
@plugin_cli.command('install')
|
||||
@click.argument('name')
|
||||
@click.option('--skip-migrations', is_flag=True, help='Skip database migrations')
|
||||
@with_appcontext
|
||||
def install_plugin(name: str, skip_migrations: bool):
|
||||
"""
|
||||
Install a plugin.
|
||||
|
||||
Usage: flask plugin install printers
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Installing plugin: {name}")
|
||||
|
||||
if pm.install_plugin(name, run_migrations=not skip_migrations):
|
||||
click.echo(click.style(f"Successfully installed {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to install {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('uninstall')
|
||||
@click.argument('name')
|
||||
@click.option('--remove-data', is_flag=True, help='Remove plugin database tables')
|
||||
@click.confirmation_option(prompt='Are you sure you want to uninstall this plugin?')
|
||||
@with_appcontext
|
||||
def uninstall_plugin(name: str, remove_data: bool):
|
||||
"""
|
||||
Uninstall a plugin.
|
||||
|
||||
Usage: flask plugin uninstall printers
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Uninstalling plugin: {name}")
|
||||
|
||||
if pm.uninstall_plugin(name, remove_data=remove_data):
|
||||
click.echo(click.style(f"Successfully uninstalled {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to uninstall {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('enable')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def enable_plugin(name: str):
|
||||
"""Enable a disabled plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if pm.enable_plugin(name):
|
||||
click.echo(click.style(f"Enabled {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to enable {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('disable')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def disable_plugin(name: str):
|
||||
"""Disable an enabled plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if pm.disable_plugin(name):
|
||||
click.echo(click.style(f"Disabled {name}", fg='green'))
|
||||
else:
|
||||
click.echo(click.style(f"Failed to disable {name}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
@plugin_cli.command('info')
|
||||
@click.argument('name')
|
||||
@with_appcontext
|
||||
def plugin_info(name: str):
|
||||
"""Show detailed information about a plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
plugin_class = pm.loader.load_plugin_class(name)
|
||||
if not plugin_class:
|
||||
click.echo(click.style(f"Plugin {name} not found", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
temp = plugin_class()
|
||||
meta = temp.meta
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"Error loading plugin: {e}", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
state = pm.registry.get(name)
|
||||
|
||||
click.echo("")
|
||||
click.echo("=" * 50)
|
||||
click.echo(click.style(f"Plugin: {meta.name}", fg='cyan', bold=True))
|
||||
click.echo("=" * 50)
|
||||
click.echo(f"Version: {meta.version}")
|
||||
click.echo(f"Description: {meta.description}")
|
||||
click.echo(f"Author: {meta.author or 'Unknown'}")
|
||||
click.echo(f"API Prefix: {meta.api_prefix}")
|
||||
click.echo(f"Dependencies: {', '.join(meta.dependencies) or 'None'}")
|
||||
click.echo(f"Core Version: {meta.core_version}")
|
||||
click.echo("")
|
||||
|
||||
if state:
|
||||
status = click.style('Enabled', fg='green') if state.enabled else click.style('Disabled', fg='yellow')
|
||||
click.echo(f"Status: {status}")
|
||||
click.echo(f"Installed: {state.installed_at}")
|
||||
click.echo(f"Migrations: {len(state.migrations_applied)} applied")
|
||||
else:
|
||||
click.echo(f"Status: {click.style('Not installed', fg='white')}")
|
||||
|
||||
click.echo("")
|
||||
|
||||
|
||||
@plugin_cli.command('migrate')
|
||||
@click.argument('name')
|
||||
@click.option('--revision', default='head', help='Target revision')
|
||||
@with_appcontext
|
||||
def migrate_plugin(name: str, revision: str):
|
||||
"""Run migrations for a specific plugin."""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
if not pm.registry.is_installed(name):
|
||||
click.echo(click.style(f"Plugin {name} is not installed", fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f"Running migrations for {name}...")
|
||||
|
||||
if pm.migration_manager.run_plugin_migrations(name, revision):
|
||||
click.echo(click.style("Migrations completed", fg='green'))
|
||||
else:
|
||||
click.echo(click.style("Migration failed", fg='red'))
|
||||
raise SystemExit(1)
|
||||
174
shopdb/plugins/loader.py
Normal file
174
shopdb/plugins/loader.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Plugin discovery and loading."""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Type, Optional
|
||||
from flask import Flask
|
||||
import logging
|
||||
|
||||
from .base import BasePlugin
|
||||
from .registry import PluginRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""
|
||||
Discovers and loads plugins from the plugins directory.
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: Path, registry: PluginRegistry):
|
||||
self.plugins_dir = plugins_dir
|
||||
self.registry = registry
|
||||
self._loaded_plugins: Dict[str, BasePlugin] = {}
|
||||
self._plugin_classes: Dict[str, Type[BasePlugin]] = {}
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""
|
||||
Discover available plugins in plugins directory.
|
||||
Returns list of plugin names.
|
||||
"""
|
||||
available = []
|
||||
if not self.plugins_dir.exists():
|
||||
return available
|
||||
|
||||
for item in self.plugins_dir.iterdir():
|
||||
if item.is_dir() and (item / 'plugin.py').exists():
|
||||
available.append(item.name)
|
||||
elif item.is_dir() and (item / '__init__.py').exists():
|
||||
# Check for plugin.py in package
|
||||
if (item / 'plugin.py').exists():
|
||||
available.append(item.name)
|
||||
|
||||
return available
|
||||
|
||||
def load_plugin_class(self, name: str) -> Optional[Type[BasePlugin]]:
|
||||
"""
|
||||
Load plugin class without instantiating.
|
||||
Used for inspection before installation.
|
||||
"""
|
||||
if name in self._plugin_classes:
|
||||
return self._plugin_classes[name]
|
||||
|
||||
plugin_dir = self.plugins_dir / name
|
||||
plugin_module_path = plugin_dir / 'plugin.py'
|
||||
|
||||
if not plugin_module_path.exists():
|
||||
logger.error(f"Plugin {name} not found: {plugin_module_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Import the plugin module
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"plugins.{name}.plugin",
|
||||
plugin_module_path
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Find the plugin class
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (isinstance(attr, type) and
|
||||
issubclass(attr, BasePlugin) and
|
||||
attr is not BasePlugin):
|
||||
self._plugin_classes[name] = attr
|
||||
return attr
|
||||
|
||||
logger.error(f"No BasePlugin subclass found in {name}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading plugin {name}: {e}")
|
||||
return None
|
||||
|
||||
def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]:
|
||||
"""
|
||||
Load and instantiate a plugin.
|
||||
"""
|
||||
if name in self._loaded_plugins:
|
||||
return self._loaded_plugins[name]
|
||||
|
||||
plugin_class = self.load_plugin_class(name)
|
||||
if not plugin_class:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Instantiate plugin
|
||||
plugin = plugin_class()
|
||||
|
||||
# Check dependencies
|
||||
for dep in plugin.meta.dependencies:
|
||||
if not self.registry.is_enabled(dep):
|
||||
logger.error(
|
||||
f"Plugin {name} requires {dep} which is not enabled"
|
||||
)
|
||||
return None
|
||||
|
||||
# Initialize plugin
|
||||
plugin.init_app(app, db)
|
||||
|
||||
self._loaded_plugins[name] = plugin
|
||||
return plugin
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error instantiating plugin {name}: {e}")
|
||||
return None
|
||||
|
||||
def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]:
|
||||
"""
|
||||
Load all enabled plugins.
|
||||
Returns dict of name -> plugin instance.
|
||||
"""
|
||||
loaded = {}
|
||||
|
||||
# Sort by dependencies (simple topological sort)
|
||||
enabled = self.registry.get_enabled_plugins()
|
||||
sorted_plugins = self._sort_by_dependencies(enabled)
|
||||
|
||||
for name in sorted_plugins:
|
||||
plugin = self.load_plugin(name, app, db)
|
||||
if plugin:
|
||||
loaded[name] = plugin
|
||||
logger.info(f"Loaded plugin: {name} v{plugin.meta.version}")
|
||||
else:
|
||||
logger.warning(f"Failed to load plugin: {name}")
|
||||
|
||||
return loaded
|
||||
|
||||
def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]:
|
||||
"""Sort plugins so dependencies come first."""
|
||||
sorted_list = []
|
||||
visited = set()
|
||||
|
||||
def visit(name):
|
||||
if name in visited:
|
||||
return
|
||||
visited.add(name)
|
||||
|
||||
plugin_class = self.load_plugin_class(name)
|
||||
if plugin_class:
|
||||
# Create temporary instance to get meta
|
||||
try:
|
||||
temp = plugin_class()
|
||||
for dep in temp.meta.dependencies:
|
||||
if dep in plugin_names:
|
||||
visit(dep)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sorted_list.append(name)
|
||||
|
||||
for name in plugin_names:
|
||||
visit(name)
|
||||
|
||||
return sorted_list
|
||||
|
||||
def get_loaded_plugin(self, name: str) -> Optional[BasePlugin]:
|
||||
"""Get an already loaded plugin."""
|
||||
return self._loaded_plugins.get(name)
|
||||
|
||||
def get_all_loaded(self) -> Dict[str, BasePlugin]:
|
||||
"""Get all loaded plugins."""
|
||||
return self._loaded_plugins.copy()
|
||||
173
shopdb/plugins/migrations.py
Normal file
173
shopdb/plugins/migrations.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Plugin migration management using Alembic."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginMigrationManager:
|
||||
"""
|
||||
Manages database migrations for plugins.
|
||||
Each plugin has its own migrations directory.
|
||||
"""
|
||||
|
||||
def __init__(self, plugins_dir: Path, database_url: str):
|
||||
self.plugins_dir = plugins_dir
|
||||
self.database_url = database_url
|
||||
|
||||
def get_migrations_dir(self, plugin_name: str) -> Optional[Path]:
|
||||
"""Get migrations directory for a plugin."""
|
||||
migrations_dir = self.plugins_dir / plugin_name / 'migrations'
|
||||
if migrations_dir.exists():
|
||||
return migrations_dir
|
||||
return None
|
||||
|
||||
def run_plugin_migrations(
|
||||
self,
|
||||
plugin_name: str,
|
||||
revision: str = 'head'
|
||||
) -> bool:
|
||||
"""
|
||||
Run migrations for a plugin.
|
||||
|
||||
Uses flask db upgrade with the plugin's migrations directory.
|
||||
"""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
|
||||
if not migrations_dir:
|
||||
logger.info(f"No migrations directory for plugin {plugin_name}")
|
||||
return True # No migrations to run
|
||||
|
||||
try:
|
||||
# Use alembic directly with plugin's migrations
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
config.set_main_option('sqlalchemy.url', self.database_url)
|
||||
|
||||
# Use plugin-specific version table
|
||||
config.set_main_option(
|
||||
'version_table',
|
||||
f'alembic_version_{plugin_name}'
|
||||
)
|
||||
|
||||
command.upgrade(config, revision)
|
||||
logger.info(f"Migrations completed for {plugin_name}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
# Fallback to subprocess if alembic not available in context
|
||||
logger.warning("Using subprocess for migrations")
|
||||
return self._run_migrations_subprocess(plugin_name, revision)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed for {plugin_name}: {e}")
|
||||
return False
|
||||
|
||||
def _run_migrations_subprocess(
|
||||
self,
|
||||
plugin_name: str,
|
||||
revision: str = 'head'
|
||||
) -> bool:
|
||||
"""Run migrations via subprocess as fallback."""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, '-m', 'alembic',
|
||||
'-c', str(migrations_dir / 'alembic.ini'),
|
||||
'upgrade', revision
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={
|
||||
**dict(__import__('os').environ),
|
||||
'DATABASE_URL': self.database_url
|
||||
}
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Migration error: {result.stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration subprocess failed: {e}")
|
||||
return False
|
||||
|
||||
def downgrade_plugin(
|
||||
self,
|
||||
plugin_name: str,
|
||||
revision: str = 'base'
|
||||
) -> bool:
|
||||
"""
|
||||
Downgrade/rollback plugin migrations.
|
||||
"""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
|
||||
if not migrations_dir:
|
||||
return True
|
||||
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic import command
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
config.set_main_option('sqlalchemy.url', self.database_url)
|
||||
config.set_main_option(
|
||||
'version_table',
|
||||
f'alembic_version_{plugin_name}'
|
||||
)
|
||||
|
||||
command.downgrade(config, revision)
|
||||
logger.info(f"Downgrade completed for {plugin_name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Downgrade failed for {plugin_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_current_revision(self, plugin_name: str) -> Optional[str]:
|
||||
"""Get current migration revision for a plugin."""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return None
|
||||
|
||||
try:
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
config = Config()
|
||||
config.set_main_option('script_location', str(migrations_dir))
|
||||
|
||||
script = ScriptDirectory.from_config(config)
|
||||
return script.get_current_head()
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_pending_migrations(self, plugin_name: str) -> bool:
|
||||
"""Check if plugin has pending migrations."""
|
||||
# Simplified check - would need DB connection for full check
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir:
|
||||
return False
|
||||
|
||||
versions_dir = migrations_dir / 'versions'
|
||||
if not versions_dir.exists():
|
||||
return False
|
||||
|
||||
# Check if there are any migration files
|
||||
migration_files = list(versions_dir.glob('*.py'))
|
||||
return len(migration_files) > 0
|
||||
121
shopdb/plugins/registry.py
Normal file
121
shopdb/plugins/registry.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Plugin registry for tracking installed and enabled plugins."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginState:
|
||||
"""Persistent state for a plugin."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
installed_at: str
|
||||
enabled: bool = True
|
||||
migrations_applied: List[str] = field(default_factory=list)
|
||||
config: Dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
"""
|
||||
Manages plugin state persistence.
|
||||
Stores state in JSON file in instance folder.
|
||||
"""
|
||||
|
||||
def __init__(self, state_file: Path):
|
||||
self.state_file = state_file
|
||||
self._plugins: Dict[str, PluginState] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load registry from file."""
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
for name, state_data in data.get('plugins', {}).items():
|
||||
self._plugins[name] = PluginState(**state_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Corrupted file, start fresh
|
||||
self._plugins = {}
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save registry to file."""
|
||||
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump({
|
||||
'plugins': {
|
||||
name: asdict(state)
|
||||
for name, state in self._plugins.items()
|
||||
}
|
||||
}, f, indent=2)
|
||||
|
||||
def register(self, name: str, version: str) -> PluginState:
|
||||
"""Register a newly installed plugin."""
|
||||
state = PluginState(
|
||||
name=name,
|
||||
version=version,
|
||||
installed_at=datetime.utcnow().isoformat(),
|
||||
enabled=True
|
||||
)
|
||||
self._plugins[name] = state
|
||||
self._save()
|
||||
return state
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove plugin from registry."""
|
||||
if name in self._plugins:
|
||||
del self._plugins[name]
|
||||
self._save()
|
||||
|
||||
def get(self, name: str) -> Optional[PluginState]:
|
||||
"""Get plugin state."""
|
||||
return self._plugins.get(name)
|
||||
|
||||
def is_installed(self, name: str) -> bool:
|
||||
"""Check if plugin is installed."""
|
||||
return name in self._plugins
|
||||
|
||||
def is_enabled(self, name: str) -> bool:
|
||||
"""Check if plugin is enabled."""
|
||||
state = self._plugins.get(name)
|
||||
return state.enabled if state else False
|
||||
|
||||
def enable(self, name: str) -> None:
|
||||
"""Enable a plugin."""
|
||||
if name in self._plugins:
|
||||
self._plugins[name].enabled = True
|
||||
self._save()
|
||||
|
||||
def disable(self, name: str) -> None:
|
||||
"""Disable a plugin."""
|
||||
if name in self._plugins:
|
||||
self._plugins[name].enabled = False
|
||||
self._save()
|
||||
|
||||
def get_enabled_plugins(self) -> List[str]:
|
||||
"""Get list of enabled plugin names."""
|
||||
return [
|
||||
name for name, state in self._plugins.items()
|
||||
if state.enabled
|
||||
]
|
||||
|
||||
def add_migration(self, name: str, revision: str) -> None:
|
||||
"""Record that a migration was applied."""
|
||||
if name in self._plugins:
|
||||
if revision not in self._plugins[name].migrations_applied:
|
||||
self._plugins[name].migrations_applied.append(revision)
|
||||
self._save()
|
||||
|
||||
def get_all(self) -> Dict[str, PluginState]:
|
||||
"""Get all registered plugins."""
|
||||
return self._plugins.copy()
|
||||
|
||||
def update_config(self, name: str, config: Dict) -> None:
|
||||
"""Update plugin configuration."""
|
||||
if name in self._plugins:
|
||||
self._plugins[name].config.update(config)
|
||||
self._save()
|
||||
Reference in New Issue
Block a user