Display role vocabulary, migration commit fix, stale enforcement reports

This commit is contained in:
2026-08-13 09:30:40 -04:00
parent d327ea1a5c
commit 5dcc7e30af
16 changed files with 490 additions and 46 deletions

View File

@@ -9,7 +9,8 @@ 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.core.models.dashboarddefault import (
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
@@ -117,17 +118,19 @@ def create_default():
data = request.get_json() or {}
fqdn = (data.get('fqdn') or '').strip().lower() or None
ipaddress = (data.get('ipaddress') or '').strip() or None
role = (data.get('displayrole') or 'dashboard').strip()
# Accept any casing and the retired spellings, store the canonical one, so a
# caller matching the kiosk's own file is never turned away over a capital.
role = normalize_display_role(data.get('displayrole') or 'Dashboard')
businessunitid = data.get('businessunitid')
if not fqdn and not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR,
'fqdn or ipaddress is required')
if role not in DISPLAY_ROLES:
if not role:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
# Only the dashboard role needs a business unit (location).
if role == 'dashboard':
# Only the Dashboard role needs a business unit (location).
if role == 'Dashboard':
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'a location is required for the dashboard role')
@@ -167,8 +170,8 @@ def update_default(default_id):
data = request.get_json() or {}
if 'displayrole' in data:
role = (data.get('displayrole') or '').strip()
if role not in DISPLAY_ROLES:
role = normalize_display_role(data.get('displayrole'))
if not role:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
default.displayrole = role
@@ -187,8 +190,8 @@ def update_default(default_id):
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':
# Non-Dashboard roles carry no location; Dashboard needs one.
if normalize_display_role(default.displayrole) != 'Dashboard':
default.businessunitid = None
elif not default.businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,

View File

@@ -9,15 +9,46 @@ lookups the kiosks call at launch.
from shopdb.extensions import db
from .base import BaseModel
# Display role -> the frontend kiosk path it maps to. Kept here so the API and
# any consumer resolve a role to a URL the same way.
# Display role -> the frontend kiosk path it maps to. THE one place a role is
# named, and the reason the names look like this: these are the literal values a
# person types into C:\Enrollment\display-type.txt on the kiosk itself, which the
# GE-Enforce dispatcher reads to pick a target. Two vocabularies used to exist -
# core said 'partskiosk' where the kiosk said '3DPrintRoom' - so a display could
# report a role core could not store and core could store a role no kiosk would
# ever match. The machine's own file wins, because that is what a person edits.
DISPLAY_ROLE_PATHS = {
'dashboard': '/shopfloor',
'lobby': '/tv',
'partskiosk': '/parts-kiosk',
'Dashboard': '/shopfloor',
'Lobby': '/tv',
'3DPrintRoom': '/parts-kiosk',
}
DISPLAY_ROLES = tuple(DISPLAY_ROLE_PATHS.keys())
# Retired spellings -> canonical. 'partskiosk' is what core called the 3D print
# room before the vocabularies were merged; rows and API callers still carry it.
LEGACY_DISPLAY_ROLES = {
'partskiosk': '3DPrintRoom',
}
def normalize_display_role(value):
"""Canonical role for any spelling, or None if it names no role.
Case-insensitive on purpose. The authoritative value is one line of a text
file edited by hand on the kiosk, so 'lobby', 'Lobby' and 'LOBBY' all arrive
in practice; the dispatcher already matches its own map case-insensitively
(-ieq) and this keeps the server agreeing with it. Retired spellings map
forward. Returns None rather than guessing, so an unrecognised value can be
stored verbatim and SEEN rather than silently rewritten to a wrong role.
"""
text = (value or '').strip()
if not text:
return None
lowered = text.lower()
for role in DISPLAY_ROLES:
if role.lower() == lowered:
return role
return LEGACY_DISPLAY_ROLES.get(lowered)
# GE device naming: a PC's DNS name is 'F' + its BIOS serial under the device
# domain, e.g. FABC1234.device.geaerospace.net. The domain is a setting so other
# sites can point elsewhere; the collector already reports the serial, so the
@@ -52,8 +83,8 @@ class DashboardDefault(BaseModel):
# not depend on innodb_large_prefix; FQDNs (F<serial>.<domain>) fit easily.
fqdn = db.Column(db.String(191), unique=True, nullable=True, index=True)
ipaddress = db.Column(db.String(50), unique=True, nullable=True)
# Which display the mapping drives. Only the dashboard role uses businessunitid.
displayrole = db.Column(db.String(20), nullable=False, default='dashboard')
# Which display the mapping drives. Only the Dashboard role uses businessunitid.
displayrole = db.Column(db.String(20), nullable=False, default='Dashboard')
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
@@ -65,7 +96,9 @@ class DashboardDefault(BaseModel):
@property
def displaypath(self):
return DISPLAY_ROLE_PATHS.get(self.displayrole)
# Resolve through the normalizer so a legacy or oddly-cased stored value
# still finds its path instead of silently returning None.
return DISPLAY_ROLE_PATHS.get(normalize_display_role(self.displayrole))
def __repr__(self):
return f"<DashboardDefault {self.fqdn or self.ipaddress} -> {self.displayrole}>"