Phase 4: plugin scaffolding (flask plugin new) with canary tests
Lowers the barrier for sister sites to build their own plugins. Generated output satisfies the framework contract out of the box. CLI command (shopdb/plugins/cli.py): - `flask plugin new <name> --description "..."` generates a plugin skeleton under plugins/<name>/. Validates the name against CONTRIBUTING.md rules (lowercase letters/digits only, no underscores or hyphens, not in the reserved list) and refuses to overwrite existing plugins unless --overwrite is passed. - Output prints the next steps (install, migrate, test). Scaffolder (shopdb/plugins/scaffolder.py): - validate_name: enforces the naming rules - pascal_case: lowercase-to-PascalCase for class names - scaffold_plugin: copies templates with string.Template substitution. Three placeholders: $name, $Name, $description. Files with `model.py` in the path get renamed to <name>.py. Templates (shopdb/plugins/templates/): - manifest.json.tmpl: name, version 0.1.0, description, core_version range >=0.1.0,<1.0.0 (broad enough to survive minor framework bumps) - plugin.py.tmpl: <Name>Plugin class extending BasePlugin with all required hooks implemented (meta from manifest, get_blueprint returning the bp, get_models returning the example model). Includes on_install hook that seeds the AssetType row. - models/__init__.py.tmpl + models/model.py.tmpl: Asset extension table keyed by assetid with one example field. TODO comment marks it as a placeholder. - api/__init__.py.tmpl + api/routes.py.tmpl: Blueprint with list and detail endpoints using the framework's pagination + response helpers. - schemas/__init__.py.tmpl: marshmallow schema stub. - tests/__init__.py.tmpl + tests/test_plugin.py.tmpl: smoke tests asserting plugin loads, get_blueprint returns Blueprint, get_models returns at least one model. - README.md.tmpl: one-pager for plugin authors with common edits and next-step references. Canary tests (tests/test_plugin_scaffold.py): - 14 tests asserting the scaffold output passes contract checks. - Validates name rules (lowercase, reserved, hyphens, digits, etc.) - Verifies all expected files generated, manifest fields present. - Loads the generated plugin via PluginLoader (spec_from_file_location bypasses the real `plugins` package shadowing). - Asserts subclasses BasePlugin, get_blueprint returns Blueprint, get_models returns model with __tablename__. - Module-scoped fixture; cleans up sys.modules + SQLAlchemy metadata on teardown to avoid cross-test contamination. Quickstart docs (docs/PLUGIN-QUICKSTART.md): - 30-minute walkthrough: scaffold -> edit model -> add routes -> install -> verify -> add hooks. Cross-links to PLUGIN-HOOKS.md and the ADRs. Includes common-errors table. Test count: 87 -> 101 passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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 `docs/PLUGIN-HOOKS.md`. |
|
||||
| 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
|
||||
|
||||
- `docs/PLUGIN-HOOKS.md` - canonical hook reference
|
||||
- `docs/PLUGIN-QUICKSTART.md` - 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']
|
||||
44
shopdb/plugins/templates/api/routes.py.tmpl
Normal file
44
shopdb/plugins/templates/api/routes.py.tmpl
Normal file
@@ -0,0 +1,44 @@
|
||||
"""$Name plugin API routes."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.utils.responses import (
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes,
|
||||
)
|
||||
from shopdb.utils.pagination import 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())
|
||||
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']
|
||||
33
shopdb/plugins/templates/models/model.py.tmpl
Normal file
33
shopdb/plugins/templates/models/model.py.tmpl
Normal file
@@ -0,0 +1,33 @@
|
||||
"""$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.extensions import db
|
||||
from shopdb.core.models.base import 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,
|
||||
}
|
||||
71
shopdb/plugins/templates/plugin.py.tmpl
Normal file
71
shopdb/plugins/templates/plugin.py.tmpl
Normal file
@@ -0,0 +1,71 @@
|
||||
"""$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.core.models import AssetType
|
||||
from shopdb.extensions import db
|
||||
|
||||
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 docs/PLUGIN-HOOKS.md 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