"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.
240 lines
8.4 KiB
Python
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__
|
|
})
|