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.
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""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])
|