diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md
index 0cb6e1d..0540b74 100644
--- a/docs/PLUGIN-HOOKS.md
+++ b/docs/PLUGIN-HOOKS.md
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
The framework declares its contract version in `shopdb/__init__.py`:
```python
-__contract_version__ = '0.17.0'
+__contract_version__ = '0.18.0'
```
Each plugin's `manifest.json` declares the range of contract versions it supports:
diff --git a/frontend/src/views/settings/DashboardDefaultsList.vue b/frontend/src/views/settings/DashboardDefaultsList.vue
index c63fd86..f84f3d8 100644
--- a/frontend/src/views/settings/DashboardDefaultsList.vue
+++ b/frontend/src/views/settings/DashboardDefaultsList.vue
@@ -77,10 +77,10 @@
{{ r.label }}
-
+
Location *
+ :required="form.displayrole === 'Dashboard'">
Select location...
{{ bu.businessunit }}
@@ -127,13 +127,33 @@ import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
+// Values are the kiosk's own vocabulary - the literal contents of
+// C:\Enrollment\display-type.txt - so what is picked here is what a display
+// reports. Must stay in step with DISPLAY_ROLE_PATHS in
+// shopdb/core/models/dashboarddefault.py.
const roleOptions = [
- { value: 'dashboard', label: 'Shopfloor Dashboard' },
- { value: 'lobby', label: 'Lobby Slideshow' },
- { value: 'partskiosk', label: '3D Parts Kiosk' },
+ { value: 'Dashboard', label: 'Shopfloor Dashboard' },
+ { value: 'Lobby', label: 'Lobby Slideshow' },
+ { value: '3DPrintRoom', label: '3D Print Room Kiosk' },
]
+// Rows written before the vocabularies merged still carry the old spelling, so
+// match case-insensitively and fall back to the retired name.
+const legacyRoleLabels = { partskiosk: '3D Print Room Kiosk' }
+const legacyRoleValues = { partskiosk: '3DPrintRoom' }
+// Canonical value for any stored spelling. Without this, opening a row saved
+// before the merge puts a value in the select that matches no option, and the
+// dropdown renders blank as if the row had no role.
+function canonicalRole(value) {
+ const wanted = (value || '').toLowerCase()
+ return roleOptions.find(r => r.value.toLowerCase() === wanted)?.value
+ || legacyRoleValues[wanted]
+ || 'Dashboard'
+}
function roleLabel(value) {
- return roleOptions.find(r => r.value === value)?.label || value
+ const wanted = (value || '').toLowerCase()
+ return roleOptions.find(r => r.value.toLowerCase() === wanted)?.label
+ || legacyRoleLabels[wanted]
+ || value
}
const items = ref([])
@@ -149,7 +169,7 @@ const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
-const form = ref({ fqdn: '', ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' })
+const form = ref({ fqdn: '', ipaddress: '', displayrole: 'Dashboard', businessunitid: '', description: '' })
onMounted(async () => {
try {
@@ -184,10 +204,10 @@ function openModal(item = null) {
form.value = item ? {
fqdn: item.fqdn || '',
ipaddress: item.ipaddress || '',
- displayrole: item.displayrole || 'dashboard',
+ displayrole: canonicalRole(item.displayrole),
businessunitid: item.businessunitid || '',
description: item.description || ''
- } : { fqdn: '', ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' }
+ } : { fqdn: '', ipaddress: '', displayrole: 'Dashboard', businessunitid: '', description: '' }
error.value = ''
showModal.value = true
}
diff --git a/migrations/versions/7d32_displayrole_kiosk_vocabulary.py b/migrations/versions/7d32_displayrole_kiosk_vocabulary.py
new file mode 100644
index 0000000..d9c2bae
--- /dev/null
+++ b/migrations/versions/7d32_displayrole_kiosk_vocabulary.py
@@ -0,0 +1,67 @@
+"""Move dashboarddefaults.displayrole onto the kiosk's own role vocabulary
+
+Core named the roles dashboard / lobby / partskiosk. The kiosks name them
+Dashboard / Lobby / 3DPrintRoom - the literal values a person types into
+C:\\Enrollment\\display-type.txt, which the GE-Enforce dispatcher reads to pick
+a target. Two vocabularies meant a display could report a role core could not
+store, and core could store 'partskiosk', a value no kiosk would ever match.
+
+The machine's own file wins, so the stored values move to it. 'partskiosk'
+becomes '3DPrintRoom'; the other two are a case change only. Anything else is
+left alone - an unrecognised value is somebody's data, not ours to guess at.
+
+Idempotent and reversible: matching is case-insensitive, so a re-run is a no-op
+rather than a second rewrite, and downgrade puts the old spellings back.
+
+Revision ID: 7d32_displayrole_kiosk_vocabulary
+Revises: 7d31_dashboarddefault_fqdn
+Create Date: 2026-08-13
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = '7d32_displayrole_kiosk_vocabulary'
+down_revision = '7d31_dashboarddefault_fqdn'
+branch_labels = None
+depends_on = None
+
+
+# old spelling -> new spelling
+FORWARD = {
+ 'dashboard': 'Dashboard',
+ 'lobby': 'Lobby',
+ 'partskiosk': '3DPrintRoom',
+}
+BACKWARD = {new: old for old, new in FORWARD.items()}
+
+
+def _rewrite(mapping, newdefault):
+ connection = op.get_bind()
+ inspector = sa.inspect(connection)
+ if 'dashboarddefaults' not in inspector.get_table_names():
+ return
+ columns = {col['name'] for col in inspector.get_columns('dashboarddefaults')}
+ if 'displayrole' not in columns:
+ return
+
+ for source, target in mapping.items():
+ connection.execute(
+ sa.text('UPDATE dashboarddefaults SET displayrole = :target'
+ ' WHERE LOWER(displayrole) = :source'),
+ {'target': target, 'source': source.lower()})
+
+ # 7d28 created the column with server_default='dashboard'; keep the default
+ # spelled the same way as the values.
+ op.alter_column('dashboarddefaults', 'displayrole',
+ existing_type=sa.String(length=20),
+ existing_nullable=False,
+ server_default=newdefault)
+
+
+def upgrade():
+ _rewrite(FORWARD, 'Dashboard')
+
+
+def downgrade():
+ _rewrite(BACKWARD, 'dashboard')
diff --git a/plugins/geenforce/manifest.json b/plugins/geenforce/manifest.json
index 35e50bf..956e564 100644
--- a/plugins/geenforce/manifest.json
+++ b/plugins/geenforce/manifest.json
@@ -5,7 +5,7 @@
"description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).",
"author": "ShopDB Team",
"dependencies": [],
- "core_version": ">=0.7.0,<1.0.0",
+ "core_version": ">=0.18.0,<1.0.0",
"api_prefix": "/api/geenforce",
"default_enabled": false,
"provides": {
diff --git a/plugins/geenforce/seed_display_scope.py b/plugins/geenforce/seed_display_scope.py
index a65bf5a..6223e51 100644
--- a/plugins/geenforce/seed_display_scope.py
+++ b/plugins/geenforce/seed_display_scope.py
@@ -30,7 +30,7 @@ running replace_scope_draft is an idempotent draft rebuild.
import hashlib
import os
-from shopdb.api import db
+from shopdb.api import db, DISPLAY_ROLE_PATHS
from . import service
@@ -47,17 +47,18 @@ RELAUNCH_WINDOW_JSON = (
# Data-driven display-type -> kiosk target map. The value of
# C:\Enrollment\display-type.txt selects the row; the target is a route the
-# kiosk browser opens against the local kiosk base URL. Edit here to retarget a
-# subtype. Keys are matched case-insensitively by the dispatcher.
+# kiosk browser opens against the local kiosk base URL. Keys are matched
+# case-insensitively by the dispatcher.
+#
+# NOT a copy any more: this IS core's DISPLAY_ROLE_PATHS, read through the
+# contract surface. It used to be a second map with the same routes under
+# different names, which is how a display could report a role core could not
+# store. Retarget a subtype in shopdb/core/models/dashboarddefault.py.
#
# TODO-confirm: 3DPrintRoom points at the printedparts /parts-kiosk route as a
# PLACEHOLDER. Confirm the real 3D-print-room kiosk target with the floor team
# before this scope is published to production displays.
-DISPLAY_TYPE_TARGETS = {
- 'Dashboard': '/shopfloor',
- 'Lobby': '/tv',
- '3DPrintRoom': '/parts-kiosk',
-}
+DISPLAY_TYPE_TARGETS = DISPLAY_ROLE_PATHS
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py
index bacf767..ad16036 100644
--- a/plugins/geenforce/service.py
+++ b/plugins/geenforce/service.py
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
from flask import current_app
from sqlalchemy import func
-from shopdb.api import db, Application
+from shopdb.api import db, Application, normalize_display_role
from .models import (
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
@@ -49,6 +49,25 @@ class LibVersionTooOldError(Exception):
'if you are certain those PCs must not receive this scope.')
+def _canonical_subtype(value):
+ """Store a reported subtype under core's role vocabulary where it names one.
+
+ A display reports the literal contents of C:\\Enrollment\\display-type.txt,
+ hand-edited on the machine, so casing drifts freely. Normalising on the way
+ in means the fleet table reads one vocabulary rather than whatever each
+ kiosk happened to be typed as.
+
+ A value that names NO known role is kept VERBATIM, not dropped: an unknown
+ subtype is a kiosk with a typo or a role nobody told the server about, and
+ both are things you want to see in the table rather than have silently
+ blanked. Non-display scopes report no subtype at all and land as None.
+ """
+ text = (value or '').strip()
+ if not text:
+ return None
+ return normalize_display_role(text) or text
+
+
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
@@ -263,7 +282,7 @@ def record_enforcement_report(payload):
phase=phase,
appliedversion=payload.get('appliedversion'),
enforcerversion=payload.get('enforcerversion'),
- subtype=(payload.get('subtype') or None),
+ subtype=_canonical_subtype(payload.get('subtype')),
installedcount=installed,
skippedcount=int(counts.get('skipped', 0)),
failedcount=failed,
diff --git a/shopdb/__init__.py b/shopdb/__init__.py
index ead6e8e..5e6d6cc 100644
--- a/shopdb/__init__.py
+++ b/shopdb/__init__.py
@@ -42,7 +42,13 @@ from .plugins import plugin_manager
# first-time write under the declared category, and lets a plugin mark a key
# readable without auth for pages that run logged out. Additive optional hook,
# minor bump.
-__contract_version__ = '0.17.0'
+# 0.18.0: added DISPLAY_ROLES, DISPLAY_ROLE_PATHS and normalize_display_role
+# beside DashboardDefault (which 0.17.0 imported but never listed in __all__).
+# The role vocabulary is now the kiosk's own - Dashboard / Lobby / 3DPrintRoom,
+# the literal values of C:\Enrollment\display-type.txt - so a plugin holding its
+# own copy of that map (geenforce did) can read core's instead of drifting from
+# it. Additive names on the import surface, minor bump.
+__contract_version__ = '0.18.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py
index 20459f6..a4df277 100644
--- a/shopdb/api/__init__.py
+++ b/shopdb/api/__init__.py
@@ -51,7 +51,12 @@ from shopdb.core.models import (
# Display-role mapping (which kiosk shows which surface). On the surface
# because a plugin reporting on displays has no other way to name what a
# display IS - the role lives in core, not in any plugin.
-from shopdb.core.models.dashboarddefault import DashboardDefault
+from shopdb.core.models.dashboarddefault import (
+ DashboardDefault,
+ DISPLAY_ROLES,
+ DISPLAY_ROLE_PATHS,
+ normalize_display_role,
+)
# Response + pagination helpers for plugin API blueprints
from shopdb.utils.responses import (
@@ -259,6 +264,12 @@ __all__ = [
'OperatingSystem',
'AssetRelationship',
'RelationshipType',
+ # Display roles. The role vocabulary is the kiosk's own, so a plugin that
+ # reads a reported display type resolves it the same way core does.
+ 'DashboardDefault',
+ 'DISPLAY_ROLES',
+ 'DISPLAY_ROLE_PATHS',
+ 'normalize_display_role',
# Response + pagination helpers
'success_response',
'error_response',
diff --git a/shopdb/core/api/dashboarddefaults.py b/shopdb/core/api/dashboarddefaults.py
index 740f185..e8ee5f4 100644
--- a/shopdb/core/api/dashboarddefaults.py
+++ b/shopdb/core/api/dashboarddefaults.py
@@ -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,
diff --git a/shopdb/core/models/dashboarddefault.py b/shopdb/core/models/dashboarddefault.py
index c6a0738..711f9aa 100644
--- a/shopdb/core/models/dashboarddefault.py
+++ b/shopdb/core/models/dashboarddefault.py
@@ -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.) 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" {self.displayrole}>"
diff --git a/tests/test_core/test_dashboarddefaults.py b/tests/test_core/test_dashboarddefaults.py
index 6accf1b..3a62db8 100644
--- a/tests/test_core/test_dashboarddefaults.py
+++ b/tests/test_core/test_dashboarddefaults.py
@@ -100,7 +100,11 @@ def test_non_admin_cannot_update_or_delete(client, db, auth_headers,
def test_lobby_role_needs_no_businessunit_and_resolves_path(client, db, auth_headers):
- """A lobby-role mapping needs no business unit; display-role returns its path."""
+ """A Lobby mapping needs no business unit; display-role returns its path.
+
+ Posts the RETIRED lowercase spelling on purpose: an existing caller must not
+ start failing over a capital, and what comes back is the canonical name.
+ """
created = client.post('/api/dashboarddefaults', json={
'ipaddress': '10.20.30.99', 'displayrole': 'lobby',
'description': 'Front lobby TV',
@@ -110,17 +114,19 @@ def test_lobby_role_needs_no_businessunit_and_resolves_path(client, db, auth_hea
resolved = client.get('/api/dashboarddefaults/display-role?ipaddress=10.20.30.99')
data = resolved.get_json()['data']
- assert data['role'] == 'lobby'
+ assert data['role'] == 'Lobby'
assert data['path'] == '/tv'
assert data['businessunitid'] is None
-def test_partskiosk_role_resolves_path(client, db, auth_headers):
+def test_retired_partskiosk_spelling_maps_to_3dprintroom(client, db, auth_headers):
+ # 'partskiosk' was core's name for the room the kiosks call '3DPrintRoom'.
+ # It still posts, and stores as the name the kiosk itself would report.
client.post('/api/dashboarddefaults', json={
'ipaddress': '10.20.30.77', 'displayrole': 'partskiosk',
}, headers=auth_headers)
data = client.get('/api/dashboarddefaults/display-role?ipaddress=10.20.30.77').get_json()['data']
- assert data['role'] == 'partskiosk'
+ assert data['role'] == '3DPrintRoom'
assert data['path'] == '/parts-kiosk'
@@ -143,7 +149,7 @@ def test_fqdn_mapping_create_and_resolve(client, db, auth_headers):
# resolve by FQDN (case-insensitive)
r = client.get('/api/dashboarddefaults/display-role?fqdn=fabc123.device.geaerospace.net')
body = r.get_json()['data']
- assert body['role'] == 'lobby'
+ assert body['role'] == 'Lobby'
assert body['path'] == '/tv'
@@ -164,3 +170,63 @@ def test_invalid_role_rejected(client, db, auth_headers):
def test_unmapped_ip_display_role_null(client, db):
data = client.get('/api/dashboarddefaults/display-role?ipaddress=9.9.9.9').get_json()['data']
assert data['role'] is None and data['path'] is None
+
+
+# --- role vocabulary -------------------------------------------------------
+#
+# The role names are not ours to choose: they are the literal values a person
+# types into C:\Enrollment\display-type.txt on the kiosk, which the GE-Enforce
+# dispatcher reads. These tests exist because core and the kiosks each used to
+# hold their own map, and a display could report a role core could not store.
+
+def test_display_roles_are_the_kiosk_vocabulary():
+ from shopdb.core.models.dashboarddefault import DISPLAY_ROLE_PATHS
+ assert DISPLAY_ROLE_PATHS == {
+ 'Dashboard': '/shopfloor',
+ 'Lobby': '/tv',
+ '3DPrintRoom': '/parts-kiosk',
+ }
+
+
+def test_geenforce_display_targets_are_core_roles():
+ """The display scope must not carry a second copy of the map."""
+ from shopdb.core.models.dashboarddefault import DISPLAY_ROLE_PATHS
+ from plugins.geenforce.seed_display_scope import DISPLAY_TYPE_TARGETS
+ assert DISPLAY_TYPE_TARGETS is DISPLAY_ROLE_PATHS
+
+
+def test_normalize_display_role_accepts_any_casing():
+ from shopdb.core.models.dashboarddefault import normalize_display_role
+ for value in ('Lobby', 'lobby', 'LOBBY', ' Lobby '):
+ assert normalize_display_role(value) == 'Lobby'
+ assert normalize_display_role('3dprintroom') == '3DPrintRoom'
+ assert normalize_display_role('DASHBOARD') == 'Dashboard'
+
+
+def test_normalize_display_role_maps_retired_spelling():
+ from shopdb.core.models.dashboarddefault import normalize_display_role
+ assert normalize_display_role('partskiosk') == '3DPrintRoom'
+ assert normalize_display_role('PartsKiosk') == '3DPrintRoom'
+
+
+def test_normalize_display_role_refuses_to_guess():
+ from shopdb.core.models.dashboarddefault import normalize_display_role
+ for value in (None, '', ' ', 'bogus', '3d', 'lobbyy'):
+ assert normalize_display_role(value) is None
+
+
+def test_mixed_case_role_stores_canonically(client, db, auth_headers):
+ resp = client.post('/api/dashboarddefaults', json={
+ 'ipaddress': '10.20.30.31', 'displayrole': 'LOBBY',
+ }, headers=auth_headers)
+ assert resp.status_code == 201, resp.get_json()
+ assert resp.get_json()['data']['displayrole'] == 'Lobby'
+
+
+def test_legacy_stored_value_still_resolves_a_path(db):
+ """A row written before the merge keeps working without being rewritten."""
+ from shopdb.core.models.dashboarddefault import DashboardDefault
+ row = DashboardDefault(ipaddress='10.20.30.32', displayrole='partskiosk')
+ db.session.add(row)
+ db.session.flush()
+ assert row.displaypath == '/parts-kiosk'
diff --git a/tests/test_plugins/test_geenforce_publish_gate.py b/tests/test_plugins/test_geenforce_publish_gate.py
index 1b76c6d..0cf02ec 100644
--- a/tests/test_plugins/test_geenforce_publish_gate.py
+++ b/tests/test_plugins/test_geenforce_publish_gate.py
@@ -227,3 +227,39 @@ def test_report_without_a_subtype_stores_none(db):
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJPC77', iscurrent=True).one()
assert stored.subtype is None
+
+
+def test_reported_subtype_is_normalised_to_the_role_vocabulary(db):
+ """display-type.txt is hand-edited, so its casing drifts across the fleet.
+
+ Normalising on the way in keeps the fleet table reading one vocabulary
+ instead of whatever each kiosk happened to be typed as.
+ """
+ from plugins.geenforce.service import record_enforcement_report
+
+ for index, reported in enumerate(('lobby', 'LOBBY', ' Lobby ')):
+ host = f'WJKIOSKCASE{index}'
+ record_enforcement_report({'hostname': host,
+ 'scopename': 'gea-shopfloor-display',
+ 'subtype': reported, 'counts': {}})
+ db.session.flush()
+ stored = ManifestEnforcementReport.query.filter_by(
+ hostname=host, iscurrent=True).one()
+ assert stored.subtype == 'Lobby', reported
+
+
+def test_reported_subtype_keeps_an_unknown_value_verbatim(db):
+ """An unrecognised subtype is a typo on a kiosk or a role nobody declared.
+
+ Both are worth SEEING in the fleet table, so the value is stored as sent
+ rather than blanked or guessed at.
+ """
+ from plugins.geenforce.service import record_enforcement_report
+
+ record_enforcement_report({'hostname': 'WJKIOSKODD', 'scopename': 'gea-shopfloor-display',
+ 'subtype': 'Lobbby', 'counts': {}})
+ db.session.flush()
+
+ stored = ManifestEnforcementReport.query.filter_by(
+ hostname='WJKIOSKODD', iscurrent=True).one()
+ assert stored.subtype == 'Lobbby'