diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd94e5..e24df33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,9 @@ ADR-007 and ADR-002. four, so the reader had to hunt for the problem inside the row. The report exists to answer "what needs replacing". - Dashboard cards for enforcement failures, machine numbers claimed by two PCs, - PCs not reporting, backups that have stopped, printer supplies, and expiring - warranties. The dashboard now renders cards plugins declare, which it never + PCs not reporting, backups that have stopped, printer supplies, expiring + warranties, machines out of service, and the notifications currently showing + on the floor. The dashboard now renders cards plugins declare, which it never did before - five plugins had been declaring widgets into a void, pointing at components nobody wrote. Cards are ordered by severity, hide themselves when there is nothing to report, gate on a permission, and each fetches diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py index a44bf6c..c6040dd 100644 --- a/plugins/machines/api/routes.py +++ b/plugins/machines/api/routes.py @@ -558,3 +558,35 @@ def dashboard_summary(): 'bytype': [{'type': t, 'count': c} for t, c in by_type], 'bystatus': [{'status': s, 'count': c} for s, c in by_status] }) + + +@machines_bp.route('/dashboard/outofservice', methods=['GET']) +@jwt_required() +@require_permission('machines.view') +def dashboard_outofservice(): + """Machines whose status says they are not running. + + Anything that is not In Use: In Repair, Lost, Returned, and so on. Someone + is supposed to be chasing each of these, and today they are visible only to + whoever thinks to filter the machines list by status. + + Inventory is excluded - a spare on a shelf is not a problem, it is stock. + """ + from shopdb.api import AssetStatus + + ignored = ('In Use', 'Inventory') + + rows = (db.session.query(Machine, Asset, AssetStatus) + .join(Asset, Asset.assetid == Machine.assetid) + .join(AssetStatus, AssetStatus.statusid == Asset.statusid) + .filter(Asset.isactive.is_(True), + AssetStatus.status.notin_(ignored)) + .order_by(Asset.assetnumber) + .limit(50).all()) + + return success_response([{ + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber or str(asset.assetid), + 'name': asset.name, + 'status': status.status, + } for _machine, asset, status in rows]) diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py index 177d15e..e9b26c9 100644 --- a/plugins/machines/plugin.py +++ b/plugins/machines/plugin.py @@ -196,14 +196,30 @@ class MachinesPlugin(BasePlugin): return [machinescli] def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" + """Dashboard card: machines not in service. + + A machine sitting in Repair or marked Lost is a thing someone is + supposed to be chasing, and nothing surfaces it today - it is visible + only to whoever thinks to filter the machines list by status. + + Replaces a declaration naming a component nobody wrote. + """ return [ { - 'name': 'Machine Status', - 'component': 'MachineStatusWidget', - 'endpoint': '/api/machines/dashboard/summary', - 'size': 'medium', - 'position': 5, + 'id': 'machines-outofservice', + 'title': 'Machines out of service', + 'endpoint': '/api/machines/dashboard/outofservice', + 'render': 'exceptions', + 'severity': 'warning', + 'permission': 'machines.view', + 'empty': 'hide', + 'position': 45, + 'map': { + 'title': 'assetnumber', + 'detail': 'status', + 'meta': [{'key': 'name'}], + 'link': '/machines/{assetid}', + }, }, ] diff --git a/plugins/network/plugin.py b/plugins/network/plugin.py index 903129c..11e0837 100644 --- a/plugins/network/plugin.py +++ b/plugins/network/plugin.py @@ -192,17 +192,13 @@ class NetworkPlugin(BasePlugin): return [networkcli] - def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" - return [ - { - 'name': 'Network Status', - 'component': 'NetworkStatusWidget', - 'endpoint': '/api/network/dashboard/summary', - 'size': 'medium', - 'position': 7, - }, - ] + # NO dashboard card, deliberately. This plugin declared one for months + # naming a component nobody wrote, so nothing rendered. Converting it would + # have meant inventing a card: network devices carry no live status - there + # is no polling, no reachability check, nothing that can be WRONG - so the + # only card possible is a count of how many exist, which is the kind of + # always-true number this dashboard exists to get away from. If reachability + # is ever collected, that is the card. def get_navigation_items(self) -> List[Dict]: """Return navigation menu items.""" diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py index 0ba56be..d38bfb1 100644 --- a/plugins/notifications/api/routes.py +++ b/plugins/notifications/api/routes.py @@ -871,3 +871,36 @@ def get_shopfloor_notifications(): 'upcoming': upcoming_data, 'configversion': _config_version(), }) + + +@notifications_bp.route('/dashboard/active', methods=['GET']) +@jwt_required() +def dashboard_active(): + """Notifications currently showing on the floor. + + The items, not a count. "4 active notifications" tells an admin nothing; + knowing WHICH message the shop is looking at right now is the point, + particularly when one is stale and nobody has taken it down. + + A null start or end means open-ended, matching how the boards read them. + """ + now = datetime.now(timezone.utc).replace(tzinfo=None) + + rows = (Notification.query + .filter(db.or_(Notification.starttime.is_(None), + Notification.starttime <= now), + db.or_(Notification.endtime.is_(None), + Notification.endtime >= now)) + .order_by(Notification.starttime.desc()) + .limit(25).all()) + + out = [] + for row in rows: + notificationtype = getattr(row, 'notificationtype', None) + out.append({ + 'notificationid': row.notificationid, + 'message': (row.notification or '').strip()[:120], + 'typename': notificationtype.typename if notificationtype else None, + 'endtime': row.endtime.isoformat() + 'Z' if row.endtime else None, + }) + return success_response(out) diff --git a/plugins/notifications/plugin.py b/plugins/notifications/plugin.py index 44dad50..8178a2b 100644 --- a/plugins/notifications/plugin.py +++ b/plugins/notifications/plugin.py @@ -193,14 +193,30 @@ class NotificationsPlugin(BasePlugin): return [notifications_cli] def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" + """Dashboard card: what the floor is being shown right now. + + The items, not a count. "4 active notifications" tells an admin + nothing; knowing WHICH message is on the boards is the point, and it is + how a stale one gets noticed and taken down. + + Replaces a declaration naming a component nobody wrote. + """ return [ { - 'name': 'Active Notifications', - 'component': 'NotificationsWidget', - 'endpoint': '/api/notifications/dashboard/summary', - 'size': 'small', - 'position': 1, + 'id': 'notifications-active', + 'title': 'Active notifications', + 'endpoint': '/api/notifications/dashboard/active', + 'render': 'list', + 'severity': 'info', + 'empty': 'hide', + 'position': 60, + 'map': { + 'title': 'typename', + 'detail': 'message', + 'meta': [{'key': 'endtime', 'label': 'until', + 'format': 'date'}], + 'link': '/notifications/{notificationid}', + }, }, ] diff --git a/tests/test_core/test_dashboard_widgets.py b/tests/test_core/test_dashboard_widgets.py index 3bcdff4..8910b65 100644 --- a/tests/test_core/test_dashboard_widgets.py +++ b/tests/test_core/test_dashboard_widgets.py @@ -1,38 +1,65 @@ -"""Tests for the dashboard-widgets hook consumer (/api/dashboard/widgets). +"""Every declared dashboard card obeys the contract. -Pins the wiring added for the BasePlugin.get_dashboard_widgets hook: the -endpoint aggregates enabled plugins' widgets and skips disabled ones. - -Plugin enabled-state is monkeypatched (not persisted) so these tests do not -mutate the shared instance/plugins.json registry file. +Five plugins once declared widgets naming Vue components that were never +written, and nothing rendered - the frontend did not call the endpoint either, +so the mismatch went unnoticed for months. These tests make that class of +mistake impossible to repeat quietly: a declaration is checked against the +contract, and its endpoint must actually exist. """ +import pytest -def _widget_plugins(client, headers): - response = client.get('/api/dashboard/widgets', headers=headers) - assert response.status_code == 200, response.get_json() - widgets = response.get_json()['data'] - assert isinstance(widgets, list) - return widgets, {w.get('plugin') for w in widgets} +RENDERERS = ('exceptions', 'metric', 'list') -def test_widgets_endpoint_aggregates_enabled_plugins(app, client, auth_headers, - monkeypatch): - """An enabled plugin that implements the hook contributes a widget.""" +def _declared(app): pm = app.extensions['plugin_manager'] - monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True) - - widgets, plugins = _widget_plugins(client, auth_headers) - assert 'computers' in plugins # computers implements get_dashboard_widgets - positions = [w.get('position', 99) for w in widgets] - assert positions == sorted(positions) + cards = [] + for name, plugin in pm.get_all_plugins().items(): + for card in plugin.get_dashboard_widgets() or []: + cards.append((name, card)) + return cards -def test_widgets_endpoint_skips_disabled_plugin(app, client, auth_headers, - monkeypatch): - """A disabled plugin's widgets drop out of the aggregate.""" - pm = app.extensions['plugin_manager'] - monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'computers') +def test_there_are_cards_to_check(app): + with app.app_context(): + assert _declared(app), 'no plugin declares a dashboard card' - _, plugins = _widget_plugins(client, auth_headers) - assert 'computers' not in plugins + +def test_no_card_names_a_frontend_component(app): + """The old contract. It cannot survive a lean build, where a plugin's + component may never be staged into the bundle.""" + with app.app_context(): + named = [(p, c.get('id') or c.get('name')) for p, c in _declared(app) + if 'component' in c] + assert named == [] + + +def test_every_card_declares_the_fields_core_renders_from(app): + with app.app_context(): + for pluginname, card in _declared(app): + assert card.get('id'), pluginname + assert card.get('title'), card + assert card.get('endpoint'), card + assert card.get('render') in RENDERERS, card + assert card.get('severity') in ('critical', 'warning', 'info'), card + + +def test_every_card_endpoint_is_a_real_route(app): + """A declaration pointing at a route nobody wrote is exactly how the old + widgets rotted.""" + with app.app_context(): + rules = {str(rule) for rule in app.url_map.iter_rules()} + for pluginname, card in _declared(app): + endpoint = card['endpoint'].split('?')[0] + assert endpoint in rules, '{}: {} is not a route'.format( + pluginname, endpoint) + + +def test_every_card_links_somewhere(app): + """A row that names a problem without linking to it is a worse report.""" + with app.app_context(): + for pluginname, card in _declared(app): + if card['render'] == 'metric': + continue + assert (card.get('map') or {}).get('link'), card