Merge wilm: endpoints plugin, agent, policies, CSV import, Entra fields

Thirteen commits from benjamin.baker plus the review fixes. Everything he built
is here: the endpoints plugin and its command queue, the .NET endpoint agent,
the computers policies subsystem, the site-asset CSV import with its adapters
and collector, Entra device identity, and the catalog hand-merge.

His branch predates four commits on main (section ticking, the generator share
links, Secure Send copy message, and the smoke-test workflow), so those come
through untouched - nothing of ours is removed by this merge.

wilm stays as its own branch; this does not close it.
This commit is contained in:
2026-08-28 15:56:58 -04:00
121 changed files with 15395 additions and 562 deletions

View File

@@ -0,0 +1,187 @@
"""computers.csv can carry the machine it drives, and the link is built on import.
A site with a spreadsheet knows "this PC drives machine CNC-014" and wants to
write that in the PC row, not hand-build an assetrelationships sheet keyed on
ids it never sees. The importer accepts a virtual `machineassetnumber` column,
keeps it out of the computers write, and hands it to the computers plugin after
each PC row is written. The rules that matter for a cold user driving this off a
spreadsheet:
- a good number builds the PC-controls-machine link;
- a number that has no machine, names this PC itself, names a location-only
marker, or is claimed twice in one file, is an AMBER warning and is skipped -
the PC still imports, the file is still ok;
- a dry run builds nothing and, critically, sends no alert;
- re-running the same file does not duplicate the link.
"""
import pytest
from shopdb.core.services.csvimport import (
Resolver, import_csv, virtual_columns_for,
)
from shopdb.extensions import db as _db
REFERENCE = [
('assettypes', 'assettype\nComputer\nMachine\n'),
('assetstatuses', 'status\nIn Use\n'),
('locationtypes', 'locationtype\nBay\n'),
]
def _seed(resolver):
"""Reference rows, a location, and the 'controls' relationship type."""
from shopdb.core.models.relationship import RelationshipType
for table, text in REFERENCE:
result = import_csv(table, text, resolver=resolver, commit=True)
assert result.ok, [str(p) for p in result.problems]
result = import_csv(
'locations', 'locationname,locationtypeid\nBay 3,Bay\n',
resolver=resolver, commit=True)
assert result.ok, [str(p) for p in result.problems]
if not RelationshipType.query.filter_by(relationshiptype='controls').first():
_db.session.add(RelationshipType(relationshiptype='controls'))
# import_csv writes into the session but never commits; commit the setup so
# a dry-run import's rollback only unwinds the dry run, not the fixtures.
_db.session.commit()
def _assets(resolver, *rows):
"""Create the given assets. Each row is (assetnumber, assettype)."""
body = 'assetnumber,assettypeid,statusid,locationid\n'
body += ''.join('%s,%s,In Use,Bay 3\n' % r for r in rows)
result = import_csv('assets', body, resolver=resolver, commit=True)
assert result.ok, [str(p) for p in result.problems]
_db.session.commit()
def _import_computers(resolver, body, commit=True):
return import_csv('computers', body, resolver=resolver, commit=commit)
def _controls_target(pc_assetnumber):
"""Asset number of the machine this PC actively controls, or None."""
from shopdb.core.models import Asset
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
pc = Asset.query.filter_by(assetnumber=pc_assetnumber).first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
rel = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pc.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.isactive.is_(True)).first()
if rel is None:
return None
return _db.session.get(Asset, rel.targetassetid).assetnumber
def test_the_column_is_virtual_and_owned_by_the_computers_plugin(db):
"""The plugin registers machineassetnumber; core stays ignorant of it."""
assert 'machineassetnumber' in virtual_columns_for('computers')
def test_a_good_machine_number_builds_the_link(db):
resolver = Resolver()
_seed(resolver)
_assets(resolver, ('PC-001', 'Computer'), ('CNC-014', 'Machine'))
result = _import_computers(
resolver,
'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,CNC-014\n')
assert result.ok, [str(p) for p in result.problems]
assert result.warnings == []
assert _controls_target('PC-001') == 'CNC-014'
def test_an_empty_machine_number_links_nothing_and_does_not_warn(db):
resolver = Resolver()
_seed(resolver)
_assets(resolver, ('PC-001', 'Computer'))
result = _import_computers(
resolver, 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,\n')
assert result.ok
assert result.warnings == []
assert _controls_target('PC-001') is None
def test_a_machine_number_with_no_machine_warns_and_skips(db):
"""The PC still imports; the unresolved number is amber, not a failed file."""
resolver = Resolver()
_seed(resolver)
_assets(resolver, ('PC-001', 'Computer'))
result = _import_computers(
resolver, 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,NOPE-99\n')
assert result.ok, 'an unresolved machine number must not fail the file'
assert len(result.warnings) == 1
warning = result.warnings[0]
assert warning.column == 'machineassetnumber'
assert warning.target == 'NOPE-99'
assert warning.action, 'a warning must carry the one corrective step'
assert _controls_target('PC-001') is None
def test_a_pc_naming_its_own_asset_number_is_not_self_linked(db):
resolver = Resolver()
_seed(resolver)
_assets(resolver, ('PC-001', 'Computer'))
result = _import_computers(
resolver, 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,PC-001\n')
assert result.ok
assert len(result.warnings) == 1
assert 'own asset number' in result.warnings[0].reason
assert _controls_target('PC-001') is None
def test_two_rows_claiming_one_machine_link_only_the_first(db):
"""The in-file fan-out guard: a copied cell must not steal the bay."""
resolver = Resolver()
_seed(resolver)
_assets(resolver,
('PC-001', 'Computer'), ('PC-002', 'Computer'), ('CNC-014', 'Machine'))
result = _import_computers(
resolver,
'assetid,hostname,machineassetnumber\n'
'PC-001,WS-PC-001,CNC-014\n'
'PC-002,WS-PC-002,CNC-014\n')
assert result.ok
assert len(result.warnings) == 1
assert 'already claimed' in result.warnings[0].reason
assert _controls_target('PC-001') == 'CNC-014'
assert _controls_target('PC-002') is None
def test_a_dry_run_builds_no_link_and_leaves_no_trace(db):
resolver = Resolver()
_seed(resolver)
_assets(resolver, ('PC-001', 'Computer'), ('CNC-014', 'Machine'))
result = _import_computers(
resolver,
'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,CNC-014\n',
commit=False)
assert result.ok
assert result.created == 1
_db.session.rollback()
assert _controls_target('PC-001') is None
def test_reimporting_the_same_link_does_not_duplicate(db):
resolver = Resolver()
_seed(resolver)
_assets(resolver, ('PC-001', 'Computer'), ('CNC-014', 'Machine'))
body = 'assetid,hostname,machineassetnumber\nPC-001,WS-PC-001,CNC-014\n'
first = _import_computers(resolver, body)
assert first.ok and first.warnings == []
second = _import_computers(resolver, body)
assert second.ok and second.warnings == []
assert _controls_target('PC-001') == 'CNC-014'
from shopdb.core.models import Asset
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
pc = Asset.query.filter_by(assetnumber='PC-001').first()
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
active = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pc.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.isactive.is_(True)).count()
assert active == 1, 'a re-import must not stack a second active link'