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

@@ -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