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
|
## 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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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>
|
</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>
|
||||||
|
|||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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})")
|
||||||
|
|
||||||
|
|||||||
@@ -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})")
|
||||||
|
|||||||
@@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
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.
|
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 []
|
||||||
|
|||||||
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):
|
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():
|
||||||
|
|||||||
Reference in New Issue
Block a user