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:
cproudlock
2026-07-11 10:01:47 -04:00
parent b8c22244a1
commit 22e623c1f6
85 changed files with 3274 additions and 972 deletions

View File

@@ -18,7 +18,9 @@ from .plugins import plugin_manager
# 0.4.0: removed the never-implemented get_searchable_fields hook (search is a
# core concern over the asset model) and wired the get_dashboard_widgets hook to
# a real consumer (/api/dashboard/widgets). Pre-1.0 contract reduction.
__contract_version__ = '0.5.0'
# 0.6.0: added the get_reports hook, consumed by GET /api/reports to merge
# plugin report cards into the Reports hub. Additive optional hook, minor bump.
__contract_version__ = '0.6.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent

View File

@@ -26,6 +26,21 @@ def list_plugins():
})
@plugins_bp.route('/enabled', methods=['GET'])
@jwt_required(optional=True)
def list_enabled_plugins():
"""Return just the names of enabled plugins as a flat array.
Cheap registry read (no DB). Exposed to anonymous callers on purpose:
the navigation endpoint already leaks the same enabled/disabled signal,
and the frontend needs it (including unauthenticated kiosk routes like
/tv) to gate plugin-owned routes. No metadata beyond the names.
"""
pm = current_app.extensions.get('plugin_manager')
names = pm.registry.get_enabled_plugins() if pm else []
return success_response(sorted(names))
@plugins_bp.route('/<name>', methods=['PUT'])
@jwt_required()
@require_role('admin')

View File

@@ -2,8 +2,8 @@
import csv
import io
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request, Response
from datetime import datetime, timezone
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
@@ -206,109 +206,6 @@ def kb_popularity():
})
# =============================================================================
# Report: Warranty Status
# =============================================================================
@reports_bp.route('/warranty-status', methods=['GET'])
@jwt_required(optional=True)
def warranty_status():
"""
Report: Assets by warranty expiration status.
Categories: Expired, Expiring Soon (90 days), Valid, No Warranty Data
Query parameters:
- assettypeid: Filter by asset type
- format: 'json' (default) or 'csv'
"""
now = datetime.now(timezone.utc).replace(tzinfo=None)
expiring_threshold = now + timedelta(days=90)
# Try to get warranty data from equipment or machines
try:
from plugins.equipment.models import Equipment
from plugins.computers.models import Computer
# Equipment warranty
equipment_query = db.session.query(
Asset.assetid,
Asset.assetnumber,
Asset.name,
Equipment.warrantyenddate
).join(Equipment, Equipment.assetid == Asset.assetid
).filter(Asset.isactive == True)
if type_id := request.args.get('assettypeid'):
equipment_query = equipment_query.filter(Asset.assettypeid == int(type_id))
equipment_data = equipment_query.all()
expired = []
expiring_soon = []
valid = []
no_data = []
for row in equipment_data:
item = {
'assetid': row.assetid,
'assetnumber': row.assetnumber,
'name': row.name,
'warrantyenddate': row.warrantyenddate.isoformat() if row.warrantyenddate else None
}
if row.warrantyenddate is None:
no_data.append(item)
elif row.warrantyenddate < now:
expired.append(item)
elif row.warrantyenddate < expiring_threshold:
expiring_soon.append(item)
else:
valid.append(item)
data = {
'expired': {'count': len(expired), 'items': expired},
'expiringsoon': {'count': len(expiring_soon), 'items': expiring_soon},
'valid': {'count': len(valid), 'items': valid},
'nodata': {'count': len(no_data), 'items': no_data}
}
except (ImportError, AttributeError):
# Fallback: no warranty data available
data = {
'expired': {'count': 0, 'items': []},
'expiringsoon': {'count': 0, 'items': []},
'valid': {'count': 0, 'items': []},
'nodata': {'count': 0, 'items': []}
}
if request.args.get('format') == 'csv':
# Flatten for CSV
flat_data = []
for status, info in data.items():
for item in info['items']:
item['warrantystatus'] = status
flat_data.append(item)
csv_data = generate_csv(flat_data, ['assetid', 'assetnumber', 'name', 'warrantyenddate', 'warrantystatus'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=warranty_status.csv'}
)
return success_response({
'report': 'warranty_status',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'summary': {
'expired': data['expired']['count'],
'expiringsoon': data['expiringsoon']['count'],
'valid': data['valid']['count'],
'nodata': data['nodata']['count']
}
})
# =============================================================================
# Report: Software Compliance
# =============================================================================
@@ -631,13 +528,6 @@ def list_reports():
'endpoint': '/api/reports/kb-popularity',
'category': 'usage'
},
{
'id': 'warranty-status',
'name': 'Warranty Status',
'description': 'Assets by warranty expiration status',
'endpoint': '/api/reports/warranty-status',
'category': 'compliance'
},
{
'id': 'software-compliance',
'name': 'Software Compliance',
@@ -661,6 +551,24 @@ def list_reports():
}
]
# Merge report cards contributed by enabled plugins (get_reports hook).
# Same access pattern as dashboard.get_navigation: skip disabled plugins,
# fail loud in dev/test, isolate a broken plugin in prod.
pm = current_app.extensions.get('plugin_manager')
if pm:
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for entry in plugin.get_reports() or []:
entry['plugin'] = name
reports.append(entry)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_reports failed', name)
return success_response({
'reports': reports,
'total': len(reports)

View File

@@ -466,6 +466,27 @@ def build_default_settings():
'category': 'branding',
'description': 'Primary brand color as a CSS color value (blank = built-in theme color)'
},
{
'key': 'brand_primary_dark_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Primary hover/active color (blank = derived by darkening the primary color ~15%)'
},
{
'key': 'brand_accent_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Accent color for secondary buttons and badges (blank = built-in theme color)'
},
{
'key': 'brand_sidebar_color',
'value': '',
'valuetype': 'string',
'category': 'branding',
'description': 'Sidebar background color (blank = built-in theme color)'
},
]
# Printed QR/label targets. Blank template = QR links to the asset's own

View File

@@ -85,6 +85,11 @@ class Permission(db.Model):
('warranty.create', 'Create warranties', 'warranty'),
('warranty.edit', 'Edit warranties', 'warranty'),
('warranty.delete', 'Delete warranties', 'warranty'),
# Measuring tools
('measuringtools.view', 'View measuring tools', 'measuringtools'),
('measuringtools.create', 'Create measuring tools', 'measuringtools'),
('measuringtools.edit', 'Edit measuring tools', 'measuringtools'),
('measuringtools.delete', 'Delete measuring tools', 'measuringtools'),
# Reports
('reports.view', 'View reports', 'reports'),
('reports.export', 'Export reports', 'reports'),

View File

@@ -79,13 +79,14 @@ class PluginManager:
self._register_plugin_components(plugin)
def upgrade_all_plugins(self) -> Dict[str, str]:
"""Run pending Alembic migrations for every loaded plugin.
"""Run pending Alembic migrations for every discovered plugin.
Returns {plugin_name: 'ok'|'no-migrations'|<error str>}. Skips
plugins with no migrations/ directory. Use from the CLI
(`flask plugin upgrade-all`) on a fresh deploy after the core
schema is in place; existing deploys that still use db.create_all
can ignore this and continue to do so.
plugins with no migrations/ directory. Driven by the CLI
(`flask plugin upgrade-all`), which every deploy runs after
`flask db upgrade`. Each bundled plugin's chain begins with a
stamp-only anchor (the core chain already built its tables); later
per-plugin migrations extend that chain. See ADR-008. Idempotent.
"""
results: Dict[str, str] = {}
if not self.migration_manager:
@@ -285,6 +286,18 @@ class PluginManager:
self.registry.enable(name)
# Surface (do not auto-apply) a plugin chain that is ahead of the DB, so
# an operator enabling a plugin knows to run `flask plugin upgrade-all`.
try:
if self.migration_manager and \
self.migration_manager.has_unapplied_migrations(name):
logger.warning(
f"Plugin {name} has unapplied migrations; "
f"run 'flask plugin upgrade-all'"
)
except Exception:
logger.debug(f"Could not check migration state for {name}")
# Fire the on_enable hook best-effort. Do NOT register the blueprint
# here: Flask forbids register_blueprint after the first request, so
# routes/nav for a re-enabled plugin take effect on the next restart

View File

@@ -1,7 +1,8 @@
"""Shared Alembic env.py logic for bundled plugins.
Each bundled plugin (computers, equipment, network, notifications, printers,
usb) has a `migrations/env.py` that does the minimum:
Every bundled plugin that owns tables (computers, employees, equipment,
knowledgebase, network, notifications, printers, slides, usb, warranty) has a
`migrations/env.py` that does the minimum:
import os
os.environ['PLUGIN_NAME'] = 'computers'
@@ -12,6 +13,13 @@ This module wires the plugin's models into a MetaData object filtered to
only the tables that belong to that plugin, then runs Alembic in either
offline or online mode against the Flask app's configured engine.
Ownership cutover (see ADR-008): the core Alembic chain created every table
that exists through its head (`7d16_directoryemployees`), including the plugin
tables. Each plugin's `0001` migration is therefore a stamp-only no-op that
just records the anchor revision in `alembic_version_<plugin>`. NEW plugin
schema changes land as `plugins/<name>/migrations/000N` from here on, never in
the core chain.
Plugin tables must be importable via `plugins.<name>.models`. Plugins
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
filter is explicit (avoids depending on import-side-effect global state).
@@ -31,12 +39,18 @@ logger = logging.getLogger('alembic.env.plugin')
# Explicit table-ownership map. Adding tables to a plugin requires updating
# this dict so the per-plugin migration knows which tables to include.
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'computers': ('computertypes', 'computers', 'computerinstalledapps'),
'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess'),
'employees': ('directoryemployees',),
'equipment': ('equipmenttypes', 'equipment'),
'knowledgebase': ('knowledgebase',),
'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),
'printers': ('printertypes', 'printers', 'modelsupplies'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
'slides': ('tvslides',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
'warranty': ('warranties', 'warrantyassets'),
}
@@ -129,12 +143,12 @@ def run_migrations():
with context.begin_transaction():
context.run_migrations()
else:
from sqlalchemy import engine_from_config
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
# Build the engine straight from the resolved URL. The plugin manager
# drives this via a programmatic alembic Config (no ini file), so
# config.get_section returns an empty dict and engine_from_config would
# find no sqlalchemy.url. db_url is already resolved above.
from sqlalchemy import create_engine
connectable = create_engine(db_url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,

View File

@@ -208,3 +208,22 @@ class BasePlugin(ABC):
}
"""
return []
def get_reports(self) -> List[Dict]:
"""
Return report card definitions for the Reports hub.
Each entry: {
'id': str, # stable report id
'name': str, # card title
'description': str, # one-line blurb
'category': str, # grouping key (lowercase)
# plus EXACTLY ONE of:
'route': str, # frontend path for a dedicated report page
'endpoint': str, # API endpoint for generic inline rendering
}
Consumed by GET /api/reports, which merges these after the static core
reports. Disabled plugins are skipped by the consumer.
"""
return []

View File

@@ -110,6 +110,15 @@ def enable_plugin(name: str):
if pm.enable_plugin(name):
click.echo(click.style(f"Enabled {name}", fg='green'))
# Nudge the operator if the plugin's chain is ahead of the DB.
try:
if pm.migration_manager and \
pm.migration_manager.has_unapplied_migrations(name):
click.echo(click.style(
f" {name} has unapplied migrations - "
f"run 'flask plugin upgrade-all'", fg='yellow'))
except Exception:
pass
else:
click.echo(click.style(f"Failed to enable {name}", fg='red'))
raise SystemExit(1)
@@ -213,9 +222,11 @@ def new_plugin(name: str, description: str, overwrite: bool):
click.echo('Next steps:')
click.echo(f' 1. Edit plugins/{name}/models/{name}.py with your domain fields')
click.echo(f' 2. Edit plugins/{name}/api/routes.py with your endpoints')
click.echo(f' 3. Run: flask plugin install {name}')
click.echo(f' 4. Run: flask db migrate -m "Add {name} plugin"')
click.echo(f' 5. Run: flask db upgrade')
click.echo(f' 3. Add plugins/{name}/migrations/ with an Alembic chain that')
click.echo(f' creates your tables (per-plugin chain, NOT the core chain;')
click.echo(f' see ADR-008). Register the tables in PLUGIN_TABLE_OWNERS.')
click.echo(f' 4. Run: flask plugin install {name}')
click.echo(f' 5. Run: flask plugin upgrade-all')
click.echo(f' 6. Run: pytest plugins/{name}/tests/')
@@ -246,12 +257,12 @@ def migrate_plugin(name: str, revision: str):
@plugin_cli.command('upgrade-all')
@with_appcontext
def upgrade_all_plugins():
"""Run pending migrations for every loaded plugin.
"""Run pending migrations for every discovered plugin.
Idempotent. Use on a fresh deploy after core schema is in place,
instead of (or alongside) `db-utils create-all`. Existing deployments
that still bootstrap plugin tables via db.create_all can ignore this
command until ready to move a plugin onto its Alembic version chain.
Idempotent. Run this after `flask db upgrade` on every deploy and
upgrade. It stamps each bundled plugin's anchor revision into
alembic_version_<plugin> and applies any per-plugin migrations added
after the ownership cutover (ADR-008). Safe to re-run at head.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:

View File

@@ -158,8 +158,13 @@ class PluginMigrationManager:
return None
def has_pending_migrations(self, plugin_name: str) -> bool:
"""Check if plugin has pending migrations."""
# Simplified check - would need DB connection for full check
"""Check if plugin has any migration scripts on disk.
File-level check only (no DB): True when the plugin ships a
migrations/versions dir with at least one script. Used by
upgrade-all to decide whether the plugin participates in the
per-plugin Alembic flow at all.
"""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir:
return False
@@ -168,6 +173,35 @@ class PluginMigrationManager:
if not versions_dir.exists():
return False
# Check if there are any migration files
# Any migration files on disk?
migration_files = list(versions_dir.glob('*.py'))
return len(migration_files) > 0
def get_applied_revision(self, plugin_name: str) -> Optional[str]:
"""Return the revision stamped in alembic_version_<plugin> in the DB,
or None if the plugin chain has never been stamped (or on any error)."""
migrations_dir = self.get_migrations_dir(plugin_name)
if not migrations_dir or not self.database_url:
return None
try:
from sqlalchemy import create_engine
from alembic.runtime.migration import MigrationContext
engine = create_engine(self.database_url)
with engine.connect() as connection:
context = MigrationContext.configure(
connection,
opts={'version_table': f'alembic_version_{plugin_name}'},
)
return context.get_current_revision()
except Exception:
return None
def has_unapplied_migrations(self, plugin_name: str) -> bool:
"""True when the plugin's on-disk chain head is ahead of what the DB
has stamped. Compares the script head against alembic_version_<plugin>.
Best-effort: returns False when it cannot tell (no chain / no DB)."""
head = self.get_current_revision(plugin_name) # script head on disk
if not head:
return False
return self.get_applied_revision(plugin_name) != head