diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index 57be038..26a7e12 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -1106,7 +1106,9 @@ def list_reports(): 'machineassetid': known.get('machineassetid'), 'toolassetnumber': known.get('toolassetnumber'), 'toolassetid': known.get('toolassetid'), - 'displayrole': known.get('displayrole'), + # Reported by the device wins; the DashboardDefault mapping is a + # fallback for hosts on an older client that does not send it. + 'displayrole': report.subtype or known.get('displayrole'), 'backupkind': known.get('backupkind'), 'backuplastseen': known.get('backuplastseen'), 'scopename': report.scopename, diff --git a/plugins/geenforce/client/ShopdbEnforceClient.psm1 b/plugins/geenforce/client/ShopdbEnforceClient.psm1 index ee1fe1b..234006c 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/plugins/geenforce/migrations/versions/0003_report_subtype.py b/plugins/geenforce/migrations/versions/0003_report_subtype.py new file mode 100644 index 0000000..0fafc09 --- /dev/null +++ b/plugins/geenforce/migrations/versions/0003_report_subtype.py @@ -0,0 +1,28 @@ +"""Record the display subtype a PC reports for itself. + +A kiosk knows whether it is a Dashboard, Lobby or 3DPrintRoom - the dispatcher +reads C:\\Enrollment\\display-type.txt to pick its target - but it never told +ShopDB, so the reports table had to infer it from the DashboardDefault +fqdn/ip mapping, which is blank unless someone added a row per kiosk. Reported +by the device beats inferred from a mapping table, the same way enforcerversion +already works. + +Revision ID: geenforce0003subtype +Revises: geenforce0002blobs +""" +from alembic import op +import sqlalchemy as sa + +revision = 'geenforce0003subtype' +down_revision = 'geenforce0002blobs' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('manifestenforcementreports', + sa.Column('subtype', sa.String(length=50), nullable=True)) + + +def downgrade(): + op.drop_column('manifestenforcementreports', 'subtype') diff --git a/plugins/geenforce/models/manifest.py b/plugins/geenforce/models/manifest.py index 1d2865f..12329c4 100644 --- a/plugins/geenforce/models/manifest.py +++ b/plugins/geenforce/models/manifest.py @@ -295,6 +295,11 @@ class ManifestEnforcementReport(db.Model): # published version to see whether this PC received the latest manifest. appliedversion = db.Column(db.Integer, nullable=True) enforcerversion = db.Column(db.String(20), nullable=True) + # What the PC says it IS within its scope (a display's Dashboard / Lobby / + # 3DPrintRoom). Reported by the device, which reads it from its own + # enrollment file - not inferred from a mapping table that may have no row + # for this host. + subtype = db.Column(db.String(50), nullable=True) installedcount = db.Column(db.Integer, nullable=False, default=0) skippedcount = db.Column(db.Integer, nullable=False, default=0) failedcount = db.Column(db.Integer, nullable=False, default=0) diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py index 657f39d..bacf767 100644 --- a/plugins/geenforce/service.py +++ b/plugins/geenforce/service.py @@ -263,6 +263,7 @@ def record_enforcement_report(payload): phase=phase, appliedversion=payload.get('appliedversion'), enforcerversion=payload.get('enforcerversion'), + subtype=(payload.get('subtype') or None), installedcount=installed, skippedcount=int(counts.get('skipped', 0)), failedcount=failed, diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index dc733f0..15bc406 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -52,7 +52,7 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen' # geenforce adds the content-addressed blob store (manifestblobs) on top of its # baseline. -EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs' +EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0003subtype' # printers adds the printersupplyalerts crossing-state table on top of its anchor. EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts' # machines (renamed from equipment) keeps its original anchor id and adds the diff --git a/tests/test_plugins/test_geenforce_ddl_parity.py b/tests/test_plugins/test_geenforce_ddl_parity.py index 47cd51b..53e8d47 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 354f0ce..1b76c6d 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