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).
|
||||
Reference in New Issue
Block a user