Dashboard cards: rebuild deployable dists

This commit is contained in:
2026-08-11 14:31:24 -04:00
parent 0ed8be75d9
commit 3b37603691
14 changed files with 507 additions and 62 deletions

View File

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

View File

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

View File

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

View File

@@ -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}',
},
},
]

View File

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

View File

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

View File

@@ -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}',
},
},
]

View File

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

View File

@@ -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}',
},
},
]

View File

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

View File

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