Dashboard cards: rebuild deployable dists
This commit is contained in:
10
CHANGELOG.md
10
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user