Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework: - Per-plugin Alembic migration chains (ADR-008): every bundled plugin carries its own chain with a stamp-only anchor at the ownership cutover; new plugin schema lands in plugins/<name>/migrations/, never the core chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the shared alembic template (engine URL resolution) and taught the metadata filter to include FK-referenced core tables. - Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin; a disabled plugin's pages redirect to the dashboard via a cached, fail-open check against the new public GET /api/plugins/enabled. - get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute report cards; warranty and toner cards moved off the hardcoded list. Reports: - Hub grouped by category with search; inline reports render at the top, are URL-backed (?report=id, back-button and deep links work), expose their server-side filter params as controls, and export CSV. Warranty and Toner pages gained CSV export. - Deleted the dead legacy Warranty Status report (always-zero buckets from a retired column). Theming and fonts: - Inter (variable) bundled locally via @fontsource, replacing the Google Fonts Roboto import - air-gapped installs now render correctly; tables use tabular numerals. - Optional brand_primary_dark_color, brand_accent_color, brand_sidebar_color settings applied to CSS vars at bootstrap. USB frontend repair (views were reading a dead legacy shape): - List/detail/form and the employee profile USB panels remapped to the real API shape (device_id/device_desc/checkinoutlog); employee panels now use /usb/checkouts endpoints; external-mode /usb/checkouts/active honors the badge filter; dead client methods pruned. Also: warranties list page no longer requires login (matches app convention); collector doc rewritten with a GE-Enforce integration guide and paste-ready PowerShell reporter; ADR index and CHANGELOG updated. Verified: 323 tests pass, naming/style green, frontend builds, plugin migration dry-run green on scratch MySQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
123
docs/adr/ADR-008-plugin-migration-ownership.md
Normal file
123
docs/adr/ADR-008-plugin-migration-ownership.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ADR-008: Plugin migration ownership (per-plugin chains from the cutover)
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-10
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** the "Migration strategy (resolved)" section of ADR-004
|
||||
|
||||
## Context
|
||||
|
||||
ADR-004 resolved a Phase 7B footgun by folding every bundled plugin's tables
|
||||
into the single core Alembic chain (migration `7c04_fold_plugin_schema`), and
|
||||
later core migrations (`7d04`..`7d16`) kept adding plugin schema directly to the
|
||||
core chain. At the time this was the safe choice: bundled-plugin baselines and
|
||||
the core baseline had both been creating the same tables, so
|
||||
`flask plugin upgrade-all` would collide with `flask db upgrade`.
|
||||
|
||||
That resolution left the per-plugin Alembic engine
|
||||
(`shopdb/plugins/migrations.py`, `shopdb/plugins/alembic_template.py`, the
|
||||
`alembic_version_<plugin>` version tables, and `flask plugin upgrade-all`) fully
|
||||
built but unused for the bundled plugins. The framework is the product (per the
|
||||
project's charter); a plugin that cannot own its own schema is not really a
|
||||
plugin. Sister sites that adopt or fork a plugin need its schema history to
|
||||
travel with the plugin, not be entangled in the host's core chain. Keeping every
|
||||
future plugin table change in the core chain also means the core chain grows
|
||||
without bound and a plugin can never be cleanly removed.
|
||||
|
||||
The blocker ADR-004 worried about (double table creation) only exists while a
|
||||
plugin's own migration tries to CREATE tables the core chain already created.
|
||||
That is avoidable: history is immutable, so the tables already built by the core
|
||||
chain stay owned by the core chain; only NEW schema needs a new home.
|
||||
|
||||
## Decision
|
||||
|
||||
Ownership splits at a fixed cutover, the current core-chain head.
|
||||
|
||||
1. **Core chain owns history through its head.** The core Alembic chain
|
||||
(baseline `68b3947ae14f` .. head `7d16_directoryemployees`) remains
|
||||
authoritative for every table that exists at the cutover, including the
|
||||
bundled-plugin tables it created. Those migrations are immutable and are not
|
||||
rewritten. `flask db upgrade` continues to reproduce the full schema.
|
||||
|
||||
2. **Plugin chains own plugin schema going forward.** From the cutover forward,
|
||||
any change to a plugin's schema lands as
|
||||
`plugins/<name>/migrations/versions/000N_*.py` in that plugin's own chain,
|
||||
never in the core chain. The core chain is reserved for core tables.
|
||||
|
||||
3. **Every table-owning bundled plugin gets a `0001` anchor.** Each such plugin
|
||||
carries a migration chain whose first revision is a stamp-only no-op:
|
||||
`upgrade()` does nothing because the core chain already created the tables.
|
||||
The anchor exists so the plugin chain has a base that
|
||||
`flask plugin upgrade-all` can stamp into the per-plugin version table
|
||||
`alembic_version_<plugin>`. Blueprint-only plugins that own no tables get no
|
||||
chain.
|
||||
|
||||
4. **Deploy and upgrade sequence.** A deploy runs `flask db upgrade`
|
||||
(core chain, creates everything through the head) then
|
||||
`flask plugin upgrade-all` (stamps every plugin anchor and applies any later
|
||||
per-plugin migrations). The same two commands upgrade an existing install;
|
||||
both are idempotent. The registry (`instance/plugins.json`) tracks each
|
||||
plugin's applied revisions in `migrations_applied`.
|
||||
|
||||
5. **Table-ownership registry.** `PLUGIN_TABLE_OWNERS` in
|
||||
`shopdb/plugins/alembic_template.py` is the explicit map of which tables each
|
||||
plugin owns; it is kept in sync with the plugins' `__tablename__` declarations
|
||||
and pinned by `tests/test_plugin_migrations.py`.
|
||||
|
||||
External (out-of-tree) plugins per ADR-003 already shipped their own chains;
|
||||
this ADR brings the bundled plugins onto the same model, so there is one rule
|
||||
for all plugins.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- A plugin's schema history travels with the plugin. Adopting or forking sites
|
||||
get the plugin's migrations, not a slice of someone else's core chain.
|
||||
- The core chain stops accreting plugin schema; it stays about core tables.
|
||||
- A plugin can be evolved (or, with its own downgrade, removed) independently.
|
||||
- The long-built per-plugin Alembic engine is finally exercised on every deploy,
|
||||
so it cannot silently rot.
|
||||
|
||||
### Negative / cost
|
||||
|
||||
- Two migrate commands per deploy instead of one. Documented in `docs/DEPLOY.md`
|
||||
and `docs/UPGRADE.md`; both are idempotent so the cost is one extra safe call.
|
||||
- A plugin author must now put new tables in the plugin chain and register them
|
||||
in `PLUGIN_TABLE_OWNERS`, rather than autogenerating into the core chain. The
|
||||
`flask plugin new` guidance and this ADR spell that out.
|
||||
- The cutover is a discontinuity: tables created before it are core-owned,
|
||||
tables created after it are plugin-owned. The line is the core-chain head at
|
||||
this ADR's date, recorded here so it is unambiguous.
|
||||
|
||||
### Neutral
|
||||
|
||||
- No schema changes and no data migration: the anchors are no-ops. A fresh
|
||||
install and an existing install converge to the same state.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Keep everything in the core chain (status quo per ADR-004).** Simplest
|
||||
operationally but defeats the plugin-as-product goal: plugin schema cannot
|
||||
travel, the core chain grows without bound, and plugins can never be cleanly
|
||||
removed. Rejected.
|
||||
2. **Rewrite history so plugin tables move out of the core chain into plugin
|
||||
`0001` CREATE migrations.** Would make each plugin chain self-contained from
|
||||
empty, but breaks the immutability rule, forces every existing site to
|
||||
re-run a rewritten chain, and re-introduces the exact double-creation footgun
|
||||
ADR-004 fixed. Rejected.
|
||||
3. **Anchor that CREATEs tables with `IF NOT EXISTS` guards.** Lets a from-empty
|
||||
install build plugin tables from the plugin chain, but then two chains both
|
||||
claim the same tables and drift can diverge silently. The no-op anchor keeps
|
||||
a single authoritative creator (the core chain) for cutover-era tables.
|
||||
Rejected.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-003 (plugin distribution; external plugins already ship chains)
|
||||
- ADR-004 (deployment topology; this ADR supersedes its migration-strategy note)
|
||||
- `shopdb/plugins/alembic_template.py` (`PLUGIN_TABLE_OWNERS`, shared env runner)
|
||||
- `shopdb/plugins/migrations.py`, `shopdb/plugins/cli.py` (`upgrade-all`)
|
||||
- `plugins/<name>/migrations/` (per-plugin chains and `0001` anchors)
|
||||
- `tests/test_plugin_migrations.py` (ownership + chain + idempotency guards)
|
||||
- `docs/DEPLOY.md`, `docs/UPGRADE.md`, `docs/PLUGINS.md` (deploy sequence)
|
||||
146
docs/adr/ADR-009-frontend-plugin-gating.md
Normal file
146
docs/adr/ADR-009-frontend-plugin-gating.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# ADR-009: Frontend plugin gating
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-10
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** none
|
||||
|
||||
## Context
|
||||
|
||||
The backend plugin system (ADR-002, ADR-003) lets an operator disable a
|
||||
plugin. Disabling it unregisters the plugin's API blueprint, drops its
|
||||
rows from global search (see `test_search_disabled`), and removes its
|
||||
entry from `get_navigation_items()`, so the disabled feature's sidebar
|
||||
link disappears.
|
||||
|
||||
The frontend told a different story. Every plugin's Vue routes and views
|
||||
ship inside the core bundle. `frontend/src/router/index.js` auto-discovers
|
||||
them with `import.meta.glob('./routes/*.js')`, so a route like `/usb` or
|
||||
`/printers/3` is registered regardless of whether the owning backend
|
||||
plugin is enabled. A user who typed the URL, followed a stale bookmark,
|
||||
or clicked a cross-link reached a page whose API calls all 404, landing
|
||||
on a broken shell instead of a clean redirect. The navigation was already
|
||||
data-driven; direct-URL reachability was the gap.
|
||||
|
||||
There is no frontend plugin system yet. The product is packaged as one
|
||||
core Flask app plus backend-only plugins; the Vue app is monolithic and
|
||||
build-time static. Any gating has to live in core frontend code because
|
||||
that is the only place the plugin routes exist.
|
||||
|
||||
## Decision
|
||||
|
||||
### Step 1 (this ADR, implemented): route-level gating
|
||||
|
||||
Plugin-owned frontend routes are gated against the backend's enabled-plugin
|
||||
list. This is the whole of what ships now.
|
||||
|
||||
1. **Enabled-list endpoint.** A new `GET /api/plugins/enabled`
|
||||
(`jwt_required(optional=True)`) returns a flat JSON array of enabled
|
||||
plugin name strings and nothing else. It is a cheap registry read
|
||||
(`registry.get_enabled_plugins()`), no database access. Exposing it to
|
||||
anonymous callers is safe because `GET /api/dashboard/navigation`
|
||||
already leaks the same enabled/disabled signal, and unauthenticated
|
||||
kiosk routes (`/tv`) need the answer too. It carries no metadata, so it
|
||||
reveals strictly less than the admin-gated `GET /api/plugins`.
|
||||
|
||||
2. **Route tagging.** Every plugin-owned route carries `meta.plugin =
|
||||
'<pluginname>'`. This covers the per-plugin route modules
|
||||
(`routes/computers.js`, `routes/equipment.js`, ...) and the
|
||||
plugin-owned routes that physically live in core files: the
|
||||
PC-relationships and toner reports, the slide manager and `/tv`
|
||||
dashboard (slides), the printer-QR and USB-label print pages, and the
|
||||
employee-detail page. Genuinely core routes (dashboard, search, map,
|
||||
applications, reference-data settings) stay untagged and are never
|
||||
gated.
|
||||
|
||||
3. **Cached fetch, fail-open.** A composable
|
||||
(`composables/enabledPlugins.js`) fetches the list exactly once behind
|
||||
a cached promise. If the fetch fails or returns a non-array, the code
|
||||
fails **open**: every plugin is treated as enabled. A transient API
|
||||
error must never brick navigation. The cost is that a disabled
|
||||
plugin's page is briefly reachable during an outage, which is
|
||||
acceptable because its API calls would fail anyway and the next
|
||||
successful fetch closes the gap.
|
||||
|
||||
4. **Router guard.** `router.beforeEach` awaits the cached fetch when the
|
||||
target route has `meta.plugin`. If that plugin is not enabled it
|
||||
redirects to `/` and raises an info toast. The endpoint is
|
||||
jwt-optional, so the guard works for both authenticated pages and the
|
||||
unauthenticated `/tv` kiosk route.
|
||||
|
||||
This is intentionally a thin layer. It does not change the plugin
|
||||
contract surface, so `__contract_version__` does not move: adding a core
|
||||
HTTP endpoint and tagging core-shipped routes are not plugin-contract
|
||||
changes. Plugins still ship no frontend code of their own.
|
||||
|
||||
### Non-goals for step 1
|
||||
|
||||
- No build-time or runtime loading of plugin-authored Vue code.
|
||||
- No per-permission or per-role route gating (that stays with the
|
||||
existing `requiresAuth` / `requiresAdmin` meta flags).
|
||||
- No removal of disabled routes from the route table; they remain
|
||||
registered and are intercepted by the guard. Keeping them registered
|
||||
avoids a rebuild when a plugin is toggled and keeps the redirect path
|
||||
simple.
|
||||
|
||||
## Future direction (PROPOSED, not implemented)
|
||||
|
||||
Step 1 gates routes that core already owns. The longer-term goal is a
|
||||
real frontend-plugin contract where a plugin ships its own frontend and
|
||||
core discovers it, mirroring the backend model. Sketch:
|
||||
|
||||
1. **Plugin-owned frontend tree.** Each plugin gains a
|
||||
`plugins/<name>/frontend/` directory holding its route module, views,
|
||||
and any plugin-specific components. Core stops carrying `views/usb`,
|
||||
`views/printers`, and so on.
|
||||
|
||||
2. **Build-time discovery.** The Vite build discovers plugin frontends
|
||||
with a glob over `plugins/*/frontend/routes.js` (analogous to today's
|
||||
`import.meta.glob('./routes/*.js')`), so a plugin's presence in the
|
||||
tree is what puts its routes in the bundle. Combined with the step-1
|
||||
enabled-list gate, a plugin that is absent from the build ships no
|
||||
code and a plugin that is present-but-disabled is route-gated at
|
||||
runtime.
|
||||
|
||||
3. **Shared component + registration contract.** Plugins register into
|
||||
named extension points instead of editing core files: an `iconMap`
|
||||
registration for nav/asset icons, asset-detail panels, map-marker
|
||||
renderers, and search-result renderers (the "Frontend hook contract"
|
||||
already listed as deferred in the project CLAUDE.md). Core exposes a
|
||||
stable set of shared components (form controls, detail-page shells,
|
||||
table primitives) as the plugin frontend's only allowed core imports,
|
||||
the frontend analogue of the `shopdb.api` namespace.
|
||||
|
||||
4. **Versioned frontend contract.** The shared-component and
|
||||
registration surface would be versioned the same way the backend
|
||||
contract is (ADR-002), so a plugin frontend can declare the core
|
||||
frontend range it needs.
|
||||
|
||||
### Tradeoffs of the future direction
|
||||
|
||||
- **Pro:** true plugin self-containment; a site can drop in or remove a
|
||||
plugin (frontend and backend together) without patching core; smaller
|
||||
core; clearer ownership.
|
||||
- **Con:** significant build-system work (per-plugin Vite entry
|
||||
discovery, code-splitting, dev-server HMR across the plugin tree); a
|
||||
new versioned frontend contract to maintain and document; a migration
|
||||
that moves ten plugins' worth of views out of core; risk of a leaky
|
||||
shared-component surface becoming an accidental contract. The payoff
|
||||
only matters once external (out-of-tree) plugins with their own
|
||||
frontends are a real requirement. Until then, step 1's route gating
|
||||
delivers the user-visible correctness (no reachable dead pages) at a
|
||||
fraction of the cost.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Disabling a backend plugin now makes its frontend routes redirect to
|
||||
the dashboard instead of loading a broken shell. Behavior matches the
|
||||
already-dynamic navigation.
|
||||
- One extra lightweight request at app boot (`GET /api/plugins/enabled`),
|
||||
cached for the session.
|
||||
- Fail-open means gating is a UX guardrail, not a security control. It is
|
||||
not a substitute for backend authorization: the API still enforces auth
|
||||
and the disabled plugin's endpoints are simply unregistered. Never rely
|
||||
on route gating to protect data.
|
||||
- The future frontend-plugin contract remains open work; this ADR records
|
||||
the direction and its cost so a later decision can pick it up.
|
||||
@@ -20,6 +20,8 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [005](ADR-005-equipment-vs-measuringtools.md) | Equipment vs measuringtools plugin scope | ACCEPTED |
|
||||
| [006](ADR-006-collector-contract.md) | Plugin collector contract pattern | 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 |
|
||||
| [009](ADR-009-frontend-plugin-gating.md) | Frontend plugin route gating | ACCEPTED |
|
||||
|
||||
## Authoring
|
||||
|
||||
|
||||
Reference in New Issue
Block a user