displays: single display type with IP-driven role (dashboard/lobby/kiosk)

One 'display' image resolves what it shows from its own IP, like the existing
visitor-location BU mapping. Extend DashboardDefault with displayrole
(dashboard|lobby|partskiosk; migration 7d28, businessunitid now nullable since
only the dashboard role needs one) + a role->path map. New unauthenticated
GET /api/dashboarddefaults/display-role returns {role, path, businessunitid}
for the caller IP. Settings UI gains a Display selector, showing the business
unit only for the dashboard role.
This commit is contained in:
cproudlock
2026-07-21 09:49:14 -04:00
parent b05fa33278
commit 60e2947fc7
5 changed files with 218 additions and 21 deletions

View File

@@ -9,6 +9,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import DashboardDefault, BusinessUnit, AuditLog
from shopdb.core.models.dashboarddefault import DISPLAY_ROLES, DISPLAY_ROLE_PATHS
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
@@ -27,6 +28,7 @@ def _serialize(default):
data = default.to_dict()
data['businessunit'] = default.businessunit.businessunit \
if default.businessunit else None
data['displaypath'] = default.displaypath
return data
@@ -54,6 +56,35 @@ def visitor_location():
})
@dashboarddefaults_bp.route('/display-role', methods=['GET'])
def display_role():
"""Resolve what a single 'display' PC should show, from its own IP.
Unauthenticated: the display launcher calls this at boot. Returns the role
(dashboard / lobby / partskiosk), the frontend path it maps to, and the
business unit for the dashboard role. Unmapped IP -> null role.
"""
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
if not default:
return success_response({
'ipaddress': ipaddress,
'role': None,
'path': None,
'businessunitid': None,
'businessunit': None,
})
return success_response({
'ipaddress': ipaddress,
'role': default.displayrole,
'path': default.displaypath,
'businessunitid': default.businessunitid,
'businessunit': default.businessunit.businessunit
if default.businessunit else None,
})
@dashboarddefaults_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_defaults():
@@ -70,20 +101,30 @@ def create_default():
"""Create a visitor-IP -> business-unit mapping."""
data = request.get_json() or {}
ipaddress = (data.get('ipaddress') or '').strip()
role = (data.get('displayrole') or 'dashboard').strip()
businessunitid = data.get('businessunitid')
if not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR, 'businessunitid is required')
if not db.session.get(BusinessUnit, businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
if role not in DISPLAY_ROLES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
# Only the dashboard role needs a business unit.
if role == 'dashboard':
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'businessunitid is required for the dashboard role')
if not db.session.get(BusinessUnit, businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
else:
businessunitid = None
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
return error_response(ErrorCodes.CONFLICT,
f"IP {ipaddress} is already mapped", http_code=409)
default = DashboardDefault(ipaddress=ipaddress, businessunitid=businessunitid,
default = DashboardDefault(ipaddress=ipaddress, displayrole=role,
businessunitid=businessunitid,
description=data.get('description'))
db.session.add(default)
db.session.flush()
@@ -104,8 +145,14 @@ def update_default(default_id):
return error_response(ErrorCodes.NOT_FOUND, 'Mapping not found', http_code=404)
data = request.get_json() or {}
if 'displayrole' in data:
role = (data.get('displayrole') or '').strip()
if role not in DISPLAY_ROLES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
default.displayrole = role
if 'businessunitid' in data:
if not db.session.get(BusinessUnit, data['businessunitid']):
if data['businessunitid'] and not db.session.get(BusinessUnit, data['businessunitid']):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
default.businessunitid = data['businessunitid']
@@ -114,6 +161,13 @@ def update_default(default_id):
if 'description' in data:
default.description = data['description']
# Non-dashboard roles carry no business unit; dashboard needs one.
if default.displayrole != 'dashboard':
default.businessunitid = None
elif not default.businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'businessunitid is required for the dashboard role')
db.session.commit()
return success_response(_serialize(default), message='Mapping updated')