Collector: ingest GE-Enforce/enrollment data with configurable pc-type mapping
Extends the computers collector so it can replace the classic api.asp updateCompleteAsset path that the shopfloor PC fleet uses to auto-update data. Collector schema (project naming) now accepts the GE-Enforce/enrollment shape: machinenumber, pctype, pcsubtype, serialnumber, loggedinuser, lastboottime, lastcheckin, ipaddress, vendorname, modelnumber, osname, installedsoftware. - machinenumber -> Asset.assetnumber (skips the 9999 imaging placeholder, falls back to hostname), on create and update. - pctype -> ComputerType via a configurable mapping (see below). - vendor/model created if missing (free vocab); OS looked up (controlled, warns if unknown); pcsubtype accepted but not yet stored (warning). - Dropped per scope: VNC/WinRM flags, warranty, DNC config, multi-NIC. Configurable pc-type mapping (the gea-shopfloor-* imaging taxonomy -> ComputerType): defaults + resolution live in plugins/computers/pctypemap.py (plugin domain, contract-pure - reads Setting via shopdb.api); overrides stored as pctypemap_<pxetype> settings, seeded on plugin install, edited in Settings > System > "Collector PC Type Mapping" (new UI section). Migration doc: docs/COLLECTOR-INTEGRATION.md maps classic api.asp fields + GE-Enforce status fields to the collector schema, documents machine-number sourcing (registry MachineNo first, then C:\Enrollment\machine-number.txt) and that the transport is interim. Tests: complete-asset payload maps machinenumber/pctype/vendor/model/os; 9999 placeholder falls back to hostname. 186 tests pass, naming green, app boots, mapping UI verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
74
docs/COLLECTOR-INTEGRATION.md
Normal file
74
docs/COLLECTOR-INTEGRATION.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Collector integration (PC auto-update)
|
||||||
|
|
||||||
|
How the shopfloor PC fleet pushes inventory into shopdb-flask, replacing the
|
||||||
|
classic ASP `api.asp?action=updateCompleteAsset` path.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
`POST /api/collector/computers`
|
||||||
|
|
||||||
|
Auth: API key header `X-API-Key: <key>`, resolved as `COLLECTOR_API_KEY_COMPUTERS`
|
||||||
|
then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on
|
||||||
|
`hostname`.
|
||||||
|
|
||||||
|
## Payload (project naming convention: lowercase concatenated)
|
||||||
|
|
||||||
|
| Field | Meaning | Flask target |
|
||||||
|
|-------|---------|--------------|
|
||||||
|
| `hostname` (required) | identity | `Computer.hostname` |
|
||||||
|
| `machinenumber` | machine number | `Asset.assetnumber` (skips `9999` placeholder, falls back to hostname) |
|
||||||
|
| `pctype` | imaging pc-type | `Computer.computertypeid` via the configurable mapping |
|
||||||
|
| `pcsubtype` | finer class | accepted, not yet stored (warning) |
|
||||||
|
| `serialnumber` | BIOS serial | `Asset.serialnumber` |
|
||||||
|
| `loggedinuser` | current user | `Computer.loggedinuser` |
|
||||||
|
| `lastboottime` | ISO datetime | `Computer.lastboottime` |
|
||||||
|
| `lastcheckin` | ISO datetime | accepted (heartbeat) |
|
||||||
|
| `ipaddress` | primary IP | primary `Communication` |
|
||||||
|
| `vendorname` | manufacturer | `Computer.vendorid` (created if missing) |
|
||||||
|
| `modelnumber` | model | `Computer.modelnumberid` (created if missing) |
|
||||||
|
| `osname` | OS caption | `Computer.osid` (looked up; warned if unknown) |
|
||||||
|
| `installedsoftware` | `[{name, version}]` | `ComputerInstalledApp` (known apps only) |
|
||||||
|
|
||||||
|
Response: `{status, action: created|updated, assetid, identityvalue, warnings[]}`.
|
||||||
|
|
||||||
|
## Source of truth on the PC (current method, may change)
|
||||||
|
|
||||||
|
The data already exists at image time and at runtime:
|
||||||
|
|
||||||
|
- **machine number**: registry `HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\Dnc\General\MachineNo`
|
||||||
|
FIRST (authoritative post Update-MachineNumber; ignore the `9999` placeholder),
|
||||||
|
then `C:\Enrollment\machine-number.txt` as fallback. This is exactly what
|
||||||
|
GE-Enforce.ps1 already does.
|
||||||
|
- **pc-type / pc-subtype**: `C:\Enrollment\pc-type.txt` / `pc-subtype.txt`
|
||||||
|
(the `gea-shopfloor-*` taxonomy).
|
||||||
|
- **serial / vendor / model / os / user / boot**: live WMI on the PC.
|
||||||
|
|
||||||
|
GE-Enforce currently writes a status JSON to the SFLD share rather than POSTing.
|
||||||
|
Whatever transport is used (a relay reading those status files, or a direct POST
|
||||||
|
later), map its field names to the table above.
|
||||||
|
|
||||||
|
## pc-type mapping (configurable)
|
||||||
|
|
||||||
|
`pctype` (e.g. `gea-shopfloor-cmm`) is mapped to a flask Computer Type through
|
||||||
|
`pctypemap_<pxetype>` settings (Settings > System > "Collector PC Type Mapping").
|
||||||
|
Defaults live in `plugins/computers/pctypemap.py` and are seeded on plugin
|
||||||
|
install; edit per site in the UI. Unmapped pc-types are recorded as a warning,
|
||||||
|
not an error.
|
||||||
|
|
||||||
|
## Classic api.asp field mapping (for migrating the PowerShell scripts)
|
||||||
|
|
||||||
|
| Classic `updateCompleteAsset` form field | Collector field |
|
||||||
|
|---|---|
|
||||||
|
| `hostname` | `hostname` |
|
||||||
|
| `machineNo` | `machinenumber` |
|
||||||
|
| `pcType` | `pctype` |
|
||||||
|
| `serialNumber` | `serialnumber` |
|
||||||
|
| `loggedInUser` | `loggedinuser` |
|
||||||
|
| `lastBootUpTime` / `lastBootTime` | `lastboottime` |
|
||||||
|
| `manufacturer` | `vendorname` |
|
||||||
|
| `model` | `modelnumber` |
|
||||||
|
| `osVersion` | `osname` |
|
||||||
|
| `installedApps` | `installedsoftware` |
|
||||||
|
|
||||||
|
Not carried over (no current home): warranty fields, DNC config, multi-NIC
|
||||||
|
detail beyond the primary IP, VNC/WinRM flags.
|
||||||
@@ -440,6 +440,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Collector PC Type Mapping Section -->
|
||||||
|
<div class="section-card" v-if="pcTypeMappings.length">
|
||||||
|
<h2 class="section-title">Collector PC Type Mapping</h2>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<p class="setting-description">
|
||||||
|
When the collector ingests a PC, its imaging pc-type (from
|
||||||
|
C:\Enrollment\pc-type.txt) is mapped to one of your Computer Types.
|
||||||
|
Adjust the mapping per site.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="identifier-matrix">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Imaging pc-type</th><th>Computer Type</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in pcTypeMappings" :key="row.pxetype">
|
||||||
|
<td class="identifier-name">{{ row.pxetype }}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
:value="row.computertype"
|
||||||
|
@change="changePcTypeMapping(row.pxetype, $event.target.value)"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
<option v-for="ct in computerTypes" :key="ct" :value="ct">{{ ct }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
@@ -448,7 +483,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted, computed } from 'vue'
|
import { ref, reactive, onMounted, computed } from 'vue'
|
||||||
import { settingsApi } from '../../api'
|
import { settingsApi, computersApi } from '../../api'
|
||||||
import { setIdentifierFlag } from '../../composables/identifierSettings'
|
import { setIdentifierFlag } from '../../composables/identifierSettings'
|
||||||
|
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
@@ -522,6 +557,10 @@ function searchValue(domainKey) {
|
|||||||
return key in searchMatrix ? searchMatrix[key] : true
|
return key in searchMatrix ? searchMatrix[key] : true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collector pc-type -> ComputerType mapping (pctypemap_<pxetype> settings).
|
||||||
|
const pcTypeMappings = ref([]) // [{ pxetype, computertype }]
|
||||||
|
const computerTypes = ref([]) // ComputerType names for the dropdown
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testingEmail = ref(false)
|
const testingEmail = ref(false)
|
||||||
@@ -579,6 +618,7 @@ async function loadSettings() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
const { data } = await settingsApi.list()
|
const { data } = await settingsApi.list()
|
||||||
|
|
||||||
|
const pctypeRows = []
|
||||||
for (const setting of data.data) {
|
for (const setting of data.data) {
|
||||||
if (setting.key in settings) {
|
if (setting.key in settings) {
|
||||||
settings[setting.key] = setting.value
|
settings[setting.key] = setting.value
|
||||||
@@ -586,8 +626,22 @@ async function loadSettings() {
|
|||||||
identifierMatrix[setting.key] = setting.value !== false
|
identifierMatrix[setting.key] = setting.value !== false
|
||||||
} else if (/^search_.+_enabled$/.test(setting.key)) {
|
} else if (/^search_.+_enabled$/.test(setting.key)) {
|
||||||
searchMatrix[setting.key] = setting.value !== false
|
searchMatrix[setting.key] = setting.value !== false
|
||||||
|
} else if (setting.key.startsWith('pctypemap_')) {
|
||||||
|
pctypeRows.push({
|
||||||
|
pxetype: setting.key.slice('pctypemap_'.length),
|
||||||
|
computertype: setting.value
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype))
|
||||||
|
|
||||||
|
// Computer type options for the mapping dropdown.
|
||||||
|
try {
|
||||||
|
const typesResponse = await computersApi.types.list({ perpage: 100 })
|
||||||
|
computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype)
|
||||||
|
} catch (typesError) {
|
||||||
|
console.error('Failed to load computer types', typesError)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = 'Failed to load settings'
|
error.value = 'Failed to load settings'
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -596,6 +650,25 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changePcTypeMapping(pxetype, computertype) {
|
||||||
|
const key = `pctypemap_${pxetype}`
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
await settingsApi.update(key, computertype)
|
||||||
|
const row = pcTypeMappings.value.find(r => r.pxetype === pxetype)
|
||||||
|
if (row) row.computertype = computertype
|
||||||
|
success.value = 'Setting saved'
|
||||||
|
setTimeout(() => { success.value = '' }, 2000)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || 'Failed to save setting'
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleSetting(key) {
|
async function toggleSetting(key) {
|
||||||
const newValue = !settings[key]
|
const newValue = !settings[key]
|
||||||
await saveSetting(key, newValue)
|
await saveSetting(key, newValue)
|
||||||
|
|||||||
46
plugins/computers/pctypemap.py
Normal file
46
plugins/computers/pctypemap.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""PXE/GE-Enforce pc-type -> ComputerType mapping (computers-plugin domain).
|
||||||
|
|
||||||
|
The imaging pipeline writes C:\\Enrollment\\pc-type.txt (the gea-shopfloor-*
|
||||||
|
taxonomy). The collector maps that to a flask ComputerType. Defaults live here;
|
||||||
|
per-site overrides live in core Settings (pctypemap_<pxetype>, category
|
||||||
|
'pctypemapping') and are editable in the Settings UI. This indirection means
|
||||||
|
the imaging taxonomy can change without code edits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.api import Setting
|
||||||
|
|
||||||
|
# pxe pc-type -> flask ComputerType name. Defaults; overridable via settings.
|
||||||
|
DEFAULT_PCTYPE_MAP = {
|
||||||
|
'gea-shopfloor-collections': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-nocollections': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-common': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-keyence': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-cmm': 'CMM PC',
|
||||||
|
'gea-shopfloor-genspect': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-heattreat': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-waxtrace': 'Shopfloor PC',
|
||||||
|
'gea-shopfloor-display': 'Kiosk',
|
||||||
|
'gea-shopfloor-partmarker': 'Shopfloor PC',
|
||||||
|
}
|
||||||
|
|
||||||
|
_SETTING_PREFIX = 'pctypemap_'
|
||||||
|
_SETTING_CATEGORY = 'pctypemapping'
|
||||||
|
|
||||||
|
|
||||||
|
def pctype_mapping():
|
||||||
|
"""Return the live pctype -> ComputerType-name map (settings over defaults)."""
|
||||||
|
mapping = dict(DEFAULT_PCTYPE_MAP)
|
||||||
|
for setting in Setting.query.filter_by(category=_SETTING_CATEGORY).all():
|
||||||
|
if setting.key.startswith(_SETTING_PREFIX):
|
||||||
|
mapping[setting.key[len(_SETTING_PREFIX):]] = setting.get_typed_value()
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def seed_pctype_settings():
|
||||||
|
"""Seed the pctypemap_<pxetype> settings from defaults (idempotent)."""
|
||||||
|
for pxetype, computertype in DEFAULT_PCTYPE_MAP.items():
|
||||||
|
key = f'{_SETTING_PREFIX}{pxetype}'
|
||||||
|
if not Setting.query.filter_by(key=key).first():
|
||||||
|
Setting.set(key, computertype, valuetype='string',
|
||||||
|
category=_SETTING_CATEGORY,
|
||||||
|
description=f'Collector maps pc-type {pxetype} to this ComputerType')
|
||||||
@@ -67,15 +67,27 @@ class ComputersPlugin(BasePlugin):
|
|||||||
# -- ADR-006 collector contract -----------------------------------------
|
# -- ADR-006 collector contract -----------------------------------------
|
||||||
|
|
||||||
def get_collector_schema(self) -> Optional[Dict]:
|
def get_collector_schema(self) -> Optional[Dict]:
|
||||||
"""Schema for the PC collector payload (matched by hostname)."""
|
"""Schema for the PC collector payload (matched by hostname).
|
||||||
|
|
||||||
|
Aligns with the GE-Enforce status shape (transport may change), using
|
||||||
|
the project naming convention (lowercase concatenated). The caller maps
|
||||||
|
its own field names to these.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
'identityfield': 'hostname',
|
'identityfield': 'hostname',
|
||||||
'fields': {
|
'fields': {
|
||||||
'hostname': {'type': 'string', 'required': True},
|
'hostname': {'type': 'string', 'required': True},
|
||||||
|
'machinenumber': {'type': 'string'},
|
||||||
|
'pctype': {'type': 'string'},
|
||||||
|
'pcsubtype': {'type': 'string'},
|
||||||
'serialnumber': {'type': 'string'},
|
'serialnumber': {'type': 'string'},
|
||||||
'currentuser': {'type': 'string'},
|
'loggedinuser': {'type': 'string'},
|
||||||
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
'lastboottime': {'type': 'string', 'format': 'date-time'},
|
||||||
|
'lastcheckin': {'type': 'string', 'format': 'date-time'},
|
||||||
'ipaddress': {'type': 'string'},
|
'ipaddress': {'type': 'string'},
|
||||||
|
'vendorname': {'type': 'string'},
|
||||||
|
'modelnumber': {'type': 'string'},
|
||||||
|
'osname': {'type': 'string'},
|
||||||
'installedsoftware': {
|
'installedsoftware': {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {'name': 'string', 'version': 'string'},
|
'items': {'name': 'string', 'version': 'string'},
|
||||||
@@ -86,13 +98,23 @@ class ComputersPlugin(BasePlugin):
|
|||||||
def apply_collector_payload(self, payload: Dict) -> Dict:
|
def apply_collector_payload(self, payload: Dict) -> Dict:
|
||||||
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
"""Idempotent upsert of a PC from a collector payload (by hostname)."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from shopdb.api import Asset, Application, Communication, CommunicationType
|
from shopdb.api import (
|
||||||
|
Asset, Application, Communication, CommunicationType,
|
||||||
|
Vendor, Model, OperatingSystem,
|
||||||
|
)
|
||||||
|
from .pctypemap import pctype_mapping
|
||||||
|
|
||||||
warnings = []
|
warnings = []
|
||||||
hostname = (payload.get('hostname') or '').strip()
|
hostname = (payload.get('hostname') or '').strip()
|
||||||
if not hostname:
|
if not hostname:
|
||||||
raise ValueError('hostname is required')
|
raise ValueError('hostname is required')
|
||||||
|
|
||||||
|
# Machine number is the business identifier (Asset.assetnumber). Skip
|
||||||
|
# the imaging-time placeholder '9999' and fall back to hostname.
|
||||||
|
machinenumber = (payload.get('machinenumber') or '').strip()
|
||||||
|
if machinenumber in ('', '9999'):
|
||||||
|
machinenumber = None
|
||||||
|
|
||||||
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
||||||
if not comp:
|
if not comp:
|
||||||
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
|
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
|
||||||
@@ -103,14 +125,16 @@ class ComputersPlugin(BasePlugin):
|
|||||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||||
# statusid=1 is the first seeded asset status ("In Use"); a
|
# statusid=1 is the first seeded asset status ("In Use"); a
|
||||||
# collector-discovered PC is by definition in use.
|
# collector-discovered PC is by definition in use.
|
||||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
asset = Asset(assetnumber=machinenumber or hostname,
|
||||||
statusid=1)
|
assettypeid=atype.assettypeid, statusid=1)
|
||||||
db.session.add(asset)
|
db.session.add(asset)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
comp = Computer(assetid=asset.assetid, hostname=hostname)
|
||||||
db.session.add(comp)
|
db.session.add(comp)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
action = 'created'
|
action = 'created'
|
||||||
|
elif machinenumber and comp.asset:
|
||||||
|
comp.asset.assetnumber = machinenumber
|
||||||
|
|
||||||
comp.lastreporteddate = datetime.utcnow()
|
comp.lastreporteddate = datetime.utcnow()
|
||||||
if payload.get('lastboottime'):
|
if payload.get('lastboottime'):
|
||||||
@@ -119,11 +143,63 @@ class ComputersPlugin(BasePlugin):
|
|||||||
payload['lastboottime'].replace('Z', '+00:00'))
|
payload['lastboottime'].replace('Z', '+00:00'))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
warnings.append('lastboottime not parseable')
|
warnings.append('lastboottime not parseable')
|
||||||
if payload.get('currentuser'):
|
loggedinuser = payload.get('loggedinuser') or payload.get('currentuser')
|
||||||
comp.loggedinuser = payload['currentuser']
|
if loggedinuser:
|
||||||
|
comp.loggedinuser = loggedinuser
|
||||||
if payload.get('serialnumber') and comp.asset:
|
if payload.get('serialnumber') and comp.asset:
|
||||||
comp.asset.serialnumber = payload['serialnumber']
|
comp.asset.serialnumber = payload['serialnumber']
|
||||||
|
|
||||||
|
# pc-type -> ComputerType via the configurable settings mapping.
|
||||||
|
pctype = (payload.get('pctype') or '').strip()
|
||||||
|
if pctype:
|
||||||
|
from .models import ComputerType
|
||||||
|
mapped = pctype_mapping().get(pctype)
|
||||||
|
if not mapped:
|
||||||
|
warnings.append(f'no ComputerType mapping for pctype: {pctype}')
|
||||||
|
else:
|
||||||
|
ctype = ComputerType.query.filter_by(computertype=mapped).first()
|
||||||
|
if ctype:
|
||||||
|
comp.computertypeid = ctype.computertypeid
|
||||||
|
else:
|
||||||
|
warnings.append(f'mapped ComputerType not found: {mapped}')
|
||||||
|
|
||||||
|
# Vendor / model are free vocab - create if missing.
|
||||||
|
vendorname = (payload.get('vendorname') or '').strip()
|
||||||
|
vendor = None
|
||||||
|
if vendorname:
|
||||||
|
vendor = Vendor.query.filter(Vendor.vendor.ilike(vendorname)).first()
|
||||||
|
if not vendor:
|
||||||
|
vendor = Vendor(vendor=vendorname)
|
||||||
|
db.session.add(vendor)
|
||||||
|
db.session.flush()
|
||||||
|
comp.vendorid = vendor.vendorid
|
||||||
|
|
||||||
|
modelnumber = (payload.get('modelnumber') or '').strip()
|
||||||
|
if modelnumber:
|
||||||
|
model_query = Model.query.filter(Model.modelnumber.ilike(modelnumber))
|
||||||
|
if vendor:
|
||||||
|
model_query = model_query.filter(Model.vendorid == vendor.vendorid)
|
||||||
|
model = model_query.first()
|
||||||
|
if not model:
|
||||||
|
model = Model(modelnumber=modelnumber,
|
||||||
|
vendorid=vendor.vendorid if vendor else None)
|
||||||
|
db.session.add(model)
|
||||||
|
db.session.flush()
|
||||||
|
comp.modelnumberid = model.modelnumberid
|
||||||
|
|
||||||
|
# OS is a controlled vocab - look up only, warn if unknown.
|
||||||
|
osname = (payload.get('osname') or '').strip()
|
||||||
|
if osname:
|
||||||
|
os_row = OperatingSystem.query.filter(
|
||||||
|
OperatingSystem.osname.ilike(osname)).first()
|
||||||
|
if os_row:
|
||||||
|
comp.osid = os_row.osid
|
||||||
|
else:
|
||||||
|
warnings.append(f'unknown operating system: {osname}')
|
||||||
|
|
||||||
|
if payload.get('pcsubtype'):
|
||||||
|
warnings.append('pcsubtype received but not stored (no model field)')
|
||||||
|
|
||||||
if payload.get('ipaddress'):
|
if payload.get('ipaddress'):
|
||||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||||
primary = Communication.query.filter_by(
|
primary = Communication.query.filter_by(
|
||||||
@@ -167,6 +243,9 @@ class ComputersPlugin(BasePlugin):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
self._ensure_asset_type()
|
self._ensure_asset_type()
|
||||||
self._ensure_computer_types()
|
self._ensure_computer_types()
|
||||||
|
from .pctypemap import seed_pctype_settings
|
||||||
|
seed_pctype_settings()
|
||||||
|
db.session.commit()
|
||||||
logger.info("Computers plugin installed")
|
logger.info("Computers plugin installed")
|
||||||
|
|
||||||
def _ensure_asset_type(self) -> None:
|
def _ensure_asset_type(self) -> None:
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ SEARCH_DOMAINS = {
|
|||||||
'subnet': 'Subnets',
|
'subnet': 'Subnets',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_secret(key: str) -> bool:
|
def _is_secret(key: str) -> bool:
|
||||||
return 'password' in key or 'token' in key or 'secret' in key
|
return 'password' in key or 'token' in key or 'secret' in key
|
||||||
|
|
||||||
@@ -210,6 +209,8 @@ def build_default_settings():
|
|||||||
for key, label in SEARCH_DOMAINS.items()
|
for key, label in SEARCH_DOMAINS.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Collector pc-type -> ComputerType mapping is computers-plugin domain;
|
||||||
|
# the plugin seeds pctypemap_<pxetype> settings on install.
|
||||||
defaults = identifierdefaults + searchdefaults + [
|
defaults = identifierdefaults + searchdefaults + [
|
||||||
# Zabbix integration
|
# Zabbix integration
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -115,3 +115,53 @@ def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype):
|
|||||||
finally:
|
finally:
|
||||||
app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None)
|
app.config.pop('COLLECTOR_API_KEY_COMPUTERS', None)
|
||||||
app.config['COLLECTOR_API_KEY'] = None
|
app.config['COLLECTOR_API_KEY'] = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key,
|
||||||
|
computer_assettype):
|
||||||
|
"""A GE-Enforce-shaped payload maps machinenumber/pctype/vendor/model/os."""
|
||||||
|
from shopdb.core.models import OperatingSystem
|
||||||
|
from plugins.computers.models import Computer, ComputerType
|
||||||
|
|
||||||
|
# Seed the controlled-vocab rows the collector looks up.
|
||||||
|
db.session.add(ComputerType(computertype='Shopfloor PC'))
|
||||||
|
db.session.add(OperatingSystem(osname='Windows 11'))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'hostname': 'WJSF1234',
|
||||||
|
'machinenumber': '0615',
|
||||||
|
'pctype': 'gea-shopfloor-collections',
|
||||||
|
'serialnumber': 'SN-ENROLL',
|
||||||
|
'vendorname': 'Dell',
|
||||||
|
'modelnumber': 'OptiPlex 7090',
|
||||||
|
'osname': 'Windows 11',
|
||||||
|
}
|
||||||
|
resp = client.post('/api/collector/computers', json=payload,
|
||||||
|
headers={'X-API-Key': KEY})
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert resp.get_json()['data']['action'] == 'created'
|
||||||
|
|
||||||
|
with client.application.app_context():
|
||||||
|
comp = Computer.query.filter(Computer.hostname.ilike('WJSF1234')).first()
|
||||||
|
assert comp is not None
|
||||||
|
assert comp.asset.assetnumber == '0615' # machinenumber -> assetnumber
|
||||||
|
assert comp.asset.serialnumber == 'SN-ENROLL'
|
||||||
|
assert comp.computertype.computertype == 'Shopfloor PC' # pctype mapped
|
||||||
|
assert comp.vendor.vendor == 'Dell' # created
|
||||||
|
assert comp.model.modelnumber == 'OptiPlex 7090' # created
|
||||||
|
assert comp.operatingsystem.osname == 'Windows 11'
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_machinenumber_9999_falls_back_to_hostname(client, db,
|
||||||
|
collector_key,
|
||||||
|
computer_assettype):
|
||||||
|
"""The 9999 imaging placeholder is ignored; assetnumber falls back to host."""
|
||||||
|
resp = client.post('/api/collector/computers',
|
||||||
|
json={'hostname': 'WJSF9999', 'machinenumber': '9999'},
|
||||||
|
headers={'X-API-Key': KEY})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
with client.application.app_context():
|
||||||
|
comp = Computer.query.filter(Computer.hostname.ilike('WJSF9999')).first()
|
||||||
|
assert comp.asset.assetnumber == 'WJSF9999'
|
||||||
|
|||||||
Reference in New Issue
Block a user