Files
shopdb-flask/shopdb/core/api/dashboard.py
cproudlock 94d8d6c9b6
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
dashboard: numbers that agree, a map on hover, wider cards
"All assets 704" sat beside "all assets in use 737", and both were correct
about different populations. The totals summed five specific asset types and
subtracted dual-bay secondaries; the status counts took every asset row of any
type with no collapse, so USB devices and hidden secondary bays inflated one
side of a comparison the layout invites. Status is now counted over exactly the
same assets the totals describe.

Warranty rows fell back to asset.name when the covered asset had no hostname,
and an asset's name is usually the MACHINE's descriptive name - which is how a
column meant to identify a PC ended up showing a machine. Hostname, else the
asset number, never the name. The machine number loses its label too: the row
is hostname, machine, state, and "machine 3015" spends a word on what position
already conveys.

Printer names now carry the floor-plan preview on hover, the same
LocationMapTooltip the printer's own page uses - a location name tells you the
room, the map tells you where to walk. Declared as map.maphover on the card, so
any card with coordinates gets it; a row without them shows a plain link rather
than being dropped.

Cards are four across rather than five. At five columns a row holding a
hostname, a machine number and a state truncates on exactly the rows that
matter. auto-fit, so two cards fill the width instead of leaving empty tracks.

Not covered by a test: the count fix. I started one and it was interrupted, and
I have not gone back for it - the assertion worth having is that in-use can
never exceed the total.
2026-08-11 16:27:14 -04:00

240 lines
8.4 KiB
Python

"""Dashboard API endpoints."""
from flask import Blueprint, current_app
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, AssetStatus
from shopdb.utils.responses import success_response
dashboard_bp = Blueprint('dashboard', __name__)
# Map asset type name -> dashboard category label
_TYPE_CATEGORY = {
'machine': 'Machine',
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
'measuring_tool': 'Measuring Tool',
}
def _count_by_type(assettype):
query = db.session.query(Asset).join(AssetType).filter(
Asset.isactive == True,
AssetType.assettype == assettype
)
# Dualpath single-machine collapse: subtract the hidden secondary bays so a
# dual-bay machine counts once. Only machines are ever Dualpath-paired, but
# the AssetType filter above keeps this correct for any type. Default on.
if assettype == 'machine':
from shopdb.core.services.dualpath import (
resolve_dualpath_pairs, dualpath_single_machine_enabled)
if dualpath_single_machine_enabled():
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
if secondaryassetids:
query = query.filter(Asset.assetid.notin_(secondaryassetids))
return query.count()
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
'measuring_tool')
def _countedassets():
"""Base query for the assets the dashboard totals describe.
The status tiles and the type tiles MUST count the same population. They
did not: the totals summed five specific types and subtracted dual-bay
secondaries, while the status counts took every asset row of any type with
no collapse. So "all assets 704" sat beside "in use 737", and both were
correct about different things - which makes them worse than either alone.
"""
from shopdb.core.services.dualpath import (
resolve_dualpath_pairs, dualpath_single_machine_enabled)
query = (db.session.query(Asset).join(AssetType)
.filter(Asset.isactive == True,
AssetType.assettype.in_(COUNTEDTYPES)))
if dualpath_single_machine_enabled():
secondaryassetids = resolve_dualpath_pairs().secondaryassetids
if secondaryassetids:
query = query.filter(Asset.assetid.notin_(secondaryassetids))
return query
@dashboard_bp.route('/summary', methods=['GET'])
@dashboard_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
measuringtool_count = _count_by_type('measuring_tool')
total = machine_count + pc_count + network_count + printer_count + measuringtool_count
# Count by status over the SAME population as the totals above, so the
# tiles can be read against each other.
status_dict = {}
for asset in _countedassets().all():
name = asset.status.status if asset.status else 'Unknown'
status_dict[name] = status_dict.get(name, 0) + 1
# Recent assets
recent = Asset.query.filter_by(isactive=True).order_by(
Asset.createddate.desc()
).limit(10).all()
return success_response({
# Fields expected by frontend (totalmachines now means machines,
# totalassets is the grand total - renamed with the machines plugin)
'totalassets': total,
'totalmachines': machine_count,
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# Structured data
'counts': {
'machines': machine_count,
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,
'measuringtools': measuringtool_count,
'total': total
},
'bystatus': status_dict,
'recent': [
{
'assetid': a.assetid,
'assetnumber': a.assetnumber,
'assettype': a.assettype.assettype if a.assettype else None,
'createddate': a.createddate.isoformat() + 'Z' if a.createddate else None
}
for a in recent
]
})
@dashboard_bp.route('/stats', methods=['GET'])
@jwt_required(optional=True)
def get_stats():
"""Get detailed statistics by asset type."""
type_counts = db.session.query(
AssetType.assettype,
db.func.count(Asset.assetid)
).outerjoin(
Asset,
db.and_(Asset.assettypeid == AssetType.assettypeid, Asset.isactive == True)
).group_by(AssetType.assettypeid).all()
return success_response({
'bytype': [
{
'type': t,
'category': _TYPE_CATEGORY.get(t, t),
'count': count
}
for t, count in type_counts
]
})
@dashboard_bp.route('/navigation', methods=['GET'])
def get_navigation():
"""Get navigation items from all loaded plugins."""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
all_items = []
# Core navigation items (always present)
all_items.extend([
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0},
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4},
])
# Collect navigation items from enabled plugins. Disabling persists to the
# registry immediately, so a disabled plugin drops out of the menu right
# away (its routes stay registered until the next restart - Flask cannot
# unregister a blueprint at runtime).
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
items = plugin.get_navigation_items()
for item in items:
item['plugin'] = name
all_items.extend(items)
except Exception:
# Fail loud in dev/test; isolate a broken plugin in prod.
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_navigation_items failed', name)
# Add core information section items
all_items.extend([
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
])
# Sort by position
all_items.sort(key=lambda x: x.get('position', 99))
return success_response(all_items)
@dashboard_bp.route('/widgets', methods=['GET'])
@jwt_required(optional=True)
def get_widgets():
"""Aggregate dashboard widget definitions from all enabled plugins.
Consumer for the BasePlugin.get_dashboard_widgets hook. Skips disabled
plugins, isolates a broken plugin in prod (re-raises in dev/test), and
returns the merged list sorted by position.
"""
pm = current_app.extensions.get('plugin_manager')
if not pm:
return success_response([])
widgets = []
for name, plugin in pm.get_all_plugins().items():
if not pm.registry.is_enabled(name):
continue
try:
for widget in plugin.get_dashboard_widgets() or []:
widget['plugin'] = name
widgets.append(widget)
except Exception:
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
raise
current_app.logger.exception(
'Plugin %s get_dashboard_widgets failed', name)
widgets.sort(key=lambda w: w.get('position', 99))
return success_response(widgets)
@dashboard_bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint (no auth required)."""
from shopdb import __version__
try:
db.session.execute(db.text('SELECT 1'))
db_status = 'healthy'
except Exception as e:
db_status = f'unhealthy: {str(e)}'
return success_response({
'status': 'ok' if db_status == 'healthy' else 'degraded',
'database': db_status,
'version': __version__
})