Dashboard cards: rebuild deployable dists
This commit is contained in:
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user