dashboarddefaults: key display mappings by stable FQDN (from BIOS serial), IP fallback

A display's DHCP IP can change; its FQDN (F<serial>.<domain>, domain from the
display_fqdn_domain setting) is stable and the collector already reports the
serial. Add a nullable unique fqdn column (varchar191 so the index fits utf8mb4
without innodb_large_prefix), make ipaddress nullable, and require fqdn OR ip.
visitor-location + display-role resolve by FQDN first, then IP; create/update
accept fqdn. Core migration 7d31, verified up/down/idempotent on MySQL 5.6.
'Business unit' wording -> 'location' in the validation messages.
This commit is contained in:
cproudlock
2026-07-29 07:20:52 -04:00
parent b539e36096
commit 3ba808028c
4 changed files with 160 additions and 22 deletions

View File

@@ -32,6 +32,18 @@ def _serialize(default):
return data
def _resolve_default(fqdn, ipaddress):
"""Find the active mapping for a display: FQDN first (stable), then IP."""
default = None
if fqdn:
default = DashboardDefault.query.filter_by(
fqdn=fqdn, isactive=True).first()
if not default and ipaddress:
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
return default
@dashboarddefaults_bp.route('/visitor-location', methods=['GET'])
def visitor_location():
"""Resolve the business unit for the calling display by its IP.
@@ -39,16 +51,18 @@ def visitor_location():
Unauthenticated: kiosks/lobby displays hit this. Returns the mapped
business unit, or a null businessunitid when the IP is not mapped.
"""
fqdn = (request.args.get('fqdn') or '').strip().lower() or None
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
default = _resolve_default(fqdn, ipaddress)
if not default:
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'businessunitid': None,
'businessunit': None,
})
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'businessunitid': default.businessunitid,
'businessunit': default.businessunit.businessunit
@@ -64,11 +78,12 @@ def display_role():
(dashboard / lobby / partskiosk), the frontend path it maps to, and the
business unit for the dashboard role. Unmapped IP -> null role.
"""
fqdn = (request.args.get('fqdn') or '').strip().lower() or None
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
default = _resolve_default(fqdn, ipaddress)
if not default:
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'role': None,
'path': None,
@@ -76,6 +91,7 @@ def display_role():
'businessunit': None,
})
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'role': default.displayrole,
'path': default.displaypath,
@@ -100,36 +116,42 @@ def list_defaults():
def create_default():
"""Create a visitor-IP -> business-unit mapping."""
data = request.get_json() or {}
ipaddress = (data.get('ipaddress') or '').strip()
fqdn = (data.get('fqdn') or '').strip().lower() or None
ipaddress = (data.get('ipaddress') or '').strip() or None
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 fqdn and not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR,
'fqdn or ipaddress is required')
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.
# Only the dashboard role needs a business unit (location).
if role == 'dashboard':
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'businessunitid is required for the dashboard role')
'a location is required for the dashboard role')
if not db.session.get(BusinessUnit, businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
return error_response(ErrorCodes.NOT_FOUND, 'Location not found',
http_code=404)
else:
businessunitid = None
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
if fqdn and DashboardDefault.query.filter_by(fqdn=fqdn, isactive=True).first():
return error_response(ErrorCodes.CONFLICT,
f"{fqdn} is already mapped", http_code=409)
if ipaddress and 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, displayrole=role,
default = DashboardDefault(fqdn=fqdn, ipaddress=ipaddress, displayrole=role,
businessunitid=businessunitid,
description=data.get('description'))
db.session.add(default)
db.session.flush()
AuditLog.log('created', 'DashboardDefault', entityid=default.dashboarddefaultid,
entityname=ipaddress)
entityname=(fqdn or ipaddress))
db.session.commit()
return success_response(_serialize(default), message='Mapping created',
http_code=201)
@@ -156,17 +178,22 @@ def update_default(default_id):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
default.businessunitid = data['businessunitid']
if 'ipaddress' in data and data['ipaddress']:
default.ipaddress = data['ipaddress'].strip()
if 'fqdn' in data:
default.fqdn = (data.get('fqdn') or '').strip().lower() or None
if 'ipaddress' in data:
default.ipaddress = (data.get('ipaddress') or '').strip() or None
if 'description' in data:
default.description = data['description']
# Non-dashboard roles carry no business unit; dashboard needs one.
if not default.fqdn and not default.ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR,
'fqdn or ipaddress is required')
# Non-dashboard roles carry no location; 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')
'a location is required for the dashboard role')
db.session.commit()
return success_response(_serialize(default), message='Mapping updated')