"""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__ })