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

@@ -173,14 +173,17 @@ def test_generated_templates_only_contain_real_columns(db):
A hand-written template set had invented columns on 7 of 11 tables and named
a table that does not exist. Generating from the schema is what makes that
impossible - so assert the generator actually does it.
impossible - so assert the generator actually does it. Plugin-registered
virtual columns (machineassetnumber) are legitimate, not invented, so they
are allowed alongside the table's real columns.
"""
from shopdb.core.services.csvimport import virtual_columns_for
for table in IMPORTABLE:
text = generate_template(table)
header = [line for line in text.splitlines() if line and not line.startswith('#')][0]
real = {c.name for c in importable_columns(table)}
allowed = {c.name for c in importable_columns(table)} | virtual_columns_for(table)
for column in header.split(','):
assert column in real, '%s.csv offers a column that does not exist: %s' % (table, column)
assert column in allowed, '%s.csv offers a column that does not exist: %s' % (table, column)
def test_every_template_includes_its_required_columns(db):
@@ -234,3 +237,46 @@ def test_a_dry_run_over_several_files_resolves_across_them(db):
assert result.ok, [str(p) for p in result.problems]
_db.session.rollback()
assert count('assets') == 0
def _example_files():
"""Reconstruct each template's example rows as an importable CSV: the header
plus the live (uncommented) example data rows that follow it."""
files = {}
for table in IMPORTABLE:
header = None
rows = []
for line in generate_template(table).splitlines():
if not line.strip():
continue
if line.startswith('#'):
continue
if header is None:
header = line
else:
rows.append(line)
files[table] = '\n'.join([header] + rows) + '\n'
return files
def test_template_examples_are_present_for_every_table(db):
"""Each template ships at least one worked example row, so a downloaded
set is a usable starter workbook rather than a bare header."""
files = _example_files()
for table in IMPORTABLE:
rows = files[table].splitlines()[1:]
assert rows, '%s.csv has no example row' % table
def test_template_example_set_imports_cleanly(db):
"""The whole curated example set must import as-is: every name a later file
references is defined by an earlier one, with no self-references that the
validation pass cannot resolve. This is the promise the workbook makes."""
resolver = Resolver()
files = _example_files()
for table in IMPORTABLE:
result = import_csv(table, files[table], resolver=resolver)
assert result.ok, '%s.csv example failed: %s' % (
table, [str(p) for p in result.problems])
_db.session.rollback()