From 3b3760369100ff042073edfa5629507879040bb3 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Tue, 11 Aug 2026 14:31:24 -0400 Subject: [PATCH] Dashboard cards: rebuild deployable dists --- CHANGELOG.md | 10 ++- plugins/computers/api/routes.py | 70 ++++++++++++++++ plugins/computers/plugin.py | 16 ++++ plugins/machines/api/routes.py | 32 +++++++ plugins/machines/plugin.py | 28 +++++-- plugins/network/plugin.py | 18 ++-- plugins/notifications/api/routes.py | 33 ++++++++ plugins/notifications/plugin.py | 28 +++++-- plugins/printers/api/asset_routes.py | 51 +++++++++++- plugins/printers/plugin.py | 26 ++++-- plugins/warranty/api/routes.py | 53 +++++++++++- plugins/warranty/plugin.py | 43 ++++++++++ tests/test_core/test_dashboard_widgets.py | 83 ++++++++++++------- .../test_plugins/test_computers_dashboard.py | 78 +++++++++++++++++ 14 files changed, 507 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5899ef4c..da5744d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,14 @@ ADR-007 and ADR-002. The only evidence a backup still ran was a line in a log file on the PC. `lastseenat` is touched on every matching post, including the no-op, and is backfilled from the timestamps that already exist. -- Dashboard cards for enforcement failures, PCs not reporting, and backups that - have stopped. The dashboard now renders cards plugins declare, which it never +- The low-supplies report lists only the cartridges that need replacing. A + printer with one empty black and three full colour cartridges was listing all + 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, 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/computers/api/routes.py b/plugins/computers/api/routes.py index 0be067c2..3f2e2f87 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -994,3 +994,73 @@ def dashboard_quiet(): # down the list, not the order the rows came out of the table. rows.sort(key=lambda r: (r['quietdays'] is not None, -(r['quietdays'] or 0))) return success_response(rows[:50]) + + +@computers_bp.route('/dashboard/sharedmachines', methods=['GET']) +@jwt_required() +@require_permission('computers.view') +def dashboard_sharedmachines(): + """Machine numbers claimed by more than one PC, with nothing filed under + them. + + Several devices genuinely sharing a number is legitimate - part markers do + it - and those are modelled: each device is its own asset filed `partof` the + operation, so the operation has CHILD ASSETS. Two PCs carrying the same + machine number by mistake looks identical from a count and has none. That + distinction is the whole card; without it this would list correct data + alongside faults and get ignored. + + Promoted from `flask relationships check-shared-machines`, which answers the + same question and which nobody will remember to run. This one found seven + mis-numbered bays that had been that way for weeks. + """ + from shopdb.api import AssetRelationship, RelationshipType + from sqlalchemy.orm import aliased + + controls = RelationshipType.query.filter_by( + relationshiptype='controls').first() + partof = RelationshipType.query.filter_by(relationshiptype='partof').first() + if not controls: + return success_response([]) + + pcasset = aliased(Asset) + machineasset = aliased(Asset) + + rows = (db.session.query(machineasset.assetid, machineasset.assetnumber, + pcasset.assetnumber) + .select_from(AssetRelationship) + .join(pcasset, AssetRelationship.sourceassetid == pcasset.assetid) + .join(machineasset, + AssetRelationship.targetassetid == machineasset.assetid) + .filter(AssetRelationship.relationshiptypeid == + controls.relationshiptypeid, + AssetRelationship.label == 'collector:machine', + AssetRelationship.isactive.is_(True)) + .all()) + + bymachine = {} + for assetid, machinenumber, pcnumber in rows: + bymachine.setdefault((assetid, machinenumber), []).append(pcnumber) + + out = [] + for (assetid, machinenumber), pcs in bymachine.items(): + if len(pcs) < 2: + continue + children = 0 + if partof: + children = (AssetRelationship.query + .filter_by(targetassetid=assetid, + relationshiptypeid=partof.relationshiptypeid, + isactive=True) + .count()) + if children: + continue # modelled: the devices are assets in their own right + out.append({ + 'assetid': assetid, + 'machinenumber': machinenumber, + 'pccount': len(pcs), + 'pcs': ', '.join(sorted(p for p in pcs if p)), + }) + + out.sort(key=lambda r: -r['pccount']) + return success_response(out) diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index ff21e057..92c7bb3d 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1280,6 +1280,22 @@ class ComputersPlugin(BasePlugin): 'link': '/pcs/{computerid}', }, }, + { + 'id': 'computers-sharedmachines', + 'title': 'Machine numbers on two PCs', + 'endpoint': '/api/computers/dashboard/sharedmachines', + 'render': 'exceptions', + 'severity': 'critical', + 'permission': 'computers.view', + 'empty': 'hide', + 'position': 15, + 'map': { + 'title': 'machinenumber', + 'detail': 'pcs', + 'meta': [{'key': 'pccount', 'suffix': ' PCs'}], + 'link': '/machines/{assetid}', + }, + }, ] def get_navigation_items(self) -> List[Dict]: diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py index a44bf6c8..c6040dd1 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 177d15e4..e9b26c91 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 903129c0..11e0837d 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 0ba56be8..d38bfb18 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 44dad50d..8178a2b7 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/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index e951e7e9..a1a17a7e 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1005,12 +1005,17 @@ def _get_low_supplies_data(): model_number = model.modelnumber if model else None modelnumberid = model.modelnumberid if model else None + # ONLY the supplies that need attention. A printer reporting one empty + # black cartridge alongside three full colour ones was listing all four, + # so the reader had to find the problem inside the row rather than being + # shown it. The whole report exists to answer "what needs replacing". annotated = [] has_low = False for s in supplies: item = _annotate_supply(s, vendor_name, modelnumberid) - if item['status'] != 'ok': - has_low = True + if item['status'] == 'ok': + continue + has_low = True annotated.append(item) if has_low: @@ -1388,3 +1393,45 @@ def delete_model_supply(modelsupplyid: int): db.session.delete(supply) db.session.commit() return success_response(message='Supply deleted') + + +@printers_asset_bp.route('/dashboard/supplies', methods=['GET']) +@jwt_required() +@require_permission('printers.view') +def dashboard_supplies(): + """Printers needing a cartridge, flattened to one row per printer. + + Reuses the existing low-supplies query and its five-minute cache, so the + card costs nothing extra: a Zabbix round-trip per printer on every dashboard + load would make this the slowest page in the app. + + Critical first, then low. A printer with several depleted cartridges appears + once, listing them - a row per cartridge would report one printer three + times and read as three problems. + """ + data = _get_low_supplies_data() + + rows = [] + for printer in data.get('printers', []): + criticals = [s for s in printer['supplies'] if s['status'] == 'critical'] + lows = [s for s in printer['supplies'] if s['status'] == 'low'] + if not criticals and not lows: + continue + # Every depleted supply, criticals first. Listing only the worst class + # would hide a low cartridge behind a critical one on the same printer, + # and whoever walks out there wants to carry both. + worst = criticals + lows + rows.append({ + 'printerid': printer['printerid'], + 'printername': printer['printername'] or printer['assetnumber'], + 'location': printer['location'], + 'status': 'critical' if criticals else 'low', + 'supplies': ', '.join( + '{} {}%'.format(s.get('name') or s.get('type') or 'supply', + s.get('percent')) + for s in worst), + 'iscritical': bool(criticals), + }) + + rows.sort(key=lambda r: (not r['iscritical'], r['printername'])) + return success_response(rows) diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index fdabfd6b..fd6cf8ab 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -287,14 +287,28 @@ class PrintersPlugin(BasePlugin): return [printerscli] def get_dashboard_widgets(self) -> List[Dict]: - """Return dashboard widget definitions.""" + """Dashboard card: printers needing a cartridge. + + Replaces a declaration naming a component nobody wrote. Reuses the + low-supplies query and its cache - a Zabbix round-trip per printer on + every dashboard load would make this the slowest page in the app. + """ return [ { - 'name': 'Printer Status', - 'component': 'PrinterStatusWidget', - 'endpoint': '/api/printers/dashboard/summary', - 'size': 'medium', - 'position': 10, + 'id': 'printers-supplies', + 'title': 'Printer supplies', + 'endpoint': '/api/printers/dashboard/supplies', + 'render': 'exceptions', + 'severity': 'warning', + 'permission': 'printers.view', + 'empty': 'hide', + 'position': 40, + 'map': { + 'title': 'printername', + 'detail': 'supplies', + 'meta': [{'key': 'location'}, {'key': 'status'}], + 'link': '/printers/{printerid}', + }, }, ] diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py index 9111a55d..232565b3 100644 --- a/plugins/warranty/api/routes.py +++ b/plugins/warranty/api/routes.py @@ -5,7 +5,7 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though the common case is one warranty per asset. """ -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from flask import Blueprint, request from flask_jwt_extended import jwt_required @@ -391,3 +391,54 @@ def warranty_report(): 'counts': {k: len(v) for k, v in buckets.items()}, 'buckets': buckets, }) + + +@warranty_bp.route('/dashboard/expiring', methods=['GET']) +@jwt_required() +@require_permission('warranty.view') +def dashboard_expiring(): + """Warranties running out, and ones that already have. + + Already-expired first, then soonest. Expired stays on the list rather than + dropping off: a machine out of warranty is a purchasing decision someone + still has to make, and silently removing it the day it lapses is how it gets + missed entirely. + + Horizon is a setting because 90 days suits a site that budgets quarterly and + nobody else (ADR-015). + """ + from shopdb.api import Setting + + days = 90 + setting = Setting.query.filter_by(key='warranty_expiringdays').first() + if setting and (setting.value or '').strip(): + try: + days = int(setting.value) + except (TypeError, ValueError): + pass + + today = date.today() + horizon = today + timedelta(days=days) + + rows = [] + query = (db.session.query(Warranty, WarrantyAsset, Asset) + .join(WarrantyAsset, WarrantyAsset.warrantyid == Warranty.warrantyid) + .join(Asset, Asset.assetid == WarrantyAsset.assetid) + .filter(Warranty.isactive.is_(True), + Warranty.enddate.isnot(None), + Warranty.enddate <= horizon, + Asset.isactive.is_(True))) + + for warranty, _link, asset in query.all(): + remaining = (warranty.enddate - today).days + rows.append({ + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber or asset.name or str(asset.assetid), + 'enddate': warranty.enddate.isoformat(), + 'provider': warranty.provider, + 'daysleft': remaining, + 'state': 'expired' if remaining < 0 else 'expiring', + }) + + rows.sort(key=lambda r: r['daysleft']) + return success_response(rows[:50]) diff --git a/plugins/warranty/plugin.py b/plugins/warranty/plugin.py index 146a8e39..b36caa67 100644 --- a/plugins/warranty/plugin.py +++ b/plugins/warranty/plugin.py @@ -114,6 +114,49 @@ class WarrantyPlugin(BasePlugin): }, ] + def get_dashboard_widgets(self) -> List[Dict]: + """Dashboard card: warranties running out, and ones already expired. + + Expired stays listed rather than dropping off. A machine out of + warranty is a purchasing decision someone still has to make, and + removing it the day it lapses is how it gets missed entirely. + """ + return [ + { + 'id': 'warranty-expiring', + 'title': 'Warranties expiring', + 'endpoint': '/api/warranty/dashboard/expiring', + 'render': 'exceptions', + 'severity': 'info', + 'permission': 'warranty.view', + 'empty': 'hide', + 'position': 50, + 'map': { + 'title': 'assetnumber', + 'detail': 'state', + 'meta': [ + {'key': 'enddate', 'label': 'ends', 'format': 'date'}, + {'key': 'provider'}, + ], + 'link': '/assets/{assetid}', + }, + }, + ] + + def get_settings_defaults(self) -> List[Dict]: + """Settings this plugin owns.""" + return [ + { + 'key': 'warranty_expiringdays', + 'value': '90', + 'valuetype': 'integer', + 'category': 'warranty', + 'description': 'Days ahead to list an expiring warranty on the ' + 'dashboard. Already-expired warranties are always ' + 'listed.', + }, + ] + def get_permissions(self) -> List: """Return the RBAC permissions this plugin owns.""" return [ diff --git a/tests/test_core/test_dashboard_widgets.py b/tests/test_core/test_dashboard_widgets.py index 3bcdff4c..8910b651 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 diff --git a/tests/test_plugins/test_computers_dashboard.py b/tests/test_plugins/test_computers_dashboard.py index b93f5bc3..fe2bc94a 100644 --- a/tests/test_plugins/test_computers_dashboard.py +++ b/tests/test_plugins/test_computers_dashboard.py @@ -118,3 +118,81 @@ def test_the_widget_uses_the_new_contract(app): assert widget['render'] == 'exceptions' assert widget['permission'] == 'computers.view' assert widget['empty'] == 'hide' + + +# ============================================================================= +# Machine numbers claimed by two PCs - a fault, unless the devices are modelled +# ============================================================================= + +SHAREDURL = '/api/computers/dashboard/sharedmachines' + + +def _machine(db, assetnumber): + mt = AssetType.query.filter_by(assettype='machine').first() + if not mt: + mt = AssetType(assettype='machine') + db.session.add(mt) + db.session.flush() + asset = Asset(assetnumber=assetnumber, assettypeid=mt.assettypeid, + statusid=1) + db.session.add(asset) + db.session.commit() + return asset + + +def _controls(db, pchostname, machineasset, label='collector:machine'): + from shopdb.core.models import AssetRelationship, RelationshipType + controls = RelationshipType.query.filter_by(relationshiptype='controls').first() + if not controls: + controls = RelationshipType(relationshiptype='controls', isdirectional=True) + db.session.add(controls) + db.session.flush() + comp = _pc(db, pchostname) + db.session.add(AssetRelationship( + sourceassetid=comp.assetid, targetassetid=machineasset.assetid, + relationshiptypeid=controls.relationshiptypeid, label=label, + isactive=True)) + db.session.commit() + return comp + + +def test_one_pc_per_machine_is_not_a_finding(client, db, auth_headers): + machine = _machine(db, '3015') + _controls(db, 'ONLYPC', machine) + assert client.get(SHAREDURL, headers=auth_headers).get_json()['data'] == [] + + +def test_two_pcs_on_one_number_is_reported(client, db, auth_headers): + machine = _machine(db, '2026') + _controls(db, 'PCONE', machine) + _controls(db, 'PCTWO', machine) + [row] = client.get(SHAREDURL, headers=auth_headers).get_json()['data'] + assert row['machinenumber'] == '2026' + assert row['pccount'] == 2 + assert 'PCONE' in row['pcs'] and 'PCTWO' in row['pcs'] + + +def test_a_modelled_shared_number_is_not_a_finding(client, db, auth_headers): + """Part markers legitimately share an operation number. Modelled correctly, + each device is its own asset filed partof the operation - so the operation + has child assets, and that is what tells a real fault from correct data.""" + from shopdb.core.models import AssetRelationship, RelationshipType + operation = _machine(db, '0615') + _controls(db, 'MARKERPCA', operation) + _controls(db, 'MARKERPCB', operation) + + partof = RelationshipType(relationshiptype='partof', isdirectional=True) + db.session.add(partof) + db.session.flush() + marker = _machine(db, 'MARKERPCA-PARTMARKER') + db.session.add(AssetRelationship( + sourceassetid=marker.assetid, targetassetid=operation.assetid, + relationshiptypeid=partof.relationshiptypeid, + label='collector:partmarker', isactive=True)) + db.session.commit() + + assert client.get(SHAREDURL, headers=auth_headers).get_json()['data'] == [] + + +def test_shared_machines_is_permission_gated(client, db, member_headers): + assert client.get(SHAREDURL, headers=member_headers).status_code == 403