Accept + implement ADR-010 frontend plugin hooks (contract 0.7.0)
Some checks failed
CI / backend (push) Failing after 1m2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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:
cproudlock
2026-07-11 19:38:32 -04:00
parent 9eed3745fc
commit 24f67d6ac5
17 changed files with 625 additions and 19 deletions

View File

@@ -8,7 +8,7 @@ the live code, not aspiration. The authoritative hook reference is
## Current version ## 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 `__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. 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.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.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.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 (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 `core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
0.2 baseline. 0.2 baseline.
@@ -49,6 +50,7 @@ land with a new or amended ADR.
| `get_navigation_items` | Sidebar menu entries | | `get_navigation_items` | Sidebar menu entries |
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` | | `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) | | `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) | | 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 | | Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |
| `get_provisioning_note` | Setup-wizard transparency note for extra tables | | `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 | | 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) | | 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) | | 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) |

View File

@@ -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`: The framework declares its contract version in `shopdb/__init__.py`:
```python ```python
__contract_version__ = '0.6.0' __contract_version__ = '0.7.0'
``` ```
Each plugin's `manifest.json` declares the range of contract versions it supports: 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 reports sorted into category groups by the frontend (disabled plugins are
skipped; a broken plugin is isolated in prod, re-raised in dev/test). 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]` ### `get_provisioning_note() -> Optional[Dict]`
Transparency note the setup wizard shows the moment a site checks this plugin Transparency note the setup wizard shows the moment a site checks this plugin

View File

@@ -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_navigation_items` | Plugin shows up in the sidebar nav |
| `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page | | `get_dashboard_widgets` | Plugin's dashboard widget appears on the home page |
| `get_reports` | Plugin's report cards appear on the Reports hub | | `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>` | | `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. Each hook has a default that does nothing. Override only what your plugin needs.

View File

@@ -1,7 +1,8 @@
# ADR-010: Frontend plugin hook contract # ADR-010: Frontend plugin hook contract
- **Status:** PROPOSED - **Status:** ACCEPTED
- **Date:** 2026-07-11 - **Date:** 2026-07-11
- **Accepted:** 2026-07-11
- **Deciders:** cproudlock - **Deciders:** cproudlock
- **Supersedes:** none - **Supersedes:** none
@@ -112,7 +113,7 @@ not pursued.
## Decision ## 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 the four presentation surfaces a generic core renderer can serve, and keep
**file-convention glob discovery** (Option C) as the deferred mechanism for the **file-convention glob discovery** (Option C) as the deferred mechanism for the
residual cases where a real component is unavoidable. Do not pursue runtime residual cases where a real component is unavoidable. Do not pursue runtime

View File

@@ -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 | | [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 | | [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 | | [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 | | [011](ADR-011-machines-rename.md) | Machines rename + modeltypes retyping | ACCEPTED |
## Authoring ## Authoring

View 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 }
}

View File

@@ -25,7 +25,10 @@
</template> </template>
<script setup> <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> </script>
<style scoped> <style scoped>

View File

@@ -36,17 +36,20 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { settingsGroups as groups } from './settingsNav' import { useSettingsCatalog } from '../../composables/settingsCatalog'
const route = useRoute() const route = useRoute()
// Core settings groups merged with plugin-contributed cards (ADR-010).
const { groups } = useSettingsCatalog()
const search = ref('') const search = ref('')
// Filter the rail by title/description; drop groups that end up empty. // Filter the rail by title/description; drop groups that end up empty.
const visibleGroups = computed(() => { const visibleGroups = computed(() => {
const term = search.value.trim().toLowerCase() const term = search.value.trim().toLowerCase()
if (!term) return groups if (!term) return groups.value
return groups return groups.value
.map(g => ({ .map(g => ({
title: g.title, title: g.title,
cards: g.cards.filter(c => cards: g.cards.filter(c =>

View File

@@ -1,7 +1,7 @@
// Shared settings navigation catalog. // Shared settings navigation catalog.
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the // Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
// grouping lives in one place. // 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 = [ export const settingsGroups = [
{ {
@@ -46,12 +46,9 @@ export const settingsGroups = [
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' }, { to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
], ],
}, },
{ // The "Measuring Tools" group is contributed by the measuringtools plugin via
title: 'Measuring Tools', // the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog),
cards: [ // not hardcoded here.
{ to: '/settings/measuringtooltypes', icon: Ruler, title: 'Measuring Tool Types', description: 'Manage measuring-tool subtypes (caliper, micrometer, thread gage...) + map colors' },
],
},
{ {
title: 'Locations & Organization', title: 'Locations & Organization',
cards: [ cards: [

View File

@@ -94,6 +94,46 @@ class MeasuringToolsPlugin(BasePlugin):
# plugin guide as the intentional empty-schema case. # plugin guide as the intentional empty-schema case.
return [] 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: def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})") logger.info(f"Measuring-tools plugin initialized (v{self.meta.version})")

View File

@@ -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: def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"Warranty plugin initialized (v{self.meta.version})") logger.info(f"Warranty plugin initialized (v{self.meta.version})")

View File

@@ -20,7 +20,10 @@ from .plugins import plugin_manager
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction. # 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 # 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. # 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 # Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent # plugin-contract version above are distinct series with independent
@@ -119,6 +122,7 @@ CORE_BLUEPRINT_NAMES = (
'users', 'users',
'customfields', 'customfields',
'setup', 'setup',
'pluginui',
) )

View File

@@ -20,6 +20,7 @@ from .auditlogs import auditlogs_bp
from .users import users_bp from .users import users_bp
from .customfields import customfields_bp from .customfields import customfields_bp
from .setup import setup_bp from .setup import setup_bp
from .pluginui import pluginui_bp
__all__ = [ __all__ = [
'auth_bp', 'auth_bp',
@@ -42,4 +43,5 @@ __all__ = [
'users_bp', 'users_bp',
'customfields_bp', 'customfields_bp',
'setup_bp', 'setup_bp',
'pluginui_bp',
] ]

108
shopdb/core/api/pluginui.py Normal file
View 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)

View File

@@ -227,3 +227,90 @@ class BasePlugin(ABC):
reports. Disabled plugins are skipped by the consumer. reports. Disabled plugins are skipped by the consumer.
""" """
return [] 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 []

View 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}

View File

@@ -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): def test_get_services_hook_has_consumer(app):
"""get_services is consumed by plugin_manager.get_service (no dead hook).""" """get_services is consumed by plugin_manager.get_service (no dead hook)."""
with app.app_context(): with app.app_context():