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:
156
scripts/reconcile_locations.py
Normal file
156
scripts/reconcile_locations.py
Normal 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])
|
||||
Reference in New Issue
Block a user