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:
158
docs/PLUGIN-QUICKSTART.md
Normal file
158
docs/PLUGIN-QUICKSTART.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Plugin Quickstart
|
||||||
|
|
||||||
|
Build a working shopdb-flask plugin in 30 minutes. This walks through generating, customizing, installing, and testing a plugin from scratch.
|
||||||
|
|
||||||
|
For the full hook reference, see [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md).
|
||||||
|
For the architectural decisions behind the contract, see [migrations/adr/](../migrations/adr/).
|
||||||
|
|
||||||
|
## Step 1: Generate the skeleton
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, and a README.
|
||||||
|
|
||||||
|
The generated plugin already passes the framework's contract tests. Verify before editing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest plugins/cameras/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Edit the model
|
||||||
|
|
||||||
|
Open `plugins/cameras/models/cameras.py`. Replace the `examplefield` placeholder with your domain fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Cameras(BaseModel):
|
||||||
|
__tablename__ = 'cameras'
|
||||||
|
|
||||||
|
assetid = db.Column(
|
||||||
|
db.Integer,
|
||||||
|
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
streamurl = db.Column(db.String(255), nullable=False)
|
||||||
|
resolution = db.Column(db.String(20))
|
||||||
|
fps = db.Column(db.Integer)
|
||||||
|
poeport = db.Column(db.String(50))
|
||||||
|
asset = db.relationship('Asset', backref=db.backref('cameras', uselist=False))
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'assetid': self.assetid,
|
||||||
|
'streamurl': self.streamurl,
|
||||||
|
'resolution': self.resolution,
|
||||||
|
'fps': self.fps,
|
||||||
|
'poeport': self.poeport,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the naming convention: lowercase concatenated, no underscores (`streamurl`, not `stream_url`). See [CONTRIBUTING.md](../CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## Step 3: Add routes
|
||||||
|
|
||||||
|
Open `plugins/cameras/api/routes.py`. The scaffold provides list and detail endpoints. Add CRUD as needed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@cameras_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def create_camera():
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
asset = Asset(assetnumber=data['assetnumber'], name=data['name'], ...)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
camera = Cameras(
|
||||||
|
assetid=asset.assetid,
|
||||||
|
streamurl=data['streamurl'],
|
||||||
|
resolution=data.get('resolution'),
|
||||||
|
)
|
||||||
|
db.session.add(camera)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return success_response(camera.to_dict(), http_code=201)
|
||||||
|
```
|
||||||
|
|
||||||
|
For audit logging, use the public helper:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from shopdb.api import audit_log
|
||||||
|
|
||||||
|
audit_log(action='created', entitytype='Camera', entityid=asset.assetid, entityname=asset.name)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Install the plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask plugin install cameras
|
||||||
|
flask db migrate -m "Add cameras plugin tables"
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
`install` runs the plugin's `on_install` hook (which seeds the AssetType row), registers it in the plugin registry, and runs migrations.
|
||||||
|
|
||||||
|
## Step 5: Verify it works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask plugin list
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see `cameras [Enabled]`.
|
||||||
|
|
||||||
|
Run the plugin's tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest plugins/cameras/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Hit the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5001/api/cameras
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Add hooks (optional)
|
||||||
|
|
||||||
|
Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full list. Common ones:
|
||||||
|
|
||||||
|
| Hook | Adds |
|
||||||
|
|------|------|
|
||||||
|
| `get_searchable_fields` | Plugin contributes to the global search endpoint |
|
||||||
|
| `get_navigation_items` | Plugin shows up in the sidebar nav |
|
||||||
|
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
|
||||||
|
| `get_collector_schema` | Plugin accepts external pushes at `/api/collector/<name>` |
|
||||||
|
|
||||||
|
Each hook has a default that does nothing. Override only what your plugin needs.
|
||||||
|
|
||||||
|
## Step 7: Frontend (manual for now)
|
||||||
|
|
||||||
|
Backend scaffolding is automated. Frontend is manual until the frontend scaffolding skill ships. Convention:
|
||||||
|
|
||||||
|
- `frontend/src/views/cameras/CamerasList.vue`
|
||||||
|
- `frontend/src/views/cameras/CameraDetail.vue`
|
||||||
|
- `frontend/src/views/cameras/CameraForm.vue`
|
||||||
|
|
||||||
|
Copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point. Update the API client in `frontend/src/api/index.js` to add cameras endpoints.
|
||||||
|
|
||||||
|
## Common errors
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| `PluginNotFoundError: manifest.json` | Manifest deleted or moved | Restore `plugins/<name>/manifest.json` |
|
||||||
|
| `PluginContractError: missing required field` | manifest.json incomplete | Re-add `name`, `version`, `description` |
|
||||||
|
| `PluginVersionError: requires core_version X but framework is Y` | Framework upgraded past your range | Update `core_version` in manifest |
|
||||||
|
| `Table 'cameras' is already defined` | Two models declared the same `__tablename__` | Pick a unique table name |
|
||||||
|
| Index name collision | Two indexes share the same name (SQLite enforces global uniqueness) | Prefix index names with table: `idx_cameras_streamurl` |
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) for the full hook reference
|
||||||
|
- [CONTRIBUTING.md](../CONTRIBUTING.md) for naming conventions
|
||||||
|
- [migrations/adr/ADR-001-asset-as-platform-contract.md](../migrations/adr/ADR-001-asset-as-platform-contract.md) for what your plugin can rely on
|
||||||
|
- [migrations/adr/ADR-006-collector-contract.md](../migrations/adr/ADR-006-collector-contract.md) for accepting external collector input
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `<repo>/plugins/<name>/`. See [ADR-003](../migrations/adr/ADR-003-plugin-distribution.md).
|
||||||
@@ -179,6 +179,46 @@ def plugin_info(name: str):
|
|||||||
click.echo("")
|
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. Run: flask plugin install {name}')
|
||||||
|
click.echo(f' 4. Run: flask db migrate -m "Add {name} plugin"')
|
||||||
|
click.echo(f' 5. Run: flask db upgrade')
|
||||||
|
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
|
||||||
|
|
||||||
|
|
||||||
@plugin_cli.command('migrate')
|
@plugin_cli.command('migrate')
|
||||||
@click.argument('name')
|
@click.argument('name')
|
||||||
@click.option('--revision', default='head', help='Target revision')
|
@click.option('--revision', default='head', help='Target revision')
|
||||||
|
|||||||
121
shopdb/plugins/scaffolder.py
Normal file
121
shopdb/plugins/scaffolder.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""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 scaffold_plugin(
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
plugins_dir: Path,
|
||||||
|
template_root: Optional[Path] = None,
|
||||||
|
overwrite: bool = False,
|
||||||
|
) -> 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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 `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__')
|
||||||
189
tests/test_plugin_scaffold.py
Normal file
189
tests/test_plugin_scaffold.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""Canary tests for the plugin scaffolder.
|
||||||
|
|
||||||
|
The scaffold output must satisfy the framework contract the moment it
|
||||||
|
is generated. If a contract test would fail on a freshly scaffolded
|
||||||
|
plugin, the scaffold templates are broken, not the plugin author's
|
||||||
|
mistake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from shopdb.plugins.scaffolder import (
|
||||||
|
scaffold_plugin,
|
||||||
|
validate_name,
|
||||||
|
pascal_case,
|
||||||
|
ScaffoldError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='module')
|
||||||
|
def scaffolded(tmp_path_factory):
|
||||||
|
"""Scaffold a plugin named 'widgets' under a temporary plugins dir.
|
||||||
|
|
||||||
|
Module-scoped so the generated plugin module imports exactly once
|
||||||
|
per test module. SQLAlchemy registers the model once, no metadata
|
||||||
|
collision. Returns a tuple (target_dir, plugins_dir) where
|
||||||
|
plugins_dir is suitable as PluginLoader's plugins_dir argument.
|
||||||
|
|
||||||
|
The scaffold output is loaded via PluginLoader's spec-based import
|
||||||
|
so the canary does not collide with the real `plugins/` package
|
||||||
|
that already exists at <repo>/plugins/.
|
||||||
|
"""
|
||||||
|
tmp_path = tmp_path_factory.mktemp('scaffold')
|
||||||
|
plugins_dir = tmp_path / 'plugins'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
|
||||||
|
target = scaffold_plugin(
|
||||||
|
name='widgets',
|
||||||
|
description='Test widgets plugin',
|
||||||
|
plugins_dir=plugins_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
import plugins as real_plugins
|
||||||
|
real_plugins.__path__.insert(0, str(plugins_dir))
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield target, plugins_dir
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
real_plugins.__path__.remove(str(plugins_dir))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
for mod_name in list(sys.modules):
|
||||||
|
if mod_name.startswith('plugins.widgets'):
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
from shopdb.extensions import db as _db
|
||||||
|
if 'widgets' in _db.metadata.tables:
|
||||||
|
_db.metadata.remove(_db.metadata.tables['widgets'])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_widgets_plugin_class(plugins_dir):
|
||||||
|
"""Load the scaffolded widgets plugin class via PluginLoader."""
|
||||||
|
from shopdb.plugins.loader import PluginLoader
|
||||||
|
from shopdb.plugins.registry import PluginRegistry
|
||||||
|
|
||||||
|
registry = PluginRegistry(plugins_dir.parent / 'plugins.json')
|
||||||
|
loader = PluginLoader(plugins_dir, registry)
|
||||||
|
return loader.load_plugin_class('widgets')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_accepts_simple_lowercase():
|
||||||
|
validate_name('cameras')
|
||||||
|
validate_name('barcode')
|
||||||
|
validate_name('vlan2')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_underscore():
|
||||||
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||||
|
validate_name('my_plugin')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_hyphen():
|
||||||
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||||
|
validate_name('my-plugin')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_uppercase():
|
||||||
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||||
|
validate_name('MyPlugin')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_leading_digit():
|
||||||
|
with pytest.raises(ScaffoldError, match='lowercase letters and digits'):
|
||||||
|
validate_name('1plugin')
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_name_rejects_reserved():
|
||||||
|
with pytest.raises(ScaffoldError, match='reserved'):
|
||||||
|
validate_name('plugins')
|
||||||
|
|
||||||
|
|
||||||
|
def test_pascal_case():
|
||||||
|
assert pascal_case('cameras') == 'Cameras'
|
||||||
|
assert pascal_case('vlan') == 'Vlan'
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_creates_expected_files(scaffolded):
|
||||||
|
"""Scaffold output has every file the contract expects."""
|
||||||
|
target, _ = scaffolded
|
||||||
|
expected = [
|
||||||
|
'manifest.json',
|
||||||
|
'plugin.py',
|
||||||
|
'__init__.py',
|
||||||
|
'models/__init__.py',
|
||||||
|
'models/widgets.py',
|
||||||
|
'api/__init__.py',
|
||||||
|
'api/routes.py',
|
||||||
|
'schemas/__init__.py',
|
||||||
|
'tests/__init__.py',
|
||||||
|
'tests/test_plugin.py',
|
||||||
|
'README.md',
|
||||||
|
]
|
||||||
|
for relpath in expected:
|
||||||
|
assert (target / relpath).exists(), f'Missing scaffold output: {relpath}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_manifest_has_required_fields(scaffolded):
|
||||||
|
"""manifest.json contains the fields the loader requires."""
|
||||||
|
target, _ = scaffolded
|
||||||
|
manifest = json.loads((target / 'manifest.json').read_text())
|
||||||
|
assert manifest['name'] == 'widgets'
|
||||||
|
assert manifest['version']
|
||||||
|
assert manifest['description'] == 'Test widgets plugin'
|
||||||
|
assert manifest['core_version']
|
||||||
|
assert manifest['api_prefix'] == '/api/widgets'
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_plugin_class_is_importable(scaffolded):
|
||||||
|
"""The generated plugin.py loads via PluginLoader and subclasses BasePlugin."""
|
||||||
|
from shopdb.plugins.base import BasePlugin
|
||||||
|
|
||||||
|
_, plugins_dir = scaffolded
|
||||||
|
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
||||||
|
|
||||||
|
assert issubclass(plugin_class, BasePlugin)
|
||||||
|
instance = plugin_class()
|
||||||
|
assert instance.meta.name == 'widgets'
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_get_blueprint_returns_blueprint(scaffolded):
|
||||||
|
"""The generated plugin's get_blueprint returns a Flask Blueprint."""
|
||||||
|
_, plugins_dir = scaffolded
|
||||||
|
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
||||||
|
bp = plugin_class().get_blueprint()
|
||||||
|
assert isinstance(bp, Blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_get_models_returns_model_class(scaffolded):
|
||||||
|
"""The generated plugin's get_models returns at least one model with a tablename."""
|
||||||
|
_, plugins_dir = scaffolded
|
||||||
|
plugin_class = _load_widgets_plugin_class(plugins_dir)
|
||||||
|
models = plugin_class().get_models()
|
||||||
|
assert len(models) == 1
|
||||||
|
assert models[0].__tablename__ == 'widgets'
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_refuses_to_overwrite_by_default(tmp_path):
|
||||||
|
"""A second scaffold of the same name without overwrite raises."""
|
||||||
|
plugins_dir = tmp_path / 'plugins'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
scaffold_plugin('widgets', 'first', plugins_dir)
|
||||||
|
|
||||||
|
with pytest.raises(ScaffoldError, match='already exists'):
|
||||||
|
scaffold_plugin('widgets', 'second', plugins_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_overwrites_when_explicitly_requested(tmp_path):
|
||||||
|
"""overwrite=True allows a re-scaffold."""
|
||||||
|
plugins_dir = tmp_path / 'plugins'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
scaffold_plugin('widgets', 'first', plugins_dir)
|
||||||
|
scaffold_plugin('widgets', 'second', plugins_dir, overwrite=True)
|
||||||
|
|
||||||
|
manifest = json.loads((plugins_dir / 'widgets' / 'manifest.json').read_text())
|
||||||
|
assert manifest['description'] == 'second'
|
||||||
Reference in New Issue
Block a user