# Plugin Quickstart Build a working shopdb-flask plugin in about 30 minutes: generate it, put your fields in it, install it, see it in the UI. Every shape used below is defined in [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md), the contract and the only page that defines it; read shapes there, not here. > **Windows:** use `venv\Scripts\python` and `$env:FLASK_APP="shopdb"` (not > `export`). Onboarding: [DEVELOPMENT-SETUP](DEVELOPMENT-SETUP.md). ## 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, a README, a paste-in `frontend-api-snippet.js`, and the frontend starting points - `plugins/cameras/frontend/routes.js` and `frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`. Nothing is written into `frontend/src/`; the plugin owns its frontend, which is what makes it prunable. The staging rules are in [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#frontend). A plugin developed in its own repo gets exactly this layout and starts working the moment the directory is dropped into a host repo's `plugins/`. 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) ``` Everything you may import comes from `shopdb.api` - the list is in [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#import-surface). For audit logging: ```python from shopdb.api import audit_log audit_log(action='created', entitytype='Camera', entityid=asset.assetid, entityname=asset.name) ``` ## Step 4: Install the plugin First add `plugins/cameras/migrations/` with a per-plugin Alembic chain that creates the plugin's tables, and register those tables in `PLUGIN_TABLE_OWNERS`. Your plugin is post-cutover, so its `0001` really creates the tables; see [PLUGINS.md](PLUGINS.md#migrations) for the anchor-vs-baseline rule. Then: ```bash flask plugin install cameras flask plugin upgrade-all ``` `install` runs the plugin's `on_install` hook (which seeds the AssetType row), registers it in the plugin registry, and runs the plugin's own migration chain. `flask db migrate` / `flask db upgrade` is reserved for core tables and must not be used for plugin schema. ## Step 5: Verify it works ```bash flask plugin list # cameras [Enabled] pytest plugins/cameras/tests/ curl http://localhost:5001/api/cameras ``` ## Step 6: Add hooks (optional) Override hooks on the plugin class as needed. Each has a default that does nothing, so override only what your plugin needs. The catalog, with the exact shape each one returns, is [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#optional-hooks) - do not copy shapes from this page or any other, because there is only one that is kept correct. Four that most new plugins start with: `get_navigation_items` (sidebar entry), `get_permissions` (its own RBAC permissions), `get_reports` (a card on the Reports hub) and `get_settings_cards` (a card in the settings rail). ## Step 7: Finish the generated frontend The scaffolded views build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. What you finish by hand, in order. Copy patterns from the closest bundled plugin - `network` is the cleanest. 1. **Views** - replace the `examplefield` columns and inputs in `plugins/cameras/frontend/views/*.vue` with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. Mind the import rule in [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md#frontend): `@` for core, relative for your own plugin. 2. **Routes** - `plugins/cameras/frontend/routes.js` is already generated with `meta: { plugin: 'cameras' }` on every route and `requiresAuth` on the form routes. No registration edit is needed. 3. **API client** - the scaffolded views ship with an inline `camerasApi` so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, delete the inline const in each view, and `import { camerasApi } from '@/api'` instead. 4. **Sidebar, reports, settings** - implement the hooks from step 6. No frontend edit: those surfaces build themselves from what the hooks return. 5. **Settings subtype page** (if the plugin has subtypes) - add a route whose path starts with `settings/` (e.g. `settings/cameratypes`); the router nests it under the settings shell automatically. Copy a types-list view from `frontend/src/views/settings/`. 6. **Verify** - `npm run build` must pass, then look at the pages against the dev servers. ## Common errors | Symptom | Cause | Fix | |---------|-------|-----| | `PluginNotFoundError: manifest.json` | Manifest deleted or moved | Restore `plugins//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 - [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the conventions and traps of a real plugin, and the end checklist. - [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) - shipping a plugin from its own repo, per [ADR-003](adr/ADR-003-plugin-distribution.md). - [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what you can safely build on before contract 1.0.