Accept + implement ADR-010 frontend plugin hooks (contract 0.7.0)
Four data-only hooks on BasePlugin (get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation) with a GET-only /api/pluginui consumer surface copying the dashboard-widgets semantics. Pilots: warranty declares its asset panel; measuringtools supplies its settings card, presentation, and calibration overlay - the last hardcoded settings-nav entry is now hook-sourced. Generic renderers for panels/overlays/presentation deferred per the ADR's incremental adoption plan (documented in CONTRACT-STABILITY.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
|
||||
|
||||
## Current version
|
||||
|
||||
The plugin contract is at **0.6.0**, declared in `shopdb/__init__.py` as
|
||||
The plugin contract is at **0.7.0**, declared in `shopdb/__init__.py` as
|
||||
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
|
||||
bump is allowed to break the contract, and this project has used that latitude.
|
||||
|
||||
@@ -25,8 +25,9 @@ Recorded in the comment block in `shopdb/__init__.py`:
|
||||
| 0.3.0 | `shopdb.api` expanded to the full plugin import surface (db, cache, model bases, core models, response + pagination helpers, `employee_connection`) so plugins stop importing internal core paths | additive (minor) |
|
||||
| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction |
|
||||
| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) |
|
||||
| 0.7.0 | Added the four ADR-010 frontend-contribution hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`), consumed by the `GET /api/pluginui/*` endpoints | additive optional hooks (minor) |
|
||||
|
||||
The source comment block documents 0.3.0, 0.4.0, and 0.6.0. Earlier points
|
||||
The source comment block documents 0.3.0, 0.4.0, 0.6.0, and 0.7.0. Earlier points
|
||||
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
|
||||
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
|
||||
0.2 baseline.
|
||||
@@ -49,6 +50,7 @@ land with a new or amended ADR.
|
||||
| `get_navigation_items` | Sidebar menu entries |
|
||||
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
|
||||
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) |
|
||||
| Frontend-contribution hooks | `get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`, consumed by `/api/pluginui/*` (added 0.7.0, [ADR-010](adr/ADR-010-frontend-plugin-hooks.md)) |
|
||||
| Collector pair | `get_collector_schema` + `apply_collector_payload` per [ADR-006](adr/ADR-006-collector-contract.md) |
|
||||
| Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |
|
||||
| `get_provisioning_note` | Setup-wizard transparency note for extra tables |
|
||||
@@ -65,7 +67,7 @@ Known-unstable areas. Building on these means expecting rework.
|
||||
|
||||
| Area | Status | Reference |
|
||||
|------|--------|-----------|
|
||||
| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) proposes the path: data-only declarative hooks (`get_settings_cards`, `get_asset_panels`, `get_map_overlays`, `get_asset_presentation`) rendered by generic core components, with build-time glob discovery deferred for real components. | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (PROPOSED) |
|
||||
| Frontend renderers (residual) | The four data-only hooks and their `/api/pluginui/*` consumers are settled (0.7.0). The generic core renderers are landing incrementally: the settings-cards rail/landing renderer ships with 0.7.0; the asset-panel, map-overlay, and search-presentation renderers are wired opt-in per the ADR adoption plan. Real component-backed panels (bespoke charts, custom overlays) remain deferred to Option C (build-time glob discovery). | [ADR-010](adr/ADR-010-frontend-plugin-hooks.md) (ACCEPTED) |
|
||||
| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) |
|
||||
| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) |
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.6.0'
|
||||
__contract_version__ = '0.7.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -216,6 +216,98 @@ 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_settings_cards() -> List[Dict]`
|
||||
|
||||
Returns settings-catalog card definitions. Added in contract 0.7.0 (ADR-010).
|
||||
Each card is merged into the settings rail and landing overview without the
|
||||
plugin hand-editing the core `settingsNav.js` catalog. `icon` is a string key
|
||||
mapped to a Lucide component core-side, exactly like `get_navigation_items`.
|
||||
|
||||
```python
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_settings_cards(self):
|
||||
return [{
|
||||
'group': 'Measuring Tools', # rail group title (created if new)
|
||||
'to': '/settings/measuringtooltypes',
|
||||
'icon': 'ruler', # string key, mapped core-side
|
||||
'title': 'Measuring Tool Types',
|
||||
'description': 'Manage measuring-tool subtypes + map colors',
|
||||
'position': 22, # order within the group
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/settings-cards`, which merges enabled plugins'
|
||||
cards into the core catalog (disabled plugins are skipped; a broken plugin is
|
||||
isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_asset_panels() -> List[Dict]`
|
||||
|
||||
Returns asset-detail extension-panel definitions. Added in contract 0.7.0
|
||||
(ADR-010). A generic core `AssetPanel` component renders each panel on the
|
||||
matching detail pages, fetching the panel's `endpoint`. This replaces
|
||||
hand-composing a plugin panel component into each detail view.
|
||||
|
||||
```python
|
||||
class WarrantyPlugin(BasePlugin):
|
||||
def get_asset_panels(self):
|
||||
return [{
|
||||
'id': 'warranty',
|
||||
'title': 'Warranty',
|
||||
'assettypes': ['*'], # detail pages it appears on; ['*'] = all
|
||||
'endpoint': '/api/warranty/asset/{assetid}',
|
||||
'render': 'table', # 'keyvalue' | 'table' | 'badge'
|
||||
'position': 30,
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/asset-panels?assetid=<id>`, which returns the
|
||||
panels whose `assettypes` match that asset's type (disabled plugins skipped;
|
||||
broken plugin isolated in prod, re-raised in dev/test). A panel that needs
|
||||
bespoke UI (a chart) is out of scope for this data-only hook.
|
||||
|
||||
### `get_map_overlays() -> List[Dict]`
|
||||
|
||||
Returns shop-floor map overlay/decoration definitions. Added in contract 0.7.0
|
||||
(ADR-010). The map stays data-driven off asset types + positions; an overlay
|
||||
adds decoration data (a badge or ring) plus an optional legend entry, with no
|
||||
plugin-side map code.
|
||||
|
||||
```python
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_map_overlays(self):
|
||||
return [{
|
||||
'id': 'calibration-due',
|
||||
'label': 'Calibration due', # legend label
|
||||
'endpoint': '/api/measuringtools/map-overlay', # -> [{assetid, color, label}]
|
||||
'style': 'badge', # 'badge' | 'ring'
|
||||
'legend': True,
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
|
||||
plugin isolated in prod, re-raised in dev/test).
|
||||
|
||||
### `get_asset_presentation() -> List[Dict]`
|
||||
|
||||
Returns asset-type presentation/routing definitions. Added in contract 0.7.0
|
||||
(ADR-010). Declares how a plugin-owned asset type renders in global-search rows
|
||||
and cross-links (which icon, which detail route), so core never hardcodes a
|
||||
plugin's route or icon.
|
||||
|
||||
```python
|
||||
class MeasuringToolsPlugin(BasePlugin):
|
||||
def get_asset_presentation(self):
|
||||
return [{
|
||||
'assettype': 'measuring_tool', # AssetType.assettype key the plugin owns
|
||||
'icon': 'ruler',
|
||||
'label': 'Measuring Tool',
|
||||
'route': '/measuringtools/{assetid}',
|
||||
}]
|
||||
```
|
||||
|
||||
Consumed by `GET /api/pluginui/asset-presentation` (disabled plugins skipped;
|
||||
broken plugin 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
|
||||
|
||||
@@ -122,6 +122,10 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
|
||||
| `get_navigation_items` | Plugin shows up in the sidebar nav |
|
||||
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
|
||||
| `get_reports` | Plugin's report cards appear on the Reports hub |
|
||||
| `get_settings_cards` | Plugin's card joins the settings rail + landing (no `settingsNav.js` edit) |
|
||||
| `get_asset_panels` | Plugin panel renders on matching asset-detail pages |
|
||||
| `get_map_overlays` | Plugin decorates shop-floor map markers + adds a legend entry |
|
||||
| `get_asset_presentation` | Plugin declares its asset type's search icon + detail route |
|
||||
| `get_collector_schema` + `apply_collector_payload` | Plugin accepts external pushes at `/api/collector/<name>` |
|
||||
|
||||
Each hook has a default that does nothing. Override only what your plugin needs.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ADR-010: Frontend plugin hook contract
|
||||
|
||||
- **Status:** PROPOSED
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-11
|
||||
- **Accepted:** 2026-07-11
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** none
|
||||
|
||||
@@ -112,7 +113,7 @@ not pursued.
|
||||
|
||||
## Decision
|
||||
|
||||
**PROPOSED:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
|
||||
**DECISION:** adopt a hybrid. Add **data-only declarative hooks** (Option B) for
|
||||
the four presentation surfaces a generic core renderer can serve, and keep
|
||||
**file-convention glob discovery** (Option C) as the deferred mechanism for the
|
||||
residual cases where a real component is unavoidable. Do not pursue runtime
|
||||
|
||||
@@ -22,7 +22,7 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED |
|
||||
| [008](ADR-008-plugin-migration-ownership.md) | Plugin migration ownership (per-plugin chains) | ACCEPTED |
|
||||
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
|
||||
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | PROPOSED |
|
||||
| [010](ADR-010-frontend-plugin-hooks.md) | Frontend plugin hook contract | ACCEPTED |
|
||||
| [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
|
||||
|
||||
## Authoring
|
||||
|
||||
86
frontend/src/composables/settingsCatalog.js
Normal file
86
frontend/src/composables/settingsCatalog.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Settings catalog: the static core groups (settingsNav.js) merged with the
|
||||
// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by
|
||||
// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex
|
||||
// (landing) read the merged catalog so plugins add settings cards with no core
|
||||
// edit. Fetched once into a module-level ref and shared across callers.
|
||||
import { ref } from 'vue'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
|
||||
import api from '../api'
|
||||
import { settingsGroups } from '../views/settings/settingsNav'
|
||||
|
||||
// Icon string keys (as returned by the hook) mapped to Lucide components,
|
||||
// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece.
|
||||
const iconMap = {
|
||||
ruler: Ruler,
|
||||
wrench: Wrench,
|
||||
cog: Cog,
|
||||
package: Package,
|
||||
settings: Settings,
|
||||
puzzle: Puzzle,
|
||||
sliders: SlidersHorizontal,
|
||||
palette: Palette,
|
||||
bell: Bell,
|
||||
network: Network,
|
||||
printer: Printer,
|
||||
droplets: Droplets,
|
||||
}
|
||||
|
||||
function resolveIcon(key) {
|
||||
return iconMap[key] || Puzzle
|
||||
}
|
||||
|
||||
// Merge plugin cards into a fresh copy of the core groups. A card joins the
|
||||
// group whose title matches its `group`; a new group is appended at the end.
|
||||
function mergeCards(baseGroups, cards) {
|
||||
const merged = baseGroups.map(group => ({
|
||||
title: group.title,
|
||||
cards: [...group.cards],
|
||||
}))
|
||||
const byTitle = new Map(merged.map(group => [group.title, group]))
|
||||
|
||||
for (const card of cards) {
|
||||
const entry = {
|
||||
to: card.to,
|
||||
icon: resolveIcon(card.icon),
|
||||
title: card.title,
|
||||
description: card.description || '',
|
||||
position: card.position ?? 99,
|
||||
}
|
||||
let group = byTitle.get(card.group)
|
||||
if (!group) {
|
||||
group = { title: card.group, cards: [] }
|
||||
byTitle.set(card.group, group)
|
||||
merged.push(group)
|
||||
}
|
||||
group.cards.push(entry)
|
||||
}
|
||||
|
||||
// Order plugin cards within a group by position; core cards keep their order.
|
||||
for (const group of merged) {
|
||||
group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Shared across component instances: seeded with the core groups, replaced with
|
||||
// the merged catalog once the hook endpoint answers.
|
||||
const groups = ref(settingsGroups)
|
||||
let loaded = false
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
const response = await api.get('/pluginui/settings-cards')
|
||||
groups.value = mergeCards(settingsGroups, response.data.data || [])
|
||||
} catch (error) {
|
||||
// Degrade to the core-only catalog; the rail still works without plugins.
|
||||
groups.value = settingsGroups
|
||||
}
|
||||
}
|
||||
|
||||
export function useSettingsCatalog() {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
loadCatalog()
|
||||
}
|
||||
return { groups }
|
||||
}
|
||||
@@ -25,7 +25,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { settingsGroups as groups } from './settingsNav'
|
||||
import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -36,17 +36,20 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { settingsGroups as groups } from './settingsNav'
|
||||
import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
|
||||
const search = ref('')
|
||||
|
||||
// Filter the rail by title/description; drop groups that end up empty.
|
||||
const visibleGroups = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return groups
|
||||
return groups
|
||||
if (!term) return groups.value
|
||||
return groups.value
|
||||
.map(g => ({
|
||||
title: g.title,
|
||||
cards: g.cards.filter(c =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared settings navigation catalog.
|
||||
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
|
||||
// grouping lives in one place.
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact, Ruler } from 'lucide-vue-next'
|
||||
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal, Contact } from 'lucide-vue-next'
|
||||
|
||||
export const settingsGroups = [
|
||||
{
|
||||
@@ -46,12 +46,9 @@ export const settingsGroups = [
|
||||
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Measuring Tools',
|
||||
cards: [
|
||||
{ to: '/settings/measuringtooltypes', icon: Ruler, title: 'Measuring Tool Types', description: 'Manage measuring-tool subtypes (caliper, micrometer, thread gage...) + map colors' },
|
||||
],
|
||||
},
|
||||
// The "Measuring Tools" group is contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog),
|
||||
// not hardcoded here.
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
|
||||
@@ -94,6 +94,46 @@ class MeasuringToolsPlugin(BasePlugin):
|
||||
# plugin guide as the intentional empty-schema case.
|
||||
return []
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Contributes the Measuring Tools settings card that used
|
||||
# to be hardcoded in the core settingsNav.js catalog.
|
||||
return [
|
||||
{
|
||||
'group': 'Measuring Tools',
|
||||
'to': '/settings/measuringtooltypes',
|
||||
'icon': 'ruler',
|
||||
'title': 'Measuring Tool Types',
|
||||
'description': 'Manage measuring-tool subtypes (caliper, '
|
||||
'micrometer, thread gage...) + map colors',
|
||||
'position': 22,
|
||||
},
|
||||
]
|
||||
|
||||
def get_asset_presentation(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Tells core how to render + link the measuring_tool
|
||||
# asset type in global-search rows and cross-links.
|
||||
return [
|
||||
{
|
||||
'assettype': 'measuring_tool',
|
||||
'icon': 'ruler',
|
||||
'label': 'Measuring Tool',
|
||||
'route': '/measuringtools/{assetid}',
|
||||
},
|
||||
]
|
||||
|
||||
def get_map_overlays(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Declares a calibration-due badge overlay for the
|
||||
# shop-floor map; the map fetches the endpoint to decorate markers.
|
||||
return [
|
||||
{
|
||||
'id': 'calibration-due',
|
||||
'label': 'Calibration due',
|
||||
'endpoint': '/api/measuringtools/map-overlay',
|
||||
'style': 'badge',
|
||||
'legend': True,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})")
|
||||
|
||||
|
||||
@@ -72,5 +72,21 @@ class WarrantyPlugin(BasePlugin):
|
||||
},
|
||||
]
|
||||
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
# ADR-010 pilot. Warranty is asset-general so it shows on every detail
|
||||
# page (['*']). Declares the existing per-asset warranty endpoint the
|
||||
# WarrantyPanel already reads; the generic core panel renderer consumes
|
||||
# this instead of each detail view hand-composing the panel.
|
||||
return [
|
||||
{
|
||||
'id': 'warranty',
|
||||
'title': 'Warranty',
|
||||
'assettypes': ['*'],
|
||||
'endpoint': '/api/warranty/asset/{assetid}',
|
||||
'render': 'table',
|
||||
'position': 30,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"Warranty plugin initialized (v{self.meta.version})")
|
||||
|
||||
@@ -20,7 +20,10 @@ from .plugins import plugin_manager
|
||||
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
|
||||
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
|
||||
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
|
||||
__contract_version__ = '0.6.0'
|
||||
# 0.7.0: added the four ADR-010 frontend-contribution hooks (get_settings_cards,
|
||||
# get_asset_panels, get_map_overlays, get_asset_presentation), consumed by the
|
||||
# GET /api/pluginui/* endpoints. Four additive optional hooks, one minor bump.
|
||||
__contract_version__ = '0.7.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
@@ -119,6 +122,7 @@ CORE_BLUEPRINT_NAMES = (
|
||||
'users',
|
||||
'customfields',
|
||||
'setup',
|
||||
'pluginui',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from .auditlogs import auditlogs_bp
|
||||
from .users import users_bp
|
||||
from .customfields import customfields_bp
|
||||
from .setup import setup_bp
|
||||
from .pluginui import pluginui_bp
|
||||
|
||||
__all__ = [
|
||||
'auth_bp',
|
||||
@@ -42,4 +43,5 @@ __all__ = [
|
||||
'users_bp',
|
||||
'customfields_bp',
|
||||
'setup_bp',
|
||||
'pluginui_bp',
|
||||
]
|
||||
|
||||
108
shopdb/core/api/pluginui.py
Normal file
108
shopdb/core/api/pluginui.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Plugin frontend-contribution API endpoints (ADR-010).
|
||||
|
||||
Consumers for the four data-only presentation hooks on BasePlugin:
|
||||
get_settings_cards, get_asset_panels, get_map_overlays, get_asset_presentation.
|
||||
|
||||
Every endpoint copies the dashboard-widgets consumer semantics: skip disabled
|
||||
plugins, tag each contributed dict with its originating plugin name, fail loud
|
||||
in dev/test, isolate a broken plugin in prod. All routes are GET + jwt-optional,
|
||||
matching the other read-only core endpoints; they expose data, not mutations.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
pluginui_bp = Blueprint('pluginui', __name__)
|
||||
|
||||
|
||||
def _collect(hookname):
|
||||
"""Aggregate a data-only hook across enabled plugins.
|
||||
|
||||
Same access pattern as dashboard.get_widgets: skip disabled plugins, inject
|
||||
the plugin name on every returned dict, re-raise in dev/test, log-and-isolate
|
||||
a broken plugin in prod. Returns the merged list (unsorted).
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
if not pm:
|
||||
return []
|
||||
|
||||
merged = []
|
||||
for name, plugin in pm.get_all_plugins().items():
|
||||
if not pm.registry.is_enabled(name):
|
||||
continue
|
||||
try:
|
||||
for entry in getattr(plugin, hookname)() or []:
|
||||
entry['plugin'] = name
|
||||
merged.append(entry)
|
||||
except Exception:
|
||||
# fail loud in dev/test, isolate broke plugin in prod
|
||||
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||
raise
|
||||
current_app.logger.exception('Plugin %s %s failed', name, hookname)
|
||||
return merged
|
||||
|
||||
|
||||
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def settings_cards():
|
||||
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
||||
|
||||
The frontend merges these into the core settingsNav catalog and renders
|
||||
them in the settings rail + landing overview.
|
||||
"""
|
||||
cards = _collect('get_settings_cards')
|
||||
cards.sort(key=lambda card: card.get('position', 99))
|
||||
return success_response(cards)
|
||||
|
||||
|
||||
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def asset_panels():
|
||||
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
||||
|
||||
Query param assetid is required. A panel matches when its assettypes list
|
||||
contains the asset's type key or the wildcard '*'.
|
||||
"""
|
||||
assetid = request.args.get('assetid', type=int)
|
||||
if not assetid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetid is required')
|
||||
|
||||
asset = db.session.get(Asset, assetid)
|
||||
if not asset:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
assettype = asset.assettype.assettype if asset.assettype else None
|
||||
|
||||
matched = []
|
||||
for panel in _collect('get_asset_panels'):
|
||||
assettypes = panel.get('assettypes') or []
|
||||
if '*' in assettypes or (assettype and assettype in assettypes):
|
||||
matched.append(panel)
|
||||
matched.sort(key=lambda panel: panel.get('position', 99))
|
||||
return success_response(matched)
|
||||
|
||||
|
||||
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def map_overlays():
|
||||
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
||||
|
||||
The map fetches each overlay endpoint and decorates already-placed markers.
|
||||
"""
|
||||
overlays = _collect('get_map_overlays')
|
||||
overlays.sort(key=lambda overlay: overlay.get('position', 99))
|
||||
return success_response(overlays)
|
||||
|
||||
|
||||
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def asset_presentation():
|
||||
"""Merge enabled plugins' asset-type presentation entries (hook).
|
||||
|
||||
Search rows and cross-links use these to pick a type's icon and detail route.
|
||||
"""
|
||||
entries = _collect('get_asset_presentation')
|
||||
return success_response(entries)
|
||||
@@ -227,3 +227,90 @@ class BasePlugin(ABC):
|
||||
reports. Disabled plugins are skipped by the consumer.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_settings_cards(self) -> List[Dict]:
|
||||
"""
|
||||
Return settings-catalog card definitions (ADR-010).
|
||||
|
||||
Each card contributes an entry to the settings rail + landing overview
|
||||
without a plugin hand-editing the core settingsNav.js catalog.
|
||||
|
||||
Each card: {
|
||||
'group': str, # rail group title (created if new)
|
||||
'to': str, # settings route the card links to
|
||||
'icon': str, # string key, mapped to a Lucide icon core-side
|
||||
'title': str, # card title
|
||||
'description': str, # one-line blurb
|
||||
'position': int, # order within the group
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/settings-cards, which merges enabled
|
||||
plugins' cards into the core catalog. Disabled plugins are skipped;
|
||||
a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
"""
|
||||
Return asset-detail extension-panel definitions (ADR-010).
|
||||
|
||||
A generic core AssetPanel component renders each panel on the asset
|
||||
detail pages whose type matches, fetching the panel's endpoint. This
|
||||
replaces hand-composing a plugin panel component into each detail view.
|
||||
|
||||
Each panel: {
|
||||
'id': str, # stable panel id
|
||||
'title': str, # panel heading
|
||||
'assettypes': List[str], # AssetType keys it appears on; ['*'] = all
|
||||
'endpoint': str, # data endpoint (may contain {assetid})
|
||||
'render': str, # 'keyvalue' | 'table' | 'badge'
|
||||
'position': int, # order among panels
|
||||
}
|
||||
|
||||
Consumed by GET /api/assets/{assetid}/panels via the pluginui consumer,
|
||||
which returns the panels matching that asset's type. Disabled plugins
|
||||
are skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
A panel needing bespoke UI is out of scope for the data-only hook.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_map_overlays(self) -> List[Dict]:
|
||||
"""
|
||||
Return shop-floor map overlay/decoration definitions (ADR-010).
|
||||
|
||||
The map is data-driven off asset types + positions; an overlay adds
|
||||
decoration data (a badge or ring on already-placed markers) plus a
|
||||
legend entry, without the plugin shipping any map code.
|
||||
|
||||
Each overlay: {
|
||||
'id': str, # stable overlay id
|
||||
'label': str, # legend label
|
||||
'endpoint': str, # returns [{assetid, color, label}] to decorate
|
||||
'style': str, # 'badge' | 'ring'
|
||||
'legend': bool, # True to add a legend entry
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/map-overlays. Disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_asset_presentation(self) -> List[Dict]:
|
||||
"""
|
||||
Return asset-type presentation/routing definitions (ADR-010).
|
||||
|
||||
Declares how a plugin-owned asset type renders in global-search rows
|
||||
and cross-links: which icon to show and where the detail link points,
|
||||
so core never hardcodes a plugin's route or icon.
|
||||
|
||||
Each entry: {
|
||||
'assettype': str, # AssetType.assettype key the plugin owns
|
||||
'icon': str, # string key, mapped to a Lucide icon core-side
|
||||
'label': str, # human label for the type
|
||||
'route': str, # detail-route pattern (may contain {assetid})
|
||||
}
|
||||
|
||||
Consumed by GET /api/pluginui/asset-presentation. Disabled plugins are
|
||||
skipped; a broken plugin is isolated in prod, re-raised in dev/test.
|
||||
"""
|
||||
return []
|
||||
|
||||
144
tests/test_core/test_pluginui.py
Normal file
144
tests/test_core/test_pluginui.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Tests for the ADR-010 frontend-contribution consumers (/api/pluginui/*).
|
||||
|
||||
Pins the wiring added for the four data-only presentation hooks
|
||||
(get_settings_cards, get_asset_panels, get_map_overlays,
|
||||
get_asset_presentation): each endpoint aggregates enabled plugins'
|
||||
contributions, tags them with the plugin name, and drops disabled plugins.
|
||||
Also asserts the pilot plugins (warranty, measuringtools) contribute.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
"""
|
||||
|
||||
|
||||
def _data(client, path, headers):
|
||||
response = client.get(path, headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
payload = response.get_json()['data']
|
||||
assert isinstance(payload, list)
|
||||
return payload
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# settings-cards
|
||||
# =============================================================================
|
||||
|
||||
def test_settings_cards_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools contributes its settings card, tagged + position-sorted."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
cards = _data(client, '/api/pluginui/settings-cards', auth_headers)
|
||||
plugins = {c.get('plugin') for c in cards}
|
||||
assert 'measuringtools' in plugins
|
||||
card = next(c for c in cards if c['plugin'] == 'measuringtools')
|
||||
assert card['group'] == 'Measuring Tools'
|
||||
assert card['to'] == '/settings/measuringtooltypes'
|
||||
positions = [c.get('position', 99) for c in cards]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
def test_settings_cards_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
"""A disabled measuringtools drops its settings card."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
|
||||
cards = _data(client, '/api/pluginui/settings-cards', auth_headers)
|
||||
assert 'measuringtools' not in {c.get('plugin') for c in cards}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# asset-panels
|
||||
# =============================================================================
|
||||
|
||||
def _make_asset(db):
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer', pluginname='computer',
|
||||
tablename='computer', description='c')
|
||||
db.session.add(atype)
|
||||
db.session.commit()
|
||||
asset = Asset(assetnumber='AST-PANEL01', assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def test_asset_panels_require_assetid(client, auth_headers):
|
||||
"""asset-panels without assetid is a 400."""
|
||||
response = client.get('/api/pluginui/asset-panels', headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_asset_panels_match_asset_type(app, client, db, auth_headers, monkeypatch):
|
||||
"""Warranty's ['*'] panel matches any asset; tagged with its plugin."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
assetid = _make_asset(db)
|
||||
|
||||
panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers)
|
||||
warranty = next((p for p in panels if p.get('plugin') == 'warranty'), None)
|
||||
assert warranty is not None, 'warranty asset panel missing'
|
||||
assert warranty['id'] == 'warranty'
|
||||
assert warranty['endpoint'] == '/api/warranty/asset/{assetid}'
|
||||
assert warranty['render'] in ('keyvalue', 'table', 'badge')
|
||||
|
||||
|
||||
def test_asset_panels_skip_disabled_plugin(app, client, db, auth_headers, monkeypatch):
|
||||
"""A disabled warranty drops its asset panel."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty')
|
||||
assetid = _make_asset(db)
|
||||
|
||||
panels = _data(client, f'/api/pluginui/asset-panels?assetid={assetid}', auth_headers)
|
||||
assert 'warranty' not in {p.get('plugin') for p in panels}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# map-overlays
|
||||
# =============================================================================
|
||||
|
||||
def test_map_overlays_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools contributes its calibration-due overlay."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
overlays = _data(client, '/api/pluginui/map-overlays', auth_headers)
|
||||
overlay = next((o for o in overlays if o.get('plugin') == 'measuringtools'), None)
|
||||
assert overlay is not None
|
||||
assert overlay['id'] == 'calibration-due'
|
||||
assert overlay['legend'] is True
|
||||
|
||||
|
||||
def test_map_overlays_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
|
||||
overlays = _data(client, '/api/pluginui/map-overlays', auth_headers)
|
||||
assert 'measuringtools' not in {o.get('plugin') for o in overlays}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# asset-presentation
|
||||
# =============================================================================
|
||||
|
||||
def test_asset_presentation_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools declares presentation for its measuring_tool type."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||
entry = next((e for e in entries if e.get('assettype') == 'measuring_tool'), None)
|
||||
assert entry is not None
|
||||
assert entry['plugin'] == 'measuringtools'
|
||||
assert entry['route'] == '/measuringtools/{assetid}'
|
||||
|
||||
|
||||
def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
|
||||
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||
assert 'measuring_tool' not in {e.get('assettype') for e in entries}
|
||||
@@ -156,6 +156,23 @@ def test_plugin_get_reports_is_iterable(plugin_instances, name):
|
||||
)
|
||||
|
||||
|
||||
def test_baseplugin_has_frontend_contribution_hooks():
|
||||
"""The four ADR-010 frontend-contribution hooks are on the contract (0.7.0)."""
|
||||
for hook in ('get_settings_cards', 'get_asset_panels',
|
||||
'get_map_overlays', 'get_asset_presentation'):
|
||||
assert hasattr(BasePlugin, hook), f'{hook} missing from BasePlugin'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_frontend_hooks_return_lists(plugin_instances, name):
|
||||
"""The four ADR-010 hooks default to a list on every bundled plugin."""
|
||||
plugin = plugin_instances[name]
|
||||
assert isinstance(plugin.get_settings_cards(), list)
|
||||
assert isinstance(plugin.get_asset_panels(), list)
|
||||
assert isinstance(plugin.get_map_overlays(), list)
|
||||
assert isinstance(plugin.get_asset_presentation(), list)
|
||||
|
||||
|
||||
def test_get_services_hook_has_consumer(app):
|
||||
"""get_services is consumed by plugin_manager.get_service (no dead hook)."""
|
||||
with app.app_context():
|
||||
|
||||
Reference in New Issue
Block a user