From d6b433e5f45a3e8d10644a1eb9d58396b81c46ee Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Wed, 12 Aug 2026 17:07:21 -0400 Subject: [PATCH] Kiosks report their display type; whole-chain DDL parity --- .../geenforce/client/ShopdbEnforceClient.psm1 | 32 +++++++++++++++-- .../test_plugins/test_geenforce_ddl_parity.py | 27 ++++++++++----- .../test_geenforce_publish_gate.py | 34 +++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 index ee1fe1b5..234006c1 100644 --- a/plugins/geenforce/client/ShopdbEnforceClient.psm1 +++ b/plugins/geenforce/client/ShopdbEnforceClient.psm1 @@ -210,7 +210,8 @@ function New-ShopdbReport { param([string]$Hostname = $env:COMPUTERNAME, [Parameter(Mandatory)][string]$Scope, [int]$AppliedVersion, - [Parameter(Mandatory)][hashtable]$Summary) + [Parameter(Mandatory)][hashtable]$Summary, + [string]$SubType = (Get-ShopdbSubType)) $results = @(foreach ($entry in @($Summary.Results)) { if ($null -eq $entry) { continue } $mapped = @{ @@ -230,6 +231,7 @@ function New-ShopdbReport { scopename = $Scope appliedversion = $AppliedVersion enforcerversion = $Summary.EnforcerVersion + subtype = $SubType counts = @{ installed = [int]$Summary.Installed skipped = [int]$Summary.Skipped @@ -240,6 +242,32 @@ function New-ShopdbReport { } } +function Get-ShopdbSubType { + <# + .SYNOPSIS + What this PC is WITHIN its scope, read off the machine itself. + + .DESCRIPTION + A display knows whether it is a Dashboard, a Lobby screen or the 3D print + room: the dispatcher reads C:\Enrollment\display-type.txt to choose which + page to open. It just never told shopdb, so the fleet table had to guess + from a DashboardDefault fqdn mapping that is empty unless somebody added a + row per kiosk. Reported by the device beats inferred from a lookup table, + the same way enforcerversion already works. + + Returns '' when the file is absent - every non-display PC type - so the + report simply carries no subtype rather than a made-up one. + #> + param([string]$Path = 'C:\Enrollment\display-type.txt') + try { + if (-not (Test-Path $Path)) { return '' } + $value = (Get-Content -Path $Path -TotalCount 1 -ErrorAction Stop) + return ([string]$value).Trim() + } catch { + return '' + } +} + function Get-ShopdbPayload { <# Fetch a payload blob by content hash over HTTPS, verify the sha256, and @@ -449,4 +477,4 @@ function ConvertTo-ShopdbSummary { Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, ` Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, ` Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, ` - ConvertTo-ShopdbSummary + ConvertTo-ShopdbSummary, Get-ShopdbSubType diff --git a/tests/test_plugins/test_geenforce_ddl_parity.py b/tests/test_plugins/test_geenforce_ddl_parity.py index 47cd51b3..53e8d47c 100644 --- a/tests/test_plugins/test_geenforce_ddl_parity.py +++ b/tests/test_plugins/test_geenforce_ddl_parity.py @@ -22,9 +22,9 @@ from alembic.migration import MigrationContext from alembic.operations import Operations -_BASELINE = (Path(__file__).resolve().parent.parent.parent / 'plugins' / - 'geenforce' / 'migrations' / 'versions' / - '0001_geenforce_baseline.py') +_VERSIONS = (Path(__file__).resolve().parent.parent.parent / 'plugins' / + 'geenforce' / 'migrations' / 'versions') +_BASELINE = _VERSIONS / '0001_geenforce_baseline.py' def _geenforce_models(): @@ -53,15 +53,23 @@ def _model_schema(db, models): def _migration_schema(): - """Run the baseline migration's upgrade() on a fresh engine; return columns. + """Run the WHOLE chain's upgrade() on a fresh engine; return columns. + + The whole chain, not just the baseline. Reading only 0001 meant a column + added by a later revision read as model/migration drift even though the + migration for it existed - the check is "do the models match what the + migrations build", and that is the chain, not its first file. Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens while the connection is open, so we materialize the dicts before it closes. """ - spec = importlib.util.spec_from_file_location('geenforce_baseline', - str(_BASELINE)) - migration = importlib.util.module_from_spec(spec) - spec.loader.exec_module(migration) + migrations = [] + for path in sorted(_VERSIONS.glob('[0-9]*.py')): + spec = importlib.util.spec_from_file_location( + 'geenforce_migration_' + path.stem, str(path)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + migrations.append(module) engine = create_engine('sqlite://') schema = {} @@ -69,7 +77,8 @@ def _migration_schema(): operations = Operations(MigrationContext.configure(connection)) operations._install_proxy() try: - migration.upgrade() + for migration in migrations: + migration.upgrade() finally: operations._remove_proxy() connection.commit() diff --git a/tests/test_plugins/test_geenforce_publish_gate.py b/tests/test_plugins/test_geenforce_publish_gate.py index 354f0cec..1b76c6dd 100644 --- a/tests/test_plugins/test_geenforce_publish_gate.py +++ b/tests/test_plugins/test_geenforce_publish_gate.py @@ -193,3 +193,37 @@ def test_backup_state_prefers_the_newest_confirmed_revision(db): assert facts['wjpc01']['backupkind'] == 'udc' assert facts['wjpc01']['backuplastseen'] is not None + + +# -- reported display subtype ------------------------------------------------- + +def test_report_stores_the_subtype_the_device_reports(db): + """A kiosk knows whether it is a Dashboard or a Lobby; it now says so. + + Previously the fleet table inferred this from the DashboardDefault fqdn + mapping, which is empty unless somebody added a row per kiosk, so the + column was blank for every display. + """ + from plugins.geenforce.service import record_enforcement_report + + record_enforcement_report({'hostname': 'WJKIOSK01', 'scopename': 'gea-shopfloor-display', + 'subtype': 'Lobby', 'counts': {}}) + db.session.flush() + + stored = ManifestEnforcementReport.query.filter_by( + hostname='WJKIOSK01', iscurrent=True).one() + assert stored.subtype == 'Lobby' + + +def test_report_without_a_subtype_stores_none(db): + """Every non-display PC type has no display-type.txt, so it reports no + subtype - that must land as NULL, not as an empty string.""" + from plugins.geenforce.service import record_enforcement_report + + record_enforcement_report({'hostname': 'WJPC77', 'scopename': 'common', + 'subtype': '', 'counts': {}}) + db.session.flush() + + stored = ManifestEnforcementReport.query.filter_by( + hostname='WJPC77', iscurrent=True).one() + assert stored.subtype is None