The sidebar hides what the caller cannot reach - that is what makes it useful to a guest, and what stops a member with a sparse role being offered links that can only answer 403. It reads the permission off the nav item to decide. Only four items ever declared one. Machines, PCs, Printers, Network, USB, Knowledge Base, Notifications, Calendar, Dashboard, Map, Applications and Reports declared nothing, so the filter had nothing to go on: granting the guest role machines.view opened the API and left the sidebar empty, which reads as the grant not working. Every item now names its read permission - in the plugins, in the core navigation endpoint, and in the frontend's fallback list, which is what renders when the endpoint is unreachable and would otherwise disagree with it.
269 lines
9.8 KiB
Python
269 lines
9.8 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
|
|
from shopdb.utils.authz import require_permission
|
|
|
|
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'])
|
|
@require_permission('assets.view')
|
|
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'])
|
|
@require_permission('assets.view')
|
|
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)
|
|
# Every item names the permission its page needs, so the filter below can
|
|
# hide what the caller cannot reach - a link that only leads to a 401 is
|
|
# worse than no link.
|
|
all_items.extend([
|
|
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/',
|
|
'position': 0, 'permission': 'assets.view'},
|
|
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4,
|
|
'permission': 'assets.view'},
|
|
])
|
|
|
|
# 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',
|
|
'permission': 'applications.view'},
|
|
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports',
|
|
'position': 40, 'section': 'information', 'permission': 'reports.view'},
|
|
])
|
|
|
|
# Sort by position
|
|
all_items.sort(key=lambda x: x.get('position', 99))
|
|
|
|
# A signed-out visitor is shown only what the guest role can actually
|
|
# reach. Without this the sidebar offers them the whole app and every link
|
|
# bounces off a 401, which reads as a broken site rather than a closed
|
|
# door. An item with no declared permission is treated as members-only: a
|
|
# plugin opts into being guest-visible by naming the permission its page
|
|
# needs, which is the same string the API already gates on.
|
|
from flask_jwt_extended import verify_jwt_in_request
|
|
from shopdb.utils.authz import currentuser, guestholds
|
|
|
|
verify_jwt_in_request(optional=True)
|
|
def guestmayreach(item):
|
|
declared = item.get('permission')
|
|
if not declared:
|
|
return False
|
|
names = declared if isinstance(declared, list) else [declared]
|
|
return any(guestholds(name) for name in names)
|
|
|
|
if currentuser() is None:
|
|
all_items = [item for item in all_items if guestmayreach(item)]
|
|
|
|
return success_response(all_items)
|
|
|
|
|
|
@dashboard_bp.route('/widgets', methods=['GET'])
|
|
@require_permission('assets.view')
|
|
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__
|
|
})
|