Display role vocabulary, migration commit fix, stale enforcement reports
This commit is contained in:
@@ -77,10 +77,10 @@
|
||||
<option v-for="r in roleOptions" :key="r.value" :value="r.value">{{ r.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.displayrole === 'dashboard'">
|
||||
<div class="form-group" v-if="form.displayrole === 'Dashboard'">
|
||||
<label for="businessunitid">Location *</label>
|
||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control"
|
||||
:required="form.displayrole === 'dashboard'">
|
||||
:required="form.displayrole === 'Dashboard'">
|
||||
<option value="">Select location...</option>
|
||||
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ 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
|
||||
}
|
||||
|
||||
@@ -147,6 +147,21 @@ def run_migrations_online():
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
# COMMIT THE RUN. SQLAlchemy 2.0 connections do not autocommit, and on
|
||||
# MySQL alembic reports "non-transactional DDL" so begin_transaction()
|
||||
# above is a no-op - nothing here commits on its own.
|
||||
#
|
||||
# It looked like it worked because MySQL implicitly commits on DDL: each
|
||||
# ALTER/CREATE flushed everything queued before it, including the
|
||||
# PREVIOUS migration's version stamp. The LAST migration of every run
|
||||
# has no DDL after it, so its stamp was rolled back at close. The column
|
||||
# changes survived (already committed by their own DDL) while
|
||||
# alembic_version stayed one revision behind, so `flask db upgrade`
|
||||
# exited 0 having silently re-run the final migration, and re-ran it
|
||||
# again on the next deploy. A migration that is not idempotent would
|
||||
# apply twice.
|
||||
connection.commit()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
|
||||
67
migrations/versions/7d32_displayrole_kiosk_vocabulary.py
Normal file
67
migrations/versions/7d32_displayrole_kiosk_vocabulary.py
Normal file
@@ -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')
|
||||
@@ -11,7 +11,7 @@ Two audiences:
|
||||
import ipaddress
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response, send_file, current_app, g
|
||||
@@ -1061,6 +1061,29 @@ def _attach_backup_state(facts, hostnames):
|
||||
if revision.lastseenat else None)
|
||||
|
||||
|
||||
def _report_stale_cutoff():
|
||||
"""(cutoff datetime, threshold minutes) for 'this PC has gone quiet'.
|
||||
|
||||
A report is a record of one cycle, not a heartbeat with an expiry: nothing
|
||||
ages it. So a PC that stops reporting keeps the status of its last good
|
||||
cycle and reads as healthy while it is unplugged. Comparing receivedat - the
|
||||
SERVER's clock, not anything the client asserts - against a threshold is
|
||||
what turns silence into a visible state.
|
||||
|
||||
Returns (None, 0) when the check is disabled, so callers skip it entirely.
|
||||
"""
|
||||
from shopdb.api import Setting
|
||||
try:
|
||||
minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0)
|
||||
except (TypeError, ValueError):
|
||||
minutes = 30
|
||||
if minutes <= 0:
|
||||
return None, 0
|
||||
# Same clock helper that WROTE receivedat (naive UTC), so the two cannot
|
||||
# drift into comparing an aware datetime against a naive one.
|
||||
return service._utcnow() - timedelta(minutes=minutes), minutes
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@@ -1084,6 +1107,7 @@ def list_reports():
|
||||
facts = _asset_facts({report.hostname for report in reports
|
||||
if report.hostname})
|
||||
|
||||
stalecutoff, stalafter = _report_stale_cutoff()
|
||||
latest_cache = {}
|
||||
data = []
|
||||
for report in reports:
|
||||
@@ -1092,6 +1116,9 @@ def list_reports():
|
||||
latest_cache[key] = _current_published_version(*key)
|
||||
latest = latest_cache[key]
|
||||
known = facts.get((report.hostname or '').lower(), {})
|
||||
isstale = (stalecutoff is not None
|
||||
and (report.receivedat is None
|
||||
or report.receivedat < stalecutoff))
|
||||
data.append({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
@@ -1118,7 +1145,14 @@ def list_reports():
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'enforcerversion': report.enforcerversion,
|
||||
# 'status' is what the PC said about its LAST cycle and is left
|
||||
# exactly as reported. 'isstale' is the server's own judgement that
|
||||
# no cycle has been heard from in too long - a PC switched off after
|
||||
# a clean run reports 'ok' forever, so the two are different facts
|
||||
# and the table shows both.
|
||||
'status': report.status,
|
||||
'isstale': isstale,
|
||||
'staleafterminutes': stalafter,
|
||||
'installed': report.installedcount,
|
||||
'skipped': report.skippedcount,
|
||||
'failed': report.failedcount,
|
||||
|
||||
@@ -88,7 +88,14 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
|
||||
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
|
||||
<!-- A PC that stops reporting keeps the status of its last good
|
||||
cycle, so silence has to outrank it: 'stale' shows instead,
|
||||
with what it last reported kept in the tooltip. -->
|
||||
<td>
|
||||
<span v-if="report.isstale" class="badge badge-warning"
|
||||
:title="staleTitle(report)">stale</span>
|
||||
<span v-else class="badge" :class="statusClass(report.status)">{{ report.status }}</span>
|
||||
</td>
|
||||
<td>{{ report.installed }}</td>
|
||||
<td class="muted">{{ report.skipped }}</td>
|
||||
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
|
||||
@@ -183,6 +190,12 @@ async function openDetail(reportid) {
|
||||
function statusClass(status) {
|
||||
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
|
||||
}
|
||||
|
||||
function staleTitle(report) {
|
||||
const last = report.receivedat ? formatDate(report.receivedat) : 'never'
|
||||
return `No report for over ${report.staleafterminutes} minutes.`
|
||||
+ ` Last heard ${last}, reporting "${report.status}".`
|
||||
}
|
||||
function actionClass(action) {
|
||||
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
|
||||
filtered: '' }[action] || ''
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -141,6 +141,26 @@ class GeEnforcePlugin(BasePlugin):
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""How long a PC may go quiet before the fleet table calls it stale."""
|
||||
return [
|
||||
{
|
||||
'key': 'geenforce_reportstaleminutes',
|
||||
'value': '30',
|
||||
'valuetype': 'integer',
|
||||
'category': 'geenforce',
|
||||
'description': 'Minutes without an enforcement report before a '
|
||||
'PC is shown as stale. A report records how the '
|
||||
'LAST cycle went, so a PC that stops reporting '
|
||||
'keeps whatever status it last sent - it reads '
|
||||
'as healthy while it is switched off. Set to 0 '
|
||||
'to disable the check.',
|
||||
# Server-side only: nothing on a PC reads this, so it stays off
|
||||
# the public settings surface.
|
||||
'public': False,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -185,3 +185,103 @@ def test_report_missing_hostname_rejected(client, db, app, auth_headers):
|
||||
resp = client.post('/api/geenforce/report', json={'scopename': 'x'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# -- going quiet ------------------------------------------------------------
|
||||
#
|
||||
# A report records how one cycle went; nothing ages it. A PC switched off after
|
||||
# a clean run therefore kept 'ok' indefinitely and read as healthy - which is
|
||||
# how a machine offline for over a day still showed 'ok'.
|
||||
|
||||
def _age_report(db, hostname, minutes):
|
||||
"""Push a stored report's server-side receivedat back in time."""
|
||||
from datetime import timedelta
|
||||
from plugins.geenforce.models import ManifestEnforcementReport
|
||||
from plugins.geenforce.service import _utcnow
|
||||
(ManifestEnforcementReport.query
|
||||
.filter_by(hostname=hostname, iscurrent=True)
|
||||
.update({'receivedat': _utcnow() - timedelta(minutes=minutes)}))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _post_clean_report(client, secret, hostname):
|
||||
return client.post('/api/geenforce/report', json={
|
||||
'hostname': hostname, 'scopename': 'gea-shopfloor-cmm',
|
||||
'appliedversion': 1, 'enforcerversion': '2.6',
|
||||
'counts': {'installed': 1, 'skipped': 0, 'failed': 0, 'filtered': 0},
|
||||
'results': [{'name': 'Alpha', 'action': 'installed'}],
|
||||
}, headers={'X-API-Key': secret})
|
||||
|
||||
|
||||
def test_a_pc_reporting_now_is_not_stale(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
_post_clean_report(client, secret, 'WJCMM01')
|
||||
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['status'] == 'ok'
|
||||
assert row['isstale'] is False
|
||||
assert row['staleafterminutes'] == 30
|
||||
|
||||
|
||||
def test_a_pc_that_stopped_reporting_goes_stale(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
_post_clean_report(client, secret, 'WJCMM01')
|
||||
_age_report(db, 'WJCMM01', minutes=60 * 26) # offline over a day
|
||||
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['isstale'] is True
|
||||
# The REPORTED status is left alone - it is still a true record of the last
|
||||
# cycle, and the UI shows it in the tooltip behind the stale badge.
|
||||
assert row['status'] == 'ok'
|
||||
|
||||
|
||||
def test_stale_boundary_is_the_configured_threshold(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
_post_clean_report(client, secret, 'WJCMM01')
|
||||
|
||||
_age_report(db, 'WJCMM01', minutes=29)
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['isstale'] is False, 'inside the window is not stale'
|
||||
|
||||
_age_report(db, 'WJCMM01', minutes=31)
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['isstale'] is True, 'past the window is stale'
|
||||
|
||||
|
||||
def test_stale_threshold_is_a_setting(client, db, app, auth_headers):
|
||||
from shopdb.api import Setting
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
_post_clean_report(client, secret, 'WJCMM01')
|
||||
_age_report(db, 'WJCMM01', minutes=45)
|
||||
|
||||
Setting.set('geenforce_reportstaleminutes', '120', valuetype='integer',
|
||||
category='geenforce')
|
||||
db.session.commit()
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['isstale'] is False
|
||||
assert row['staleafterminutes'] == 120
|
||||
|
||||
|
||||
def test_zero_disables_the_stale_check(client, db, app, auth_headers):
|
||||
from shopdb.api import Setting
|
||||
_seed_and_publish(app)
|
||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
||||
_post_clean_report(client, secret, 'WJCMM01')
|
||||
_age_report(db, 'WJCMM01', minutes=60 * 24 * 7)
|
||||
|
||||
Setting.set('geenforce_reportstaleminutes', '0', valuetype='integer',
|
||||
category='geenforce')
|
||||
db.session.commit()
|
||||
row = client.get('/api/geenforce/reports',
|
||||
headers=auth_headers).get_json()['data'][0]
|
||||
assert row['isstale'] is False
|
||||
assert row['staleafterminutes'] == 0
|
||||
|
||||
Reference in New Issue
Block a user