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:
29
tests/test_core/test_plugins_enabled.py
Normal file
29
tests/test_core/test_plugins_enabled.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""GET /api/plugins/enabled: the flat enabled-plugin-name list that drives
|
||||
frontend route gating (ADR-009).
|
||||
|
||||
Pins two properties: the endpoint is reachable anonymously and returns a
|
||||
plain array of names, and a disabled plugin's name drops out of that array
|
||||
(so its frontend routes gate off), mirroring the search-disabled pattern.
|
||||
"""
|
||||
|
||||
|
||||
def test_enabled_anonymous_returns_array(client):
|
||||
"""No auth required (jwt-optional): returns a flat array of name strings."""
|
||||
resp = client.get('/api/plugins/enabled')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert isinstance(data, list)
|
||||
assert all(isinstance(name, str) for name in data)
|
||||
|
||||
|
||||
def test_disabled_plugin_absent(app, client, monkeypatch):
|
||||
"""A disabled plugin's name is missing; enabled ones remain present."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
# Pretend printers is enabled but usb is not.
|
||||
monkeypatch.setattr(pm.registry, 'get_enabled_plugins',
|
||||
lambda: ['printers', 'computers'])
|
||||
resp = client.get('/api/plugins/enabled')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert 'printers' in data
|
||||
assert 'usb' not in data
|
||||
55
tests/test_core/test_reports_hook.py
Normal file
55
tests/test_core/test_reports_hook.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for the get_reports hook consumer (GET /api/reports).
|
||||
|
||||
Pins the wiring added for BasePlugin.get_reports: the reports list merges
|
||||
enabled plugins' report cards after the static core reports and skips disabled
|
||||
ones. Also guards the removed legacy warranty-status core report.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
"""
|
||||
|
||||
|
||||
def _report_ids(client, headers):
|
||||
response = client.get('/api/reports', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
reports = response.get_json()['data']['reports']
|
||||
assert isinstance(reports, list)
|
||||
return reports, {r['id'] for r in reports}
|
||||
|
||||
|
||||
def test_reports_include_core_reports(client, auth_headers):
|
||||
"""The static core reports are always listed."""
|
||||
_, ids = _report_ids(client, auth_headers)
|
||||
assert 'equipment-by-type' in ids
|
||||
assert 'pc-relationships' in ids
|
||||
|
||||
|
||||
def test_legacy_warranty_status_report_absent(client, auth_headers):
|
||||
"""The dead warranty-status core report was removed."""
|
||||
_, ids = _report_ids(client, auth_headers)
|
||||
assert 'warranty-status' not in ids
|
||||
|
||||
|
||||
def test_enabled_plugin_reports_are_merged(app, client, auth_headers, monkeypatch):
|
||||
"""Enabled plugins implementing get_reports contribute their cards."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
reports, ids = _report_ids(client, auth_headers)
|
||||
# warranty + printers implement get_reports
|
||||
assert 'warranty' in ids
|
||||
assert 'toner' in ids
|
||||
# merged cards carry their originating plugin name
|
||||
warranty_card = next(r for r in reports if r['id'] == 'warranty')
|
||||
assert warranty_card['plugin'] == 'warranty'
|
||||
assert warranty_card['route'] == '/reports/warranty'
|
||||
|
||||
|
||||
def test_disabled_plugin_reports_drop_out(app, client, auth_headers, monkeypatch):
|
||||
"""A disabled plugin's report cards disappear from the list."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'warranty')
|
||||
|
||||
_, ids = _report_ids(client, auth_headers)
|
||||
assert 'warranty' not in ids
|
||||
assert 'toner' in ids # printers still enabled
|
||||
@@ -137,6 +137,25 @@ def test_baseplugin_has_dashboard_widgets_hook():
|
||||
assert hasattr(BasePlugin, 'get_dashboard_widgets')
|
||||
|
||||
|
||||
def test_baseplugin_has_reports_hook():
|
||||
"""The reports hook is on the contract surface (contract 0.6.0)."""
|
||||
assert hasattr(BasePlugin, 'get_reports')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', BUNDLED_PLUGINS)
|
||||
def test_plugin_get_reports_is_iterable(plugin_instances, name):
|
||||
"""get_reports returns a list (default empty)."""
|
||||
plugin = plugin_instances[name]
|
||||
reports = plugin.get_reports()
|
||||
assert isinstance(reports, list)
|
||||
# Each card carries an id/name/category plus exactly one of route/endpoint.
|
||||
for card in reports:
|
||||
assert card.get('id') and card.get('name') and card.get('category')
|
||||
assert ('route' in card) ^ ('endpoint' in card), (
|
||||
f'{name} report card must have exactly one of route/endpoint'
|
||||
)
|
||||
|
||||
|
||||
def test_get_services_hook_has_consumer(app):
|
||||
"""get_services is consumed by plugin_manager.get_service (no dead hook)."""
|
||||
with app.app_context():
|
||||
@@ -187,7 +206,11 @@ _PLUGIN_IMPORT_RE = re.compile(
|
||||
|
||||
def _plugin_source_files():
|
||||
root = Path(__file__).resolve().parent.parent / 'plugins'
|
||||
return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts]
|
||||
# Skip migrations/ - Alembic env.py is framework glue that legitimately
|
||||
# calls the shared runner in shopdb.plugins.alembic_template (see ADR-008),
|
||||
# not plugin domain code. Mirrors the style check excluding versions/.
|
||||
return [p for p in root.rglob('*.py')
|
||||
if '__pycache__' not in p.parts and 'migrations' not in p.parts]
|
||||
|
||||
|
||||
def test_plugins_only_import_contract_surface():
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""Plugin table-ownership tests.
|
||||
"""Per-plugin Alembic migration-chain guard tests.
|
||||
|
||||
Bundled plugin schema is owned by the core migration chain (deploys run
|
||||
`flask db upgrade` only, which reproduces the full schema). The per-plugin
|
||||
Alembic helpers remain for external/filesystem plugins; these tests pin the
|
||||
PLUGIN_TABLE_OWNERS registry those helpers consume.
|
||||
Ownership cutover (ADR-008): the core Alembic chain created every table that
|
||||
exists through its head (`7d16_directoryemployees`), including the plugin
|
||||
tables. From that point on, each bundled plugin that owns tables carries its
|
||||
own chain under `plugins/<name>/migrations/`. The `0001` migration in each
|
||||
chain is a stamp-only no-op anchor: the core chain already built the tables, so
|
||||
there is nothing to create; the anchor just gives the plugin chain a base that
|
||||
`flask plugin upgrade-all` stamps into `alembic_version_<plugin>`.
|
||||
|
||||
These tests pin that contract:
|
||||
* PLUGIN_TABLE_OWNERS stays in sync with what the models declare.
|
||||
* Every plugin that owns tables has a valid single-head chain.
|
||||
* The anchor migrations are genuine no-ops.
|
||||
* `flask plugin upgrade-all` runs clean on a fresh DB and is idempotent.
|
||||
"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
@@ -12,26 +24,209 @@ from shopdb.plugins.alembic_template import (
|
||||
_get_plugin_metadata,
|
||||
)
|
||||
|
||||
PLUGINS_DIR = Path(__file__).resolve().parent.parent / 'plugins'
|
||||
|
||||
BUNDLED_PLUGINS = ('computers', 'equipment', 'network', 'notifications', 'printers', 'usb')
|
||||
# Plugins that own tables carry a migration chain; blueprint-only plugins do
|
||||
# not. Today every bundled plugin owns tables, so this is the full set.
|
||||
TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS))
|
||||
|
||||
# The ADR-008 cutover froze this exact set of ten plugins whose tables the core
|
||||
# chain had already created. Their 0001 revision is a stamp-only no-op anchor.
|
||||
# Plugins built AFTER the cutover (e.g. measuringtools) are NOT in this list:
|
||||
# their 0001 is a real baseline that genuinely creates their tables, so the
|
||||
# no-op assertion must not apply to them. This is a frozen list on purpose - a
|
||||
# newly discovered plugin does not silently get treated as a cutover no-op.
|
||||
CUTOVER_PLUGINS = (
|
||||
'computers', 'employees', 'equipment', 'knowledgebase', 'network',
|
||||
'notifications', 'printers', 'slides', 'usb', 'warranty',
|
||||
)
|
||||
|
||||
# Expected head revision id per table-owning plugin, so the upgrade-all test can
|
||||
# check both the cutover anchors and post-cutover baselines. Cutover plugins
|
||||
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
|
||||
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
|
||||
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||
|
||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||
# core chain never owned.
|
||||
POST_CUTOVER_PLUGINS = tuple(p for p in PLUGIN_TABLE_OWNERS if p not in CUTOVER_PLUGINS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS)
|
||||
def _declared_tablenames(plugin: str) -> set:
|
||||
"""Scan a plugin's models package for every __tablename__ literal.
|
||||
|
||||
Static parse (no import) so the test can compare what the code declares
|
||||
against PLUGIN_TABLE_OWNERS without side effects.
|
||||
"""
|
||||
names = set()
|
||||
models_dir = PLUGINS_DIR / plugin / 'models'
|
||||
if not models_dir.exists():
|
||||
return names
|
||||
for source in models_dir.glob('*.py'):
|
||||
tree = ast.parse(source.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
|
||||
if '__tablename__' in targets and isinstance(node.value, ast.Constant):
|
||||
names.add(node.value.value)
|
||||
return names
|
||||
|
||||
|
||||
def test_table_owners_match_declared_models():
|
||||
"""Every plugin that declares a __tablename__ is registered in
|
||||
PLUGIN_TABLE_OWNERS, and vice versa. Catches a new plugin table that
|
||||
forgot to update the ownership map."""
|
||||
declared = {p.name for p in PLUGINS_DIR.iterdir()
|
||||
if p.is_dir() and _declared_tablenames(p.name)}
|
||||
assert declared == set(PLUGIN_TABLE_OWNERS), (
|
||||
f"PLUGIN_TABLE_OWNERS keys {set(PLUGIN_TABLE_OWNERS)} do not match "
|
||||
f"plugins declaring tables {declared}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
|
||||
def test_bundled_plugin_has_table_owner_entry(plugin):
|
||||
"""Every bundled plugin appears in PLUGIN_TABLE_OWNERS with at least
|
||||
"""Every table-owning plugin appears in PLUGIN_TABLE_OWNERS with at least
|
||||
one table, documenting which tables it contributes to the schema."""
|
||||
assert plugin in PLUGIN_TABLE_OWNERS
|
||||
assert len(PLUGIN_TABLE_OWNERS[plugin]) > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', BUNDLED_PLUGINS)
|
||||
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
|
||||
def test_owned_tables_match_declared_models(plugin):
|
||||
"""The tables named in PLUGIN_TABLE_OWNERS are exactly the ones the
|
||||
plugin's models declare. Catches drift in either direction."""
|
||||
assert set(PLUGIN_TABLE_OWNERS[plugin]) == _declared_tablenames(plugin)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
|
||||
def test_plugin_metadata_has_all_owned_tables(plugin, app):
|
||||
"""The MetaData filtered to a plugin's owned tables actually contains
|
||||
every table named in PLUGIN_TABLE_OWNERS. Catches drift between the
|
||||
registry and what the models declare."""
|
||||
every table named in PLUGIN_TABLE_OWNERS."""
|
||||
with app.app_context():
|
||||
md = _get_plugin_metadata(plugin)
|
||||
owned = set(PLUGIN_TABLE_OWNERS[plugin])
|
||||
present = set(md.tables.keys())
|
||||
missing = owned - present
|
||||
assert not missing, f"Plugin {plugin}: tables in PLUGIN_TABLE_OWNERS not in metadata: {missing}"
|
||||
missing = owned - set(md.tables.keys())
|
||||
assert not missing, f"Plugin {plugin}: owned tables not in metadata: {missing}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', TABLE_OWNING_PLUGINS)
|
||||
def test_plugin_has_migration_chain(plugin):
|
||||
"""Every table-owning plugin has a migrations dir with env.py and exactly
|
||||
one anchor revision whose down_revision is None (a valid single-root
|
||||
chain)."""
|
||||
mig = PLUGINS_DIR / plugin / 'migrations'
|
||||
assert (mig / 'env.py').exists(), f"{plugin}: missing migrations/env.py"
|
||||
versions = sorted((mig / 'versions').glob('*.py'))
|
||||
assert versions, f"{plugin}: no version scripts"
|
||||
|
||||
roots = []
|
||||
heads = set()
|
||||
down_revisions = set()
|
||||
revisions = set()
|
||||
for script in versions:
|
||||
tree = ast.parse(script.read_text())
|
||||
rev = down = None
|
||||
found_down = False
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
names = [t.id for t in node.targets if isinstance(t, ast.Name)]
|
||||
if 'revision' in names and isinstance(node.value, ast.Constant):
|
||||
rev = node.value.value
|
||||
if 'down_revision' in names:
|
||||
found_down = True
|
||||
if isinstance(node.value, ast.Constant):
|
||||
down = node.value.value
|
||||
assert rev, f"{plugin}: {script.name} has no revision id"
|
||||
assert found_down, f"{plugin}: {script.name} has no down_revision"
|
||||
revisions.add(rev)
|
||||
if down is None:
|
||||
roots.append(rev)
|
||||
else:
|
||||
down_revisions.add(down)
|
||||
heads = revisions - down_revisions
|
||||
assert len(roots) == 1, f"{plugin}: expected 1 root revision, got {roots}"
|
||||
assert len(heads) == 1, f"{plugin}: chain must have a single head, got {heads}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', CUTOVER_PLUGINS)
|
||||
def test_anchor_migration_is_noop(plugin):
|
||||
"""The 0001 anchor's upgrade() and downgrade() are pure no-ops: no DDL
|
||||
operations, just `pass`. The core chain owns the tables at cutover.
|
||||
|
||||
Scoped to CUTOVER_PLUGINS only. A plugin built after the cutover ships a
|
||||
real baseline (measuringtools), which is deliberately NOT a no-op."""
|
||||
anchor = PLUGINS_DIR / plugin / 'migrations' / 'versions' / f'0001_{plugin}_anchor.py'
|
||||
assert anchor.exists(), f"{plugin}: missing 0001 anchor migration"
|
||||
tree = ast.parse(anchor.read_text())
|
||||
funcs = {n.name: n for n in ast.walk(tree)
|
||||
if isinstance(n, ast.FunctionDef) and n.name in ('upgrade', 'downgrade')}
|
||||
assert set(funcs) == {'upgrade', 'downgrade'}, f"{plugin}: anchor missing up/downgrade"
|
||||
for name, fn in funcs.items():
|
||||
# Body may only be a docstring/comment plus a bare `pass`. No calls.
|
||||
calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call)]
|
||||
assert not calls, f"{plugin}: anchor {name}() is not a no-op (found calls)"
|
||||
|
||||
|
||||
def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
||||
"""`flask plugin upgrade-all` on a fresh SQLite DB (after the core schema
|
||||
is created) stamps every plugin anchor without error, and a second run is
|
||||
a no-op. Mirrors the deploy sequence: `flask db upgrade` then
|
||||
`flask plugin upgrade-all`."""
|
||||
from sqlalchemy import inspect, text
|
||||
from shopdb.config import TestingConfig
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
db_file = tmp_path / 'fresh.db'
|
||||
url = f'sqlite:///{db_file}'
|
||||
|
||||
# Point the whole app (db engine + migration manager) at one file DB so the
|
||||
# anchor stamps land where the app can read them back.
|
||||
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI', url)
|
||||
|
||||
# create_app repoints the process-wide plugin_manager singleton; snapshot
|
||||
# its wiring and restore it so later tests see the session app unchanged.
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
try:
|
||||
app = create_app('testing')
|
||||
with app.app_context():
|
||||
db.create_all() # stand in for the core `flask db upgrade`
|
||||
|
||||
# db.create_all() over-creates: it builds EVERY table registered on
|
||||
# the metadata, including post-cutover plugin tables the core chain
|
||||
# would never own. Drop those so each post-cutover baseline creates
|
||||
# its own tables exactly as it does after a real core upgrade (where
|
||||
# the tables are simply absent). The cutover anchors are no-ops, so
|
||||
# their create_all-built tables stay put.
|
||||
insp0 = inspect(db.engine)
|
||||
for plugin in POST_CUTOVER_PLUGINS:
|
||||
# Drop by owned name (SQLite tolerates any order with no rows);
|
||||
# avoids resolving cross-metadata FKs via sorted_tables.
|
||||
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
|
||||
if insp0.has_table(tablename):
|
||||
db.session.execute(text(f'DROP TABLE {tablename}'))
|
||||
db.session.commit()
|
||||
|
||||
first = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
second = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
|
||||
assert set(first) == set(PLUGIN_TABLE_OWNERS)
|
||||
assert all(status == 'ok' for status in first.values()), first
|
||||
assert all(status == 'ok' for status in second.values()), second
|
||||
|
||||
insp = inspect(db.engine)
|
||||
for plugin in PLUGIN_TABLE_OWNERS:
|
||||
version_table = f'alembic_version_{plugin}'
|
||||
assert insp.has_table(version_table), f"missing {version_table}"
|
||||
row = db.session.execute(
|
||||
text(f'SELECT version_num FROM {version_table}')
|
||||
).fetchone()
|
||||
assert row and row[0] == EXPECTED_HEAD_REVISION[plugin]
|
||||
finally:
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
Reference in New Issue
Block a user