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:
399
shopdb/plugins/__init__.py
Normal file
399
shopdb/plugins/__init__.py
Normal file
@@ -0,0 +1,399 @@
|
||||
"""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
|
||||
# API prefixes already claimed by a registered plugin blueprint, to
|
||||
# detect two plugins overlapping on the same /api/... namespace.
|
||||
self._registered_prefixes: set = set()
|
||||
|
||||
def init_app(self, app: Flask, db) -> None:
|
||||
"""Initialize plugin manager with Flask app."""
|
||||
self._app = app
|
||||
self._db = db
|
||||
# Reset per-app so the prefix-uniqueness guard tracks only this app's
|
||||
# registrations (the manager is a process-wide singleton; tests build
|
||||
# multiple apps from it).
|
||||
self._registered_prefixes = set()
|
||||
|
||||
# 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 upgrade_all_plugins(self) -> Dict[str, str]:
|
||||
"""Run pending Alembic migrations for every discovered plugin.
|
||||
|
||||
Returns {plugin_name: 'ok'|'no-migrations'|<error str>}. Skips
|
||||
plugins with no migrations/ directory. Driven by the CLI
|
||||
(`flask plugin upgrade-all`), which every deploy runs after
|
||||
`flask db upgrade`. Each bundled plugin's chain begins with a
|
||||
stamp-only anchor (the core chain already built its tables); later
|
||||
per-plugin migrations extend that chain. See ADR-008. Idempotent.
|
||||
"""
|
||||
results: Dict[str, str] = {}
|
||||
if not self.migration_manager:
|
||||
return results
|
||||
plugin_names = list(self.registry.list_installed().keys()) \
|
||||
if hasattr(self.registry, 'list_installed') else []
|
||||
if not plugin_names:
|
||||
# Fall back to whatever the loader found on disk.
|
||||
plugin_names = self.loader.discover_plugins()
|
||||
for name in plugin_names:
|
||||
if not self.migration_manager.has_pending_migrations(name):
|
||||
results[name] = 'no-migrations'
|
||||
continue
|
||||
try:
|
||||
ok = self.migration_manager.run_plugin_migrations(name)
|
||||
results[name] = 'ok' if ok else 'failed'
|
||||
except Exception as ex:
|
||||
results[name] = f'error: {ex}'
|
||||
return results
|
||||
|
||||
def _register_plugin_components(self, plugin: BasePlugin) -> None:
|
||||
"""Register plugin's blueprint, models, CLI commands, etc."""
|
||||
# Register blueprint
|
||||
blueprint = plugin.get_blueprint()
|
||||
if blueprint:
|
||||
prefix = plugin.meta.api_prefix
|
||||
# Guard against two plugins claiming the same API prefix; Flask only
|
||||
# rejects duplicate blueprint names, not overlapping url_prefixes, so
|
||||
# an overlap would silently shadow routes.
|
||||
if prefix in self._registered_prefixes:
|
||||
raise ValueError(
|
||||
f"Plugin {plugin.meta.name} api_prefix '{prefix}' is already "
|
||||
f"claimed by another blueprint"
|
||||
)
|
||||
self._app.register_blueprint(blueprint, url_prefix=prefix)
|
||||
self._registered_prefixes.add(prefix)
|
||||
logger.debug(f"Registered blueprint: {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)
|
||||
|
||||
try:
|
||||
config_schema = temp.get_config_schema()
|
||||
except Exception:
|
||||
config_schema = []
|
||||
try:
|
||||
provisioning_note = temp.get_provisioning_note()
|
||||
except Exception:
|
||||
provisioning_note = None
|
||||
manifest = self.loader.load_manifest(name)
|
||||
available.append({
|
||||
'name': meta.name,
|
||||
# Human label for UIs. Manifest display_name wins; else
|
||||
# title-case the machine name (right for most, e.g.
|
||||
# "computers" -> "Computers").
|
||||
'displayname': (manifest.get('display_name')
|
||||
or meta.name.replace('_', ' ').title()),
|
||||
'version': meta.version,
|
||||
'description': meta.description,
|
||||
'author': meta.author,
|
||||
'dependencies': meta.dependencies,
|
||||
'installed': state is not None,
|
||||
'enabled': state.enabled if state else False,
|
||||
'installedat': state.installed_at if state else None,
|
||||
'config_schema': config_schema,
|
||||
'provisioning_note': provisioning_note,
|
||||
'default_enabled': manifest.get('default_enabled', True),
|
||||
})
|
||||
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
|
||||
|
||||
# Read metadata from the manifest (single source of truth) instead of
|
||||
# instantiating the plugin class just to inspect deps/version.
|
||||
manifest = self.loader.load_manifest(name)
|
||||
if not manifest:
|
||||
logger.error(f"Plugin {name} not found")
|
||||
return False
|
||||
manifest_version = manifest.get('version')
|
||||
|
||||
# Check dependencies
|
||||
for dep in manifest.get('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
|
||||
# Plugins that provision extra tables install disabled until a site
|
||||
# opts in (manifest default_enabled=false).
|
||||
self.registry.register(name, manifest_version,
|
||||
enabled=manifest.get('default_enabled', True))
|
||||
|
||||
# Load the plugin
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._register_plugin_components(plugin)
|
||||
self._seed_plugin_permissions(plugin)
|
||||
plugin.on_install(self._app)
|
||||
|
||||
logger.info(f"Installed plugin: {name} v{manifest_version}")
|
||||
return True
|
||||
|
||||
def _seed_plugin_permissions(self, plugin: BasePlugin) -> None:
|
||||
"""Idempotently create Permission rows for a plugin's declared perms.
|
||||
|
||||
Runs at install and enable so a fresh plugin's RBAC permissions exist
|
||||
without a separate `flask seed permissions` pass. Best-effort: a plugin
|
||||
that raises must not abort the lifecycle."""
|
||||
try:
|
||||
entries = plugin.get_permissions() or []
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"get_permissions failed for %s", plugin.meta.name)
|
||||
return
|
||||
if not entries:
|
||||
return
|
||||
from shopdb.core.models import Permission
|
||||
with self._app.app_context():
|
||||
created = Permission.seed_entries(entries)
|
||||
if created:
|
||||
self._db.session.commit()
|
||||
logger.info(
|
||||
"Seeded %d permission(s) for plugin %s",
|
||||
created, plugin.meta.name)
|
||||
|
||||
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. Read deps from the manifest, not by
|
||||
# instantiating the plugin class (manifest is the single source of
|
||||
# truth; instantiating fires __init__ side effects unnecessarily).
|
||||
manifest = self.loader.load_manifest(name)
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_enabled(dep):
|
||||
logger.error(f"Cannot enable {name}: {dep} is not enabled")
|
||||
return False
|
||||
|
||||
self.registry.enable(name)
|
||||
|
||||
# Surface (do not auto-apply) a plugin chain that is ahead of the DB, so
|
||||
# an operator enabling a plugin knows to run `flask plugin upgrade-all`.
|
||||
try:
|
||||
if self.migration_manager and \
|
||||
self.migration_manager.has_unapplied_migrations(name):
|
||||
logger.warning(
|
||||
f"Plugin {name} has unapplied migrations; "
|
||||
f"run 'flask plugin upgrade-all'"
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(f"Could not check migration state for {name}")
|
||||
|
||||
# Fire the on_enable hook best-effort. Do NOT register the blueprint
|
||||
# here: Flask forbids register_blueprint after the first request, so
|
||||
# routes/nav for a re-enabled plugin take effect on the next restart
|
||||
# (symmetric with disable).
|
||||
try:
|
||||
plugin = self.loader.load_plugin(name, self._app, self._db)
|
||||
if plugin:
|
||||
self._seed_plugin_permissions(plugin)
|
||||
plugin.on_enable(self._app)
|
||||
except Exception:
|
||||
logger.exception(f"on_enable hook failed for plugin {name}")
|
||||
|
||||
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()
|
||||
|
||||
def get_service(self, name: str):
|
||||
"""Resolve a service exposed by an enabled plugin via get_services().
|
||||
|
||||
Consumer for the BasePlugin.get_services hook: searches enabled plugins
|
||||
for one that registers `name` and returns the registered value (a service
|
||||
class or factory). Returns None if no enabled plugin provides it. This is
|
||||
how one plugin obtains another's service (e.g. the Zabbix service).
|
||||
"""
|
||||
for plugin_name, plugin in self.get_all_plugins().items():
|
||||
if not self.registry.is_enabled(plugin_name):
|
||||
continue
|
||||
try:
|
||||
services = plugin.get_services() or {}
|
||||
except Exception:
|
||||
continue
|
||||
if name in services:
|
||||
return services[name]
|
||||
return None
|
||||
|
||||
|
||||
# Global plugin manager instance
|
||||
plugin_manager = PluginManager()
|
||||
166
shopdb/plugins/alembic_template.py
Normal file
166
shopdb/plugins/alembic_template.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Shared Alembic env.py logic for bundled plugins.
|
||||
|
||||
Every bundled plugin that owns tables (computers, employees, knowledgebase,
|
||||
machines, network, notifications, printers, slides, usb, warranty) has a
|
||||
`migrations/env.py` that does the minimum:
|
||||
|
||||
import os
|
||||
os.environ['PLUGIN_NAME'] = 'computers'
|
||||
from shopdb.plugins.alembic_template import run_migrations
|
||||
run_migrations()
|
||||
|
||||
This module wires the plugin's models into a MetaData object filtered to
|
||||
only the tables that belong to that plugin, then runs Alembic in either
|
||||
offline or online mode against the Flask app's configured engine.
|
||||
|
||||
Ownership cutover (see ADR-008): the core Alembic chain created every table
|
||||
that exists through its head (`7d16_directoryemployees`), including the plugin
|
||||
tables. Each plugin's `0001` migration is therefore a stamp-only no-op that
|
||||
just records the anchor revision in `alembic_version_<plugin>`. NEW plugin
|
||||
schema changes land as `plugins/<name>/migrations/000N` from here on, never in
|
||||
the core chain.
|
||||
|
||||
Plugin tables must be importable via `plugins.<name>.models`. Plugins
|
||||
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
|
||||
filter is explicit (avoids depending on import-side-effect global state).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import MetaData, pool
|
||||
|
||||
logger = logging.getLogger('alembic.env.plugin')
|
||||
|
||||
# Explicit table-ownership map. Adding tables to a plugin requires updating
|
||||
# this dict so the per-plugin migration knows which tables to include.
|
||||
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'computers': ('computertypes', 'computers', 'computerinstalledapps',
|
||||
'accessprotocols', 'computeraccess'),
|
||||
'employees': ('directoryemployees',),
|
||||
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
'manifestpublishedversions', 'manifestpayloads',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
}
|
||||
|
||||
|
||||
def _get_plugin_metadata(plugin_name: str) -> MetaData:
|
||||
"""Import the plugin's models and return a MetaData containing only its
|
||||
declared tables (filtered via PLUGIN_TABLE_OWNERS)."""
|
||||
owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ()))
|
||||
if not owned:
|
||||
raise RuntimeError(
|
||||
f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. "
|
||||
f"Update shopdb/plugins/alembic_template.py."
|
||||
)
|
||||
|
||||
# Importing models attaches them to the global db.metadata.
|
||||
importlib.import_module(f'plugins.{plugin_name}.models')
|
||||
from shopdb.extensions import db
|
||||
full = db.metadata
|
||||
|
||||
plugin_md = MetaData()
|
||||
for table in list(full.tables.values()):
|
||||
if table.name in owned:
|
||||
table.to_metadata(plugin_md)
|
||||
return plugin_md
|
||||
|
||||
|
||||
def create_plugin_tables(plugin_name: str):
|
||||
"""Emit CreateTable DDL for every table this plugin owns. Idempotent
|
||||
via Alembic's batch_op.create_table behavior (raises if exists; the
|
||||
baseline migration is meant to run against an empty schema).
|
||||
|
||||
Called from each plugin's 0001_baseline.py upgrade() so the table
|
||||
definitions stay sourced from the SQLAlchemy models rather than being
|
||||
duplicated in handwritten Alembic ops.
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
md = _get_plugin_metadata(plugin_name)
|
||||
bind = op.get_bind()
|
||||
# Sort by FK dependency so parent tables are created first.
|
||||
for table in md.sorted_tables:
|
||||
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
|
||||
|
||||
|
||||
def drop_plugin_tables(plugin_name: str):
|
||||
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
|
||||
order."""
|
||||
from alembic import op
|
||||
|
||||
md = _get_plugin_metadata(plugin_name)
|
||||
for table in reversed(md.sorted_tables):
|
||||
op.execute(f'DROP TABLE IF EXISTS "{table.name}"')
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Entry point called by each plugin's migrations/env.py."""
|
||||
plugin_name = os.environ.get('PLUGIN_NAME')
|
||||
if not plugin_name:
|
||||
raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()")
|
||||
|
||||
config = context.config
|
||||
target_metadata = _get_plugin_metadata(plugin_name)
|
||||
|
||||
# Per-plugin version table so each plugin's chain is independent of core
|
||||
# Alembic's alembic_version table.
|
||||
version_table = f'alembic_version_{plugin_name}'
|
||||
|
||||
db_url = config.get_main_option('sqlalchemy.url')
|
||||
if not db_url:
|
||||
# Pull from the Flask app config if running inside an app context
|
||||
# (e.g. via flask plugin migrate <name>).
|
||||
try:
|
||||
from flask import current_app
|
||||
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
|
||||
config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%'))
|
||||
except Exception as ex:
|
||||
raise RuntimeError(
|
||||
"sqlalchemy.url not set and no Flask app context available. "
|
||||
f"Original error: {ex}"
|
||||
)
|
||||
|
||||
if context.is_offline_mode():
|
||||
context.configure(
|
||||
url=db_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
version_table=version_table,
|
||||
include_schemas=False,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
else:
|
||||
# Build the engine straight from the resolved URL. The plugin manager
|
||||
# drives this via a programmatic alembic Config (no ini file), so
|
||||
# config.get_section returns an empty dict and engine_from_config would
|
||||
# find no sqlalchemy.url. db_url is already resolved above.
|
||||
from sqlalchemy import create_engine
|
||||
connectable = create_engine(db_url, poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
version_table=version_table,
|
||||
include_schemas=False,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
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 []
|
||||
281
shopdb/plugins/cli.py
Normal file
281
shopdb/plugins/cli.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""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'))
|
||||
# Nudge the operator if the plugin's chain is ahead of the DB.
|
||||
try:
|
||||
if pm.migration_manager and \
|
||||
pm.migration_manager.has_unapplied_migrations(name):
|
||||
click.echo(click.style(
|
||||
f" {name} has unapplied migrations - "
|
||||
f"run 'flask plugin upgrade-all'", fg='yellow'))
|
||||
except Exception:
|
||||
pass
|
||||
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('new')
|
||||
@click.argument('name')
|
||||
@click.option('--description', default='', help='One-sentence plugin description')
|
||||
@click.option('--overwrite', is_flag=True, help='Overwrite existing plugin directory')
|
||||
@with_appcontext
|
||||
def new_plugin(name: str, description: str, overwrite: bool):
|
||||
"""Scaffold a new plugin from the bundled templates.
|
||||
|
||||
Usage: flask plugin new cameras --description "Tracks shop-floor cameras"
|
||||
"""
|
||||
from pathlib import Path
|
||||
from .scaffolder import scaffold_plugin, ScaffoldError
|
||||
|
||||
plugins_dir = Path(current_app.root_path).parent / 'plugins'
|
||||
|
||||
if not description:
|
||||
description = f'{name.capitalize()} plugin (TODO: replace this description)'
|
||||
|
||||
try:
|
||||
target = scaffold_plugin(
|
||||
name=name,
|
||||
description=description,
|
||||
plugins_dir=plugins_dir,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
except ScaffoldError as e:
|
||||
click.echo(click.style(f'Scaffold failed: {e}', fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(click.style(f'Created plugin at {target}', fg='green'))
|
||||
click.echo('')
|
||||
click.echo('Next steps:')
|
||||
click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields')
|
||||
click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints')
|
||||
click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that')
|
||||
click.echo(f' creates your tables (per-plugin chain, NOT the core chain;')
|
||||
click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.')
|
||||
click.echo(f' 4. Run: flask plugin install {name}')
|
||||
click.echo(f' 5. Run: flask plugin upgrade-all')
|
||||
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@plugin_cli.command('upgrade-all')
|
||||
@with_appcontext
|
||||
def upgrade_all_plugins():
|
||||
"""Run pending migrations for every discovered plugin.
|
||||
|
||||
Idempotent. Run this after `flask db upgrade` on every deploy and
|
||||
upgrade. It stamps each bundled plugin's anchor revision into
|
||||
alembic_version_<plugin> and applies any per-plugin migrations added
|
||||
after the ownership cutover (ADR-008). Safe to re-run at head.
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
click.echo(click.style("Plugin manager not initialized", fg='red'))
|
||||
raise SystemExit(1)
|
||||
results = pm.upgrade_all_plugins()
|
||||
if not results:
|
||||
click.echo("No plugins discovered.")
|
||||
return
|
||||
for name, status in sorted(results.items()):
|
||||
if status == 'ok':
|
||||
click.echo(click.style(f" {name:20} ok", fg='green'))
|
||||
elif status == 'no-migrations':
|
||||
click.echo(click.style(f" {name:20} no migrations", fg='yellow'))
|
||||
else:
|
||||
click.echo(click.style(f" {name:20} {status}", fg='red'))
|
||||
276
shopdb/plugins/loader.py
Normal file
276
shopdb/plugins/loader.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Plugin discovery and loading.
|
||||
|
||||
The loader reads each plugin's manifest.json before instantiating the
|
||||
plugin class. Dependency sorting and contract-version compatibility
|
||||
checks operate on manifests, not plugin instances. The plugin class is
|
||||
imported and instantiated only after the manifest passes validation.
|
||||
|
||||
Failure policy (per the enforcing-plugin-contract skill):
|
||||
- In dev/test (app.config.DEBUG or TESTING true): re-raise the original
|
||||
exception so failures are loud.
|
||||
- In production: log the failure with full context and exclude the
|
||||
plugin from registration. The framework keeps booting.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Type, Optional
|
||||
|
||||
from flask import Flask
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
from .base import BasePlugin
|
||||
from .registry import PluginRegistry
|
||||
from ..exceptions import (
|
||||
PluginError,
|
||||
PluginNotFoundError,
|
||||
PluginContractError,
|
||||
PluginVersionError,
|
||||
PluginDependencyError,
|
||||
)
|
||||
|
||||
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]] = {}
|
||||
self._manifests: Dict[str, dict] = {}
|
||||
|
||||
def _is_strict_mode(self, app: Optional[Flask]) -> bool:
|
||||
"""Return True if loader should re-raise instead of isolating failures."""
|
||||
if app is None:
|
||||
return True
|
||||
return bool(app.config.get('DEBUG') or app.config.get('TESTING'))
|
||||
|
||||
def _handle_failure(self, app: Optional[Flask], exc: Exception, name: str) -> None:
|
||||
"""In strict mode re-raise; in production log with context and continue."""
|
||||
if self._is_strict_mode(app):
|
||||
raise exc
|
||||
logger.exception(f'Plugin {name} failed to load: {exc}')
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
"""Discover available plugins in plugins directory."""
|
||||
available = []
|
||||
if not self.plugins_dir.exists():
|
||||
return available
|
||||
|
||||
for item in self.plugins_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if (item / 'plugin.py').exists():
|
||||
available.append(item.name)
|
||||
|
||||
return available
|
||||
|
||||
def load_manifest(self, name: str) -> dict:
|
||||
"""Load the plugin's manifest.json.
|
||||
|
||||
Raises PluginNotFoundError if the manifest does not exist or is
|
||||
unparseable. The result is cached.
|
||||
"""
|
||||
if name in self._manifests:
|
||||
return self._manifests[name]
|
||||
|
||||
manifest_path = self.plugins_dir / name / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} has no manifest.json at {manifest_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
with open(manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} has invalid manifest.json: {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
for required in ('name', 'version', 'description'):
|
||||
if not manifest.get(required):
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} manifest.json missing required field "{required}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
if manifest['name'] != name:
|
||||
raise PluginContractError(
|
||||
f'Plugin directory "{name}" does not match manifest name "{manifest["name"]}"',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
self._manifests[name] = manifest
|
||||
return manifest
|
||||
|
||||
def check_contract_version(self, name: str, contract_version: str) -> None:
|
||||
"""Verify the plugin's core_version range includes contract_version.
|
||||
|
||||
Raises PluginVersionError on mismatch.
|
||||
"""
|
||||
manifest = self.load_manifest(name)
|
||||
core_version_spec = manifest.get('core_version', '')
|
||||
|
||||
if not core_version_spec:
|
||||
return
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(core_version_spec)
|
||||
framework_version = Version(contract_version)
|
||||
except (InvalidSpecifier, InvalidVersion) as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} has invalid version specifier "{core_version_spec}": {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
if framework_version not in specifier:
|
||||
raise PluginVersionError(
|
||||
f'Plugin {name} requires core_version {core_version_spec} '
|
||||
f'but framework is at {contract_version}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
def load_plugin_class(self, name: str) -> Type[BasePlugin]:
|
||||
"""Import the plugin's plugin.py and return the BasePlugin subclass.
|
||||
|
||||
Raises PluginNotFoundError if plugin.py is missing.
|
||||
Raises PluginContractError if the module has no BasePlugin subclass
|
||||
or import fails.
|
||||
"""
|
||||
if name in self._plugin_classes:
|
||||
return self._plugin_classes[name]
|
||||
|
||||
plugin_module_path = self.plugins_dir / name / 'plugin.py'
|
||||
if not plugin_module_path.exists():
|
||||
raise PluginNotFoundError(
|
||||
f'Plugin {name} plugin.py not found at {plugin_module_path}',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
try:
|
||||
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)
|
||||
except Exception as e:
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} import failed: {e}',
|
||||
plugin_name=name,
|
||||
) from e
|
||||
|
||||
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
|
||||
|
||||
raise PluginContractError(
|
||||
f'Plugin {name} module has no BasePlugin subclass',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
def load_plugin(self, name: str, app: Flask, db) -> Optional[BasePlugin]:
|
||||
"""Load and instantiate a plugin.
|
||||
|
||||
Returns the plugin instance on success. In strict mode (dev/test),
|
||||
any failure raises. In production, returns None and logs the
|
||||
failure with full context.
|
||||
"""
|
||||
if name in self._loaded_plugins:
|
||||
return self._loaded_plugins[name]
|
||||
|
||||
try:
|
||||
from shopdb import __contract_version__
|
||||
|
||||
manifest = self.load_manifest(name)
|
||||
self.check_contract_version(name, __contract_version__)
|
||||
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if not self.registry.is_enabled(dep):
|
||||
raise PluginDependencyError(
|
||||
f'Plugin {name} requires {dep} which is not enabled',
|
||||
plugin_name=name,
|
||||
)
|
||||
|
||||
plugin_class = self.load_plugin_class(name)
|
||||
plugin = plugin_class()
|
||||
plugin.init_app(app, db)
|
||||
|
||||
self._loaded_plugins[name] = plugin
|
||||
return plugin
|
||||
|
||||
except PluginError as e:
|
||||
self._handle_failure(app, e, name)
|
||||
return None
|
||||
except Exception as e:
|
||||
wrapped = PluginError(
|
||||
f'Unexpected failure loading plugin {name}: {e}',
|
||||
plugin_name=name,
|
||||
)
|
||||
self._handle_failure(app, wrapped, name)
|
||||
return None
|
||||
|
||||
def load_enabled_plugins(self, app: Flask, db) -> Dict[str, BasePlugin]:
|
||||
"""Load all enabled plugins in dependency order."""
|
||||
loaded = {}
|
||||
|
||||
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}')
|
||||
|
||||
return loaded
|
||||
|
||||
def _sort_by_dependencies(self, plugin_names: List[str]) -> List[str]:
|
||||
"""Sort plugins so dependencies come first.
|
||||
|
||||
Reads dependencies from manifest.json directly; does not
|
||||
instantiate plugin classes during sort.
|
||||
"""
|
||||
sorted_list = []
|
||||
visited = set()
|
||||
|
||||
def visit(name):
|
||||
if name in visited:
|
||||
return
|
||||
visited.add(name)
|
||||
|
||||
try:
|
||||
manifest = self.load_manifest(name)
|
||||
for dep in manifest.get('dependencies', []):
|
||||
if dep in plugin_names:
|
||||
visit(dep)
|
||||
except PluginError:
|
||||
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()
|
||||
207
shopdb/plugins/migrations.py
Normal file
207
shopdb/plugins/migrations.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""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 any migration scripts on disk.
|
||||
|
||||
File-level check only (no DB): True when the plugin ships a
|
||||
migrations/versions dir with at least one script. Used by
|
||||
upgrade-all to decide whether the plugin participates in the
|
||||
per-plugin Alembic flow at all.
|
||||
"""
|
||||
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
|
||||
|
||||
# Any migration files on disk?
|
||||
migration_files = list(versions_dir.glob('*.py'))
|
||||
return len(migration_files) > 0
|
||||
|
||||
def get_applied_revision(self, plugin_name: str) -> Optional[str]:
|
||||
"""Return the revision stamped in alembic_version_<plugin> in the DB,
|
||||
or None if the plugin chain has never been stamped (or on any error)."""
|
||||
migrations_dir = self.get_migrations_dir(plugin_name)
|
||||
if not migrations_dir or not self.database_url:
|
||||
return None
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
|
||||
engine = create_engine(self.database_url)
|
||||
with engine.connect() as connection:
|
||||
context = MigrationContext.configure(
|
||||
connection,
|
||||
opts={'version_table': f'alembic_version_{plugin_name}'},
|
||||
)
|
||||
return context.get_current_revision()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def has_unapplied_migrations(self, plugin_name: str) -> bool:
|
||||
"""True when the plugin's on-disk chain head is ahead of what the DB
|
||||
has stamped. Compares the script head against alembic_version_<plugin>.
|
||||
Best-effort: returns False when it cannot tell (no chain / no DB)."""
|
||||
head = self.get_current_revision(plugin_name) # script head on disk
|
||||
if not head:
|
||||
return False
|
||||
return self.get_applied_revision(plugin_name) != head
|
||||
140
shopdb/plugins/registry.py
Normal file
140
shopdb/plugins/registry.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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, timezone
|
||||
|
||||
|
||||
@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 = {}
|
||||
self._migrate_renamed_plugins()
|
||||
|
||||
def _migrate_renamed_plugins(self) -> None:
|
||||
"""Upgrade path for the equipment -> machines plugin rename.
|
||||
|
||||
Existing installs carry an 'equipment' entry in plugins.json. When the
|
||||
renamed plugins/machines directory exists and no 'machines' entry does,
|
||||
carry the state over under the new name and persist.
|
||||
"""
|
||||
if 'equipment' not in self._plugins or 'machines' in self._plugins:
|
||||
return
|
||||
machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines'
|
||||
if not machines_dir.exists():
|
||||
return
|
||||
state = self._plugins.pop('equipment')
|
||||
state.name = 'machines'
|
||||
self._plugins['machines'] = state
|
||||
self._save()
|
||||
|
||||
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, enabled: bool = True) -> PluginState:
|
||||
"""Register a newly installed plugin. enabled=False leaves it off until a
|
||||
site opts in (used for plugins that provision extra tables)."""
|
||||
state = PluginState(
|
||||
name=name,
|
||||
version=version,
|
||||
installed_at=datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
|
||||
enabled=enabled
|
||||
)
|
||||
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()
|
||||
236
shopdb/plugins/scaffolder.py
Normal file
236
shopdb/plugins/scaffolder.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""Plugin scaffolder.
|
||||
|
||||
Generates a new plugin skeleton from the templates under
|
||||
shopdb/plugins/templates/. The generated plugin satisfies the framework
|
||||
contract out of the box; tests/test_plugin_scaffold.py is the canary
|
||||
that this stays true as the contract evolves.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Optional
|
||||
|
||||
|
||||
TEMPLATE_ROOT = Path(__file__).parent / 'templates'
|
||||
|
||||
VALID_NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*$')
|
||||
|
||||
RESERVED_NAMES = {
|
||||
'plugin', 'plugins', 'shopdb', 'core', 'api', 'tests', 'templates',
|
||||
'schemas', 'models', 'frontend', 'docs', 'migrations', 'scripts',
|
||||
}
|
||||
|
||||
|
||||
class ScaffoldError(Exception):
|
||||
"""Raised when scaffolding cannot proceed."""
|
||||
|
||||
|
||||
def validate_name(name: str) -> None:
|
||||
"""Validate plugin name against CONTRIBUTING.md and reserved list.
|
||||
|
||||
Raises ScaffoldError on any violation.
|
||||
"""
|
||||
if not name:
|
||||
raise ScaffoldError('Plugin name is required')
|
||||
|
||||
if not VALID_NAME_PATTERN.match(name):
|
||||
raise ScaffoldError(
|
||||
f'Plugin name "{name}" must be lowercase letters and digits only, '
|
||||
f'starting with a letter (no hyphens, underscores, or special chars). '
|
||||
f'See CONTRIBUTING.md for the naming convention.'
|
||||
)
|
||||
|
||||
if name in RESERVED_NAMES:
|
||||
raise ScaffoldError(
|
||||
f'Plugin name "{name}" is reserved. Pick a different name.'
|
||||
)
|
||||
|
||||
|
||||
def pascal_case(name: str) -> str:
|
||||
"""Convert lowercase plugin name to PascalCase class name.
|
||||
|
||||
'cameras' -> 'Cameras'. The convention assumes single-word lowercase
|
||||
plugin names per the naming rules in CONTRIBUTING.md, so this is
|
||||
just title-casing.
|
||||
"""
|
||||
return name[:1].upper() + name[1:]
|
||||
|
||||
|
||||
def _render_template(
|
||||
template_path: Path,
|
||||
out_path: Path,
|
||||
substitutions: dict,
|
||||
overwrite: bool,
|
||||
) -> bool:
|
||||
"""Render one template to out_path.
|
||||
|
||||
Skips silently when out_path already exists and overwrite is False, so a
|
||||
scaffold never clobbers a file the author has already edited. Returns True
|
||||
when the file was written, False when it was skipped.
|
||||
"""
|
||||
if out_path.exists() and not overwrite:
|
||||
return False
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
body = template_path.read_text()
|
||||
out_path.write_text(Template(body).safe_substitute(substitutions))
|
||||
return True
|
||||
|
||||
|
||||
def _scaffold_frontend(
|
||||
name: str,
|
||||
substitutions: dict,
|
||||
plugin_target: Path,
|
||||
frontend_dir: Path,
|
||||
template_root: Path,
|
||||
overwrite: bool,
|
||||
) -> None:
|
||||
"""Render the frontend starting points for a scaffolded plugin.
|
||||
|
||||
Writes the paste-in api-client snippet into the plugin directory, then the
|
||||
Vue views and the router route file into the real frontend tree. The
|
||||
snippet is emitted regardless of whether the frontend tree exists, because
|
||||
it is a plugin-directory artifact useful even for external-repo plugins.
|
||||
Views and the route file are skipped when frontend_dir is missing, which is
|
||||
the normal case for a plugin developed in its own repository.
|
||||
|
||||
Ordering matters: every view is written before the route file. A route file
|
||||
that lazy-imports a view that is not on disk crashes the Vite dev server,
|
||||
so the views must land first.
|
||||
"""
|
||||
fe_templates = template_root / 'frontend'
|
||||
if not fe_templates.exists():
|
||||
return
|
||||
|
||||
plugin_name = substitutions['Name']
|
||||
|
||||
# snippet lands in the plugin dir; author pastes it into api/index.js
|
||||
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
||||
if snippet_template.exists():
|
||||
_render_template(
|
||||
snippet_template,
|
||||
plugin_target / 'frontend-api-snippet.js',
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
# views and route need the real frontend tree; external repos skip these
|
||||
if not frontend_dir.exists():
|
||||
return
|
||||
|
||||
views_dir = frontend_dir / 'views' / name
|
||||
|
||||
# views first: route file lazy-imports them, missing views 500 vite
|
||||
view_templates = {
|
||||
'List.vue.tmpl': f'{plugin_name}List.vue',
|
||||
'Detail.vue.tmpl': f'{plugin_name}Detail.vue',
|
||||
'Form.vue.tmpl': f'{plugin_name}Form.vue',
|
||||
}
|
||||
for template_name, out_name in view_templates.items():
|
||||
template_path = fe_templates / 'views' / template_name
|
||||
if template_path.exists():
|
||||
_render_template(
|
||||
template_path,
|
||||
views_dir / out_name,
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
# route file last: all referenced views now exist on disk
|
||||
route_template = fe_templates / 'routes.js.tmpl'
|
||||
if route_template.exists():
|
||||
_render_template(
|
||||
route_template,
|
||||
frontend_dir / 'router' / 'routes' / f'{name}.js',
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
|
||||
|
||||
def scaffold_plugin(
|
||||
name: str,
|
||||
description: str,
|
||||
plugins_dir: Path,
|
||||
template_root: Optional[Path] = None,
|
||||
overwrite: bool = False,
|
||||
frontend: bool = True,
|
||||
frontend_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Generate a new plugin from templates.
|
||||
|
||||
Args:
|
||||
name: Plugin name (lowercase, single word)
|
||||
description: One-sentence description for manifest.json + README
|
||||
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
|
||||
template_root: Override template source dir (default: bundled templates)
|
||||
overwrite: If True, overwrite an existing plugin directory and any
|
||||
existing generated frontend files
|
||||
frontend: If True, also render the Vue frontend starting points (list,
|
||||
detail, form views, a route file, and a paste-in api-client snippet)
|
||||
frontend_dir: Frontend src directory to render views/routes into
|
||||
(default: <plugins_dir>/../frontend/src). Views and the route file
|
||||
are skipped when this directory does not exist, which is the normal
|
||||
case for a plugin developed in its own repository.
|
||||
|
||||
Returns:
|
||||
Path to the generated plugin directory.
|
||||
|
||||
Raises:
|
||||
ScaffoldError on validation failure or when target exists and
|
||||
overwrite is False.
|
||||
"""
|
||||
validate_name(name)
|
||||
|
||||
template_root = template_root or TEMPLATE_ROOT
|
||||
if not template_root.exists():
|
||||
raise ScaffoldError(f'Template root not found: {template_root}')
|
||||
|
||||
target = plugins_dir / name
|
||||
|
||||
if target.exists():
|
||||
if not overwrite:
|
||||
raise ScaffoldError(
|
||||
f'Plugin directory already exists: {target}. '
|
||||
f'Pass overwrite=True or remove it first.'
|
||||
)
|
||||
|
||||
substitutions = {
|
||||
'name': name,
|
||||
'Name': pascal_case(name),
|
||||
'description': description,
|
||||
}
|
||||
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for template_path in template_root.rglob('*.tmpl'):
|
||||
rel = template_path.relative_to(template_root)
|
||||
|
||||
# frontend templates render into the frontend tree, not the plugin dir
|
||||
if rel.parts and rel.parts[0] == 'frontend':
|
||||
continue
|
||||
|
||||
out_rel_str = str(rel.with_suffix(''))
|
||||
if 'model.py' in out_rel_str:
|
||||
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
|
||||
|
||||
out_path = target / out_rel_str
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
body = template_path.read_text()
|
||||
rendered = Template(body).safe_substitute(substitutions)
|
||||
|
||||
out_path.write_text(rendered)
|
||||
|
||||
if frontend:
|
||||
if frontend_dir is None:
|
||||
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
|
||||
_scaffold_frontend(
|
||||
name=name,
|
||||
substitutions=substitutions,
|
||||
plugin_target=target,
|
||||
frontend_dir=Path(frontend_dir),
|
||||
template_root=template_root,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
return target
|
||||
43
shopdb/plugins/templates/README.md.tmpl
Normal file
43
shopdb/plugins/templates/README.md.tmpl
Normal file
@@ -0,0 +1,43 @@
|
||||
# $Name plugin
|
||||
|
||||
$description
|
||||
|
||||
This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain.
|
||||
|
||||
## What's here
|
||||
|
||||
- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data.
|
||||
- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields.
|
||||
- `api/routes.py` - example list and detail endpoints. Add CRUD as needed.
|
||||
- `schemas/__init__.py` - marshmallow schema stub for request/response validation.
|
||||
- `tests/test_plugin.py` - smoke tests asserting contract compliance.
|
||||
- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad.
|
||||
|
||||
## Common edits
|
||||
|
||||
| You want to... | Do this |
|
||||
|---|---|
|
||||
| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See the PLUGIN-HOOKS wiki page. |
|
||||
| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. |
|
||||
| Add another model | Create `models/<other>.py`, export it in `models/__init__.py`, return it in `get_models()`. |
|
||||
| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. |
|
||||
|
||||
## Frontend
|
||||
|
||||
Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point.
|
||||
|
||||
## Install and run
|
||||
|
||||
```bash
|
||||
flask plugin install $name
|
||||
flask db migrate -m "Add $name plugin tables"
|
||||
flask db upgrade
|
||||
pytest plugins/$name/tests/
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- the PLUGIN-HOOKS wiki page - canonical hook reference
|
||||
- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough
|
||||
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
|
||||
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
|
||||
5
shopdb/plugins/templates/__init__.py.tmpl
Normal file
5
shopdb/plugins/templates/__init__.py.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
"""$Name plugin package."""
|
||||
|
||||
from .plugin import ${Name}Plugin
|
||||
|
||||
__all__ = ['${Name}Plugin']
|
||||
5
shopdb/plugins/templates/api/__init__.py.tmpl
Normal file
5
shopdb/plugins/templates/api/__init__.py.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
"""$Name plugin API package."""
|
||||
|
||||
from .routes import ${name}_bp
|
||||
|
||||
__all__ = ['${name}_bp']
|
||||
45
shopdb/plugins/templates/api/routes.py.tmpl
Normal file
45
shopdb/plugins/templates/api/routes.py.tmpl
Normal file
@@ -0,0 +1,45 @@
|
||||
"""$Name plugin API routes."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
|
||||
from ..models import $Name
|
||||
|
||||
|
||||
${name}_bp = Blueprint('$name', __name__)
|
||||
|
||||
|
||||
@${name}_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_${name}():
|
||||
"""List $name assets, paginated."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = ${Name}.query
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [item.to_dict() for item in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@${name}_bp.route('/<int:assetid>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_${name}(assetid: int):
|
||||
"""Get a single $name by assetid."""
|
||||
item = ${Name}.query.get(assetid)
|
||||
if not item:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'$Name with assetid {assetid} not found',
|
||||
http_code=404,
|
||||
)
|
||||
return success_response(item.to_dict())
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* ${Name} API client snippet.
|
||||
*
|
||||
* Paste this ${name}Api block into frontend/src/api/index.js (next to the
|
||||
* other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail,
|
||||
* and ${Name}Form views, delete the local ${name}Api const and import the shared
|
||||
* one instead:
|
||||
*
|
||||
* import { ${name}Api } from '../../api'
|
||||
*
|
||||
* The scaffolded views ship with an identical inline client so they build and
|
||||
* run before you touch the shared api module. This file is NOT auto-merged into
|
||||
* api/index.js on purpose; that module is hand-maintained and shared.
|
||||
*
|
||||
* The create/update/delete calls assume matching POST/PUT/DELETE routes exist
|
||||
* on the backend. The scaffolded api/routes.py only ships list and get; add the
|
||||
* write endpoints when you wire up the form.
|
||||
*/
|
||||
export const ${name}Api = {
|
||||
list(params = {}) {
|
||||
return api.get('/${name}', { params })
|
||||
},
|
||||
get(itemId) {
|
||||
return api.get(`/${name}/${itemId}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/${name}', data)
|
||||
},
|
||||
update(itemId, data) {
|
||||
return api.put(`/${name}/${itemId}`, data)
|
||||
},
|
||||
remove(itemId) {
|
||||
return api.delete(`/${name}/${itemId}`)
|
||||
}
|
||||
}
|
||||
35
shopdb/plugins/templates/frontend/routes.js.tmpl
Normal file
35
shopdb/plugins/templates/frontend/routes.js.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* ${Name} plugin routes.
|
||||
*
|
||||
* Auto-discovered by the router via import.meta.glob, so no registration
|
||||
* edit is needed. Every route carries meta.plugin '${name}' so the ADR-009
|
||||
* guard redirects to the dashboard when the ${name} backend plugin is
|
||||
* disabled. Form routes add requiresAuth so anonymous users cannot reach
|
||||
* create or edit.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '${name}',
|
||||
name: '${name}',
|
||||
component: () => import('../../views/${name}/${Name}List.vue'),
|
||||
meta: { plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/new',
|
||||
name: '${name}-new',
|
||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
||||
meta: { requiresAuth: true, plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/:id',
|
||||
name: '${name}-detail',
|
||||
component: () => import('../../views/${name}/${Name}Detail.vue'),
|
||||
meta: { plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/:id/edit',
|
||||
name: '${name}-edit',
|
||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
||||
meta: { requiresAuth: true, plugin: '${name}' }
|
||||
}
|
||||
]
|
||||
149
shopdb/plugins/templates/frontend/views/Detail.vue.tmpl
Normal file
149
shopdb/plugins/templates/frontend/views/Detail.vue.tmpl
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="detail-page" v-if="item">
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title-row">
|
||||
<h1 class="hero-title">{{ item.name || item.assetnumber || '${Name}' }}</h1>
|
||||
<router-link
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="`/${name}/${itemId}/edit`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="detail-item" v-if="item.assetnumber">
|
||||
<span class="label">Asset #</span>
|
||||
<span class="value">{{ item.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="item.serialnumber">
|
||||
<span class="label">Serial</span>
|
||||
<span class="value mono">{{ item.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">${Name} Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Example Field</span>
|
||||
<span class="info-value">{{ item.examplefield || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Asset Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ item.assetnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ item.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/${name}/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
<p>Record not found</p>
|
||||
<router-link to="/${name}" class="btn btn-secondary">Back to ${Name}</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||
// import { ${name}Api } from '../../api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
create(data) { return api.post('/${name}', data) },
|
||||
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const itemId = route.params.id
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(loadItem)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await ${name}Api.get(itemId)
|
||||
item.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading ${name}:', error)
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm('Delete this record?')) {
|
||||
try {
|
||||
await ${name}Api.remove(itemId)
|
||||
router.push('/${name}')
|
||||
} catch (error) {
|
||||
console.error('Error deleting ${name}:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
228
shopdb/plugins/templates/frontend/views/Form.vue.tmpl
Normal file
228
shopdb/plugins/templates/frontend/views/Form.vue.tmpl
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit ${Name}' : 'Add ${Name}' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<fieldset>
|
||||
<legend>Asset Information</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>${Name} Details</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="examplefield">Example Field</label>
|
||||
<input
|
||||
id="examplefield"
|
||||
v-model="form.examplefield"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||
// import { ${name}Api } from '../../api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
create(data) { return api.post('/${name}', data) },
|
||||
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const itemId = route.params.id
|
||||
const isEdit = computed(() => !!itemId)
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
serialnumber: '',
|
||||
examplefield: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
await loadItem()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const response = await ${name}Api.get(itemId)
|
||||
const data = response.data.data
|
||||
form.value.assetnumber = data.assetnumber || ''
|
||||
form.value.name = data.name || ''
|
||||
form.value.serialnumber = data.serialnumber || ''
|
||||
form.value.examplefield = data.examplefield || ''
|
||||
} catch (loadError) {
|
||||
console.error('Error loading ${name}:', loadError)
|
||||
error.value = 'Failed to load record'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
examplefield: form.value.examplefield || null
|
||||
}
|
||||
|
||||
let redirectId = itemId
|
||||
if (isEdit.value) {
|
||||
await ${name}Api.update(itemId, payload)
|
||||
} else {
|
||||
const response = await ${name}Api.create(payload)
|
||||
redirectId = response.data.data?.assetid
|
||||
}
|
||||
|
||||
router.push(redirectId ? `/${name}/${redirectId}` : '/${name}')
|
||||
} catch (submitError) {
|
||||
console.error('Error saving ${name}:', submitError)
|
||||
error.value = 'Failed to save record'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEdit.value) {
|
||||
router.push(`/${name}/${itemId}`)
|
||||
} else {
|
||||
router.push('/${name}')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
142
shopdb/plugins/templates/frontend/views/List.vue.tmpl
Normal file
142
shopdb/plugins/templates/frontend/views/List.vue.tmpl
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>${Name}</h2>
|
||||
<router-link to="/${name}/new" class="btn btn-primary">Add ${Name}</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Name</th>
|
||||
<th>Example Field</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.assetid">
|
||||
<td>{{ item.assetnumber || '-' }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td>{{ item.examplefield || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/${name}/${item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No ${name} records found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/${name}/frontend-api-snippet.js) then swap for:
|
||||
// import { ${name}Api } from '../../api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
create(data) { return api.post('/${name}', data) },
|
||||
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
|
||||
}
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
const response = await ${name}Api.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading ${name}:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(target) {
|
||||
if (target >= 1 && target <= totalPages.value) {
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
9
shopdb/plugins/templates/manifest.json.tmpl
Normal file
9
shopdb/plugins/templates/manifest.json.tmpl
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "$name",
|
||||
"version": "0.1.0",
|
||||
"description": "$description",
|
||||
"author": "",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.1.0,<1.0.0",
|
||||
"api_prefix": "/api/$name"
|
||||
}
|
||||
5
shopdb/plugins/templates/models/__init__.py.tmpl
Normal file
5
shopdb/plugins/templates/models/__init__.py.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
"""$Name plugin models."""
|
||||
|
||||
from .$name import $Name
|
||||
|
||||
__all__ = ['$Name']
|
||||
32
shopdb/plugins/templates/models/model.py.tmpl
Normal file
32
shopdb/plugins/templates/models/model.py.tmpl
Normal file
@@ -0,0 +1,32 @@
|
||||
"""$Name model.
|
||||
|
||||
This is an Asset extension table keyed by assetid. The Asset row holds
|
||||
the platform fields (assetnumber, name, vendorid, locationid, etc.);
|
||||
this table holds the $name-specific fields. Replace the example fields
|
||||
below with your domain model.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class $Name(BaseModel):
|
||||
"""$Name domain entity, extending Asset by assetid."""
|
||||
|
||||
__tablename__ = '$name'
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
# TODO: replace these example fields with your domain fields.
|
||||
examplefield = db.Column(db.String(255), nullable=True)
|
||||
|
||||
asset = db.relationship('Asset', backref=db.backref('$name', uselist=False))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'assetid': self.assetid,
|
||||
'examplefield': self.examplefield,
|
||||
}
|
||||
70
shopdb/plugins/templates/plugin.py.tmpl
Normal file
70
shopdb/plugins/templates/plugin.py.tmpl
Normal file
@@ -0,0 +1,70 @@
|
||||
"""$Name plugin main class.
|
||||
|
||||
$description
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import $Name
|
||||
from .api import ${name}_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ${Name}Plugin(BasePlugin):
|
||||
"""$Name plugin.
|
||||
|
||||
$description
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
with open(manifest_path) as f:
|
||||
self._manifest = json.load(f)
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest['name'],
|
||||
version=self._manifest['version'],
|
||||
description=self._manifest['description'],
|
||||
author=self._manifest.get('author', ''),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0'),
|
||||
api_prefix=self._manifest.get('api_prefix'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return ${name}_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [$Name]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'$Name plugin initialized (v{self.meta.version})')
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._ensure_asset_type()
|
||||
logger.info('$Name plugin installed')
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
existing = AssetType.query.filter_by(assettype='$name').first()
|
||||
if not existing:
|
||||
asset_type = AssetType(
|
||||
assettype='$name',
|
||||
pluginname='$name',
|
||||
tablename='$name',
|
||||
description=self.meta.description,
|
||||
)
|
||||
db.session.add(asset_type)
|
||||
db.session.commit()
|
||||
logger.debug('Created asset type: $name')
|
||||
6
shopdb/plugins/templates/schemas/__init__.py.tmpl
Normal file
6
shopdb/plugins/templates/schemas/__init__.py.tmpl
Normal file
@@ -0,0 +1,6 @@
|
||||
"""$Name plugin schemas (marshmallow).
|
||||
|
||||
Add schema classes here when you need request/response validation
|
||||
beyond the simple to_dict() output. The framework wires marshmallow
|
||||
into the response helpers; see the PLUGIN-HOOKS wiki page for details.
|
||||
"""
|
||||
0
shopdb/plugins/templates/tests/__init__.py.tmpl
Normal file
0
shopdb/plugins/templates/tests/__init__.py.tmpl
Normal file
30
shopdb/plugins/templates/tests/test_plugin.py.tmpl
Normal file
30
shopdb/plugins/templates/tests/test_plugin.py.tmpl
Normal file
@@ -0,0 +1,30 @@
|
||||
"""$Name plugin smoke tests.
|
||||
|
||||
Asserts the plugin loads cleanly and satisfies the framework contract.
|
||||
Replace and extend with domain tests as you build the plugin out.
|
||||
"""
|
||||
|
||||
from plugins.$name.plugin import ${Name}Plugin
|
||||
|
||||
|
||||
def test_${name}_plugin_meta_is_valid():
|
||||
"""${Name}Plugin.meta returns a PluginMeta with the expected name."""
|
||||
plugin = ${Name}Plugin()
|
||||
assert plugin.meta.name == '$name'
|
||||
assert plugin.meta.api_prefix == '/api/$name'
|
||||
|
||||
|
||||
def test_${name}_plugin_get_blueprint_returns_blueprint():
|
||||
"""get_blueprint returns a Flask Blueprint, not None."""
|
||||
from flask import Blueprint
|
||||
plugin = ${Name}Plugin()
|
||||
assert isinstance(plugin.get_blueprint(), Blueprint)
|
||||
|
||||
|
||||
def test_${name}_plugin_get_models_returns_a_model():
|
||||
"""get_models returns a list with at least one SQLAlchemy model."""
|
||||
plugin = ${Name}Plugin()
|
||||
models = plugin.get_models()
|
||||
assert len(models) >= 1
|
||||
for model in models:
|
||||
assert hasattr(model, '__tablename__')
|
||||
Reference in New Issue
Block a user