From 94f852a1c85ead1b49b58bcc6ee586dcefe4a531 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 11 Jul 2026 10:14:25 -0400 Subject: [PATCH] Add frontend integration checklist and docs-drift guard PLUGIN-QUICKSTART Step 7 is now a concrete 7-item checklist (view conventions, route auto-discovery + ADR-009 gating meta, api client shape, nav/report hooks, settings auto-nesting, verification). New tests/test_docs_contract.py introspects BasePlugin and fails CI when a public hook is missing from PLUGIN-HOOKS.md or the documented contract version drifts - it immediately caught two undocumented hooks (get_provisioning_note, get_config_schema), now documented. Co-Authored-By: Claude Fable 5 --- docs/PLUGIN-HOOKS.md | 44 ++++++++++++++++++++++++++ docs/PLUGIN-QUICKSTART.md | 37 ++++++++++++++++++---- tests/test_docs_contract.py | 61 +++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 tests/test_docs_contract.py diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index 35c5efd..a2611d6 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -216,6 +216,50 @@ Consumed by `GET /api/reports`, which merges plugin cards after the static core reports sorted into category groups by the frontend (disabled plugins are skipped; a broken plugin is isolated in prod, re-raised in dev/test). +### `get_provisioning_note() -> Optional[Dict]` + +Transparency note the setup wizard shows the moment a site checks this plugin +during setup. Return `None` (the default) for plugins that need no special +setup. Plugins that create extra tables beyond their asset-extension table +(e.g. a self-hosted directory) return: + +```python +class EmployeesPlugin(BasePlugin): + def get_provisioning_note(self): + return { + 'tables': ['directoryemployees'], + 'note': 'Creates a local employee directory table in the shopdb database.', + 'docs': 'plugins/employees/README.md', + } +``` + +### `get_config_schema() -> List[Dict]` + +Declares the config fields this plugin needs, so the setup wizard can prompt +for them. Return `[]` (the default) if the plugin needs no configuration. +Each field is a dict: + +| Key | Meaning | +|-----|---------| +| `key` | the Setting key (non-secret) it maps to | +| `label` | human label shown in the wizard | +| `type` | `'text'` / `'number'` / `'password'` | +| `secret` | `True` for credentials; NOT stored in the DB - the wizard emits an `.env` line for the operator instead | +| `envvar` | (secret only) the `.env` variable name to emit | +| `default` | optional placeholder | +| `help` | optional hint | + +```python +class PrintersPlugin(BasePlugin): + def get_config_schema(self): + return [ + {'key': 'zabbix_url', 'label': 'Zabbix URL', 'type': 'text', + 'help': 'Base URL of the Zabbix server for supply lookups'}, + {'key': 'zabbix_token', 'label': 'Zabbix API token', 'type': 'password', + 'secret': True, 'envvar': 'ZABBIX_TOKEN'}, + ] +``` + ### `get_collector_schema() -> Optional[Dict]` Declares the JSON Schema for an external collector pushing to `/api/collector/`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract. diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md index 367b290..722a462 100644 --- a/docs/PLUGIN-QUICKSTART.md +++ b/docs/PLUGIN-QUICKSTART.md @@ -126,15 +126,40 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS Each hook has a default that does nothing. Override only what your plugin needs. -## Step 7: Frontend (manual for now) +## Step 7: Frontend (manual checklist) -Backend scaffolding is automated. Frontend is manual until the frontend scaffolding skill ships. Convention: +Backend scaffolding is automated. The frontend is a manual checklist until a frontend scaffold ships. Copy from the closest bundled plugin (`network` is the cleanest) and work through these in order: -- `frontend/src/views/cameras/CamerasList.vue` -- `frontend/src/views/cameras/CameraDetail.vue` -- `frontend/src/views/cameras/CameraForm.vue` +1. **View files** - create `frontend/src/views/cameras/CamerasList.vue`, `CameraDetail.vue`, `CameraForm.vue`. Copy from `frontend/src/views/network/` and rename. Use the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. -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. +2. **Route file** - create `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Tag EVERY route with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled. Add `requiresAuth: true` on form routes: + +```js +export default [ + { + path: 'cameras', + name: 'cameras', + component: () => import('../../views/cameras/CamerasList.vue'), + meta: { plugin: 'cameras' } + }, + { + path: 'cameras/:id/edit', + name: 'camera-edit', + component: () => import('../../views/cameras/CameraForm.vue'), + meta: { requiresAuth: true, plugin: 'cameras' } + } +] +``` + +3. **API client** - add a `camerasApi` block to `frontend/src/api/index.js` wrapping your endpoints. Match an existing block's shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`). + +4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`. + +5. **Report cards** (if any) - implement `get_reports` on the plugin class. No frontend edit: the Reports hub builds itself from `/api/reports`. Use `route` for a dedicated page (add it to your route file), or `endpoint` for inline rendering. + +6. **Settings page** (if the plugin has subtypes) - add a route whose path starts with `settings/` (e.g. `settings/cameratypes`) to your route file; the router automatically nests it under the settings shell. Copy a types-list view from `frontend/src/views/settings/`. + +7. **Verify** - `npm run build` must pass, then screenshot your pages against the dev servers: `venv/bin/python tools/shot.py /cameras`. ## Common errors diff --git a/tests/test_docs_contract.py b/tests/test_docs_contract.py new file mode 100644 index 0000000..b7ac2b9 --- /dev/null +++ b/tests/test_docs_contract.py @@ -0,0 +1,61 @@ +"""Docs-drift guards: docs/PLUGIN-HOOKS.md must track the live contract. + +PLUGIN-HOOKS.md is the canonical plugin-author reference. These tests fail +when the contract surface moves without the doc: a version bump that skips +the doc's version example, or a new/renamed BasePlugin hook with no doc +mention. Keeping this structural (not manual review) is what lets sister +sites trust the doc. +""" + +import inspect +from pathlib import Path + +from shopdb import __contract_version__ +from shopdb.plugins.base import BasePlugin + +HOOKS_DOC = Path(__file__).resolve().parent.parent / 'docs' / 'PLUGIN-HOOKS.md' + + +def test_hooks_doc_exists(): + assert HOOKS_DOC.exists(), 'docs/PLUGIN-HOOKS.md is missing' + + +def test_hooks_doc_declares_current_contract_version(): + """The doc's version example must match the live __contract_version__.""" + text = HOOKS_DOC.read_text() + expected = f"__contract_version__ = '{__contract_version__}'" + assert expected in text, ( + f'docs/PLUGIN-HOOKS.md version example is stale: expected {expected}. ' + f'Update the "Contract version" section when bumping the contract.' + ) + + +def test_every_public_hook_is_documented(): + """Every public BasePlugin method must be mentioned in the doc.""" + text = HOOKS_DOC.read_text() + hooks = [ + name for name, member in inspect.getmembers( + BasePlugin, predicate=inspect.isfunction) + if not name.startswith('_') + ] + assert hooks, 'No public hooks found on BasePlugin (introspection broke?)' + missing = [hook for hook in hooks if hook not in text] + assert not missing, ( + 'BasePlugin hooks missing from docs/PLUGIN-HOOKS.md: ' + + ', '.join(missing) + + '. Add a section (or mention) for each before shipping the hook.' + ) + + +def test_doc_does_not_reference_removed_hooks(): + """Hooks removed from the contract must not be documented as current. + + They may appear in "Removed" notes; this only guards section headings. + """ + text = HOOKS_DOC.read_text() + for removed in ('get_searchable_fields', 'get_event_handlers'): + assert not hasattr(BasePlugin, removed) + assert f'### `{removed}' not in text, ( + f'{removed} was removed from the contract but still has a ' + f'section heading in docs/PLUGIN-HOOKS.md' + )