Add frontend integration checklist and docs-drift guard
All checks were successful
CI / backend (push) Successful in 24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 10:14:25 -04:00
parent 54c4c808cc
commit 94f852a1c8
3 changed files with 136 additions and 6 deletions

View File

@@ -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/<pluginname>`. See [ADR-006](../docs/adr/ADR-006-collector-contract.md) for the contract.

View File

@@ -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

View File

@@ -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'
)