add Entra device identity to computers and Azure/Thor import sources

PC records now carry Entra/Azure device identity so imported machines keep a durable link back to their directory object instead of matching on hostname alone. Adds azuredeviceid, azureobjectid, and azuredevicecategory to the Computer model with matching migrations, surfaces them read-only in PCDetail as an Entra Device Info card, and lets PCForm edit the device id directly. The computers plugin registers a machineassetnumber import hook that reconciles PC-to-machine links during bulk import and warns on unresolved or conflicting relationships.

Adds importsources adapters that normalize Azure device-group and Thor PC inventory exports into canonical ShopDB CSVs via hostname alignment, exposes register_import_hook and RowWarning on the shopdb.api contract surface (contract 0.21.0), and refreshes the Import Site Assets page with clearer source instructions. Includes reconcile_locations maintenance script and coverage for the new import paths.
This commit is contained in:
benjamin.baker
2026-08-25 16:46:00 -04:00
parent f4d8868463
commit bdef21da50
21 changed files with 2064 additions and 86 deletions

View File

@@ -0,0 +1,156 @@
"""Generate reconciliation SQL for the locations table.
Reads a TSV export (locationid, locationname, description, locationtypeid,
parentlocationid) and emits a single transaction-wrapped SQL script that:
- Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the
canonical identity for a cell.
- Folds coordinate-plus-suffix rows into the canonical coordinate, moving the
suffix text into the canonical row's description (never the name).
- Folds named-area suffix variants ("NDE - 6409") into the parent named area
("NDE"); named areas are kept as-is and never forced into coordinates.
- Repoints assets.locationid, subnets.locationid and child
locations.parentlocationid onto the canonical row, then deletes duplicates.
Bare coordinates with no variants are left untouched. The trailing W (west
column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so
G2W is kept distinct from a hypothetical G2.
"""
import re
import sys
COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])')
def esc(s):
return s.replace("'", "''")
def load(path):
rows = []
with open(path, encoding='utf-8-sig') as fh:
for line in fh:
line = line.rstrip('\n').rstrip('\r')
if not line:
continue
parts = line.split('\t')
if len(parts) < 5:
parts += [''] * (5 - len(parts))
lid, name, desc, typeid, parentid = parts[:5]
rows.append({
'id': int(lid),
'name': name,
'desc': desc,
'typeid': typeid,
'parentid': parentid,
})
return rows
def classify(name):
"""Return (canonical, suffix). canonical is the bare coordinate or named
parent; suffix is the trailing detail (may be '')."""
m = COORD.match(name)
if m:
prefix, digits, west = m.group(1), m.group(2), m.group(3) or ''
canonical = f"{prefix}{digits}{west}"
suffix = name[m.end():].strip()
suffix = re.sub(r'^[\-\s]+', '', suffix).strip()
return canonical, suffix, 'coord'
# named area: fold on ' - ' separator only
if ' - ' in name:
base, _, rest = name.partition(' - ')
return base.strip(), rest.strip(), 'named'
return name.strip(), '', 'named'
def main(tsv, out):
rows = load(tsv)
# Build canonical groups. Key is casefolded canonical for grouping, but we
# preserve a display canonical (prefer an existing exact bare/standalone).
groups = {}
for r in rows:
canonical, suffix, kind = classify(r['name'])
key = (kind, canonical.casefold())
g = groups.setdefault(key, {
'canonical': canonical,
'kind': kind,
'members': [],
'exact_id': None,
})
r['_suffix'] = suffix
r['_canonical'] = canonical
g['members'].append(r)
# exact row: name equals canonical (no suffix)
if suffix == '' and r['name'].strip() == canonical:
g['exact_id'] = r['id']
g['canonical'] = r['name'].strip()
lines = []
lines.append('-- Auto-generated locations reconciliation. Review before running.')
lines.append('START TRANSACTION;')
touched = 0
for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]):
members = sorted(g['members'], key=lambda r: r['id'])
# nothing to do: single member that is already the exact canonical
if len(members) == 1 and g['exact_id'] == members[0]['id']:
continue
touched += 1
canonical = g['canonical']
# choose target id: prefer exact bare row, else promote smallest id
if g['exact_id'] is not None:
target = g['exact_id']
promote = None
else:
target = members[0]['id']
promote = members[0]
# accumulate suffix/description text from all members (dedup, ordered)
details = []
for m in members:
for chunk in (m['_suffix'], m['desc']):
chunk = (chunk or '').strip()
if chunk and chunk not in details:
details.append(chunk)
desc_val = '; '.join(details)
lines.append('')
lines.append(f"-- {canonical} (target locationid {target}) <= "
+ ', '.join(str(m['id']) for m in members))
if promote is not None:
lines.append(
f"UPDATE locations SET locationname='{esc(canonical)}' "
f"WHERE locationid={target};")
if desc_val:
lines.append(
f"UPDATE locations SET description='{esc(desc_val)}' "
f"WHERE locationid={target};")
others = [m['id'] for m in members if m['id'] != target]
if others:
ids = ','.join(str(i) for i in others)
lines.append(
f"UPDATE assets SET locationid={target} "
f"WHERE locationid IN ({ids});")
lines.append(
f"UPDATE subnets SET locationid={target} "
f"WHERE locationid IN ({ids});")
lines.append(
f"UPDATE locations SET parentlocationid={target} "
f"WHERE parentlocationid IN ({ids});")
lines.append(
f"DELETE FROM locations WHERE locationid IN ({ids});")
lines.append('')
lines.append('COMMIT;')
lines.append(f'-- groups touched: {touched}')
with open(out, 'w', encoding='utf-8') as fh:
fh.write('\n'.join(lines) + '\n')
print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows')
if __name__ == '__main__':
main(sys.argv[1], sys.argv[2])