"""Machine-level printer assignment and its propagation to the controlling PC. The whole point of the feature: the assignment is a property of the MACHINE, so a reimaged bay PC gets its printers back from the asset register with no backup and no restore step. A PC that carries its own assignment keeps it and shadows the machine's, because an exception recorded on the PC is a deliberate one. Two surfaces are exercised: GET /api/printers/for-host/ what a host should install PUT /api/printers/assignments/for-asset/ reconcile an asset's set Relationship types match `flask seed reference-data`: `usesprinter` means "this printer is installed here" (many), `defaultprinter` means "which of them is the default" (at most one), both directional source -> printer, both propagating read-time through `controls`. """ import pytest from shopdb.core.models import ( Asset, AssetRelationship, AssetType, RelationshipType, ) from plugins.printers.models import Printer HOST_URL = '/api/printers/for-host/%s' ASSIGN_URL = '/api/printers/assignments/for-asset/%d' SHOPFLOOR_HOST = 'SHOPPC01' OFFICE_HOST = 'OFFICEPC01' def _rows(response): """The assignment set out of a for-host payload. Normalized in one place: the endpoint may return the bare list under `data` or wrap it in a `printers` key, and the settled semantics under test are the same either way. """ payload = response.get_json()['data'] if isinstance(payload, dict): payload = payload.get('printers') or [] return payload def _printerids(response): return {row['printerid'] for row in _rows(response)} def _defaultprinterid(response): """The one printer flagged default, or None. Asserts the at-most-one rule on the way out: two defaults reaching a client means the PC picks whichever it saw last, which is the bug this endpoint exists to make impossible. """ flagged = [row['printerid'] for row in _rows(response) if row.get('isdefault')] assert len(flagged) <= 1, 'more than one printer came back flagged default' return flagged[0] if flagged else None def _active_defaults(pc): """Active defaultprinter rows on an asset, read straight from the table. The unique constraint is (source, target, type) and does NOT stop two rows with two different targets, so the one-default rule only holds if the write path enforces it. Counted here rather than inferred from the read side. """ dp_type = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first() return AssetRelationship.query.filter_by( sourceassetid=pc.assetid, relationshiptypeid=dp_type.relationshiptypeid, isactive=True, ).all() @pytest.fixture def scene(db): """A bay PC controlling a machine, an office PC controlling nothing, and three printers. No assignments yet: each test builds the ones it needs. """ from plugins.computers.models import Computer pc_type = AssetType(assettype='computer', pluginname='computers', tablename='computers') machine_type = AssetType(assettype='machine', pluginname='machines', tablename='machines') printer_type = AssetType(assettype='printer', pluginname='printers', tablename='printers') uses_type = RelationshipType( relationshiptype='usesprinter', description='Asset to a printer installed on it', isdirectional=True, ) default_type = RelationshipType( relationshiptype='defaultprinter', description='Asset to its default printer', isdirectional=True, ) controls_type = RelationshipType( relationshiptype='controls', description='Operational authority over another asset', isdirectional=True, ) db.session.add_all([pc_type, machine_type, printer_type, uses_type, default_type, controls_type]) db.session.flush() shopfloorpc = Asset(assetnumber='1001', name='Bay PC', assettypeid=pc_type.assettypeid, isactive=True) officepc = Asset(assetnumber='1002', name='Office PC', assettypeid=pc_type.assettypeid, isactive=True) machine = Asset(assetnumber='2001', name='Lathe', assettypeid=machine_type.assettypeid, isactive=True) db.session.add_all([shopfloorpc, officepc, machine]) db.session.flush() printers = {} for suffix, name in (('A', 'Bay label printer'), ('B', 'Bay laser printer'), ('C', 'Office laser printer')): asset = Asset(assetnumber='PRN-%s' % suffix, name=name, assettypeid=printer_type.assettypeid, isactive=True) db.session.add(asset) db.session.flush() printer = Printer(assetid=asset.assetid, windowsname='PRINTER-%s' % suffix, isnetwork=True, hostname='printer-%s' % suffix.lower()) db.session.add(printer) printers[suffix] = {'asset': asset, 'printer': printer} db.session.add_all([ Computer(assetid=shopfloorpc.assetid, hostname=SHOPFLOOR_HOST), Computer(assetid=officepc.assetid, hostname=OFFICE_HOST), ]) db.session.commit() return { 'shopfloorpc': shopfloorpc, 'officepc': officepc, 'machine': machine, 'printers': printers, 'uses_type': uses_type, 'default_type': default_type, 'controls_type': controls_type, } def _relate(db, source, target, reltype): db.session.add(AssetRelationship( sourceassetid=source.assetid, targetassetid=target.assetid, relationshiptypeid=reltype.relationshiptypeid, )) db.session.commit() def _assign(db, scene, owner, suffixes, default=None): """Write usesprinter rows (and one defaultprinter row) straight to the table.""" for suffix in suffixes: _relate(db, owner, scene['printers'][suffix]['asset'], scene['uses_type']) if default: _relate(db, owner, scene['printers'][default]['asset'], scene['default_type']) def _controls(db, scene): _relate(db, scene['shopfloorpc'], scene['machine'], scene['controls_type']) def _printerid(scene, suffix): return scene['printers'][suffix]['printer'].printerid def _assetid(scene, suffix): return scene['printers'][suffix]['asset'].assetid def test_pc_with_own_assignment_gets_exactly_that(client, db, scene): """A PC's own rows resolve as-is. If this breaks, an assignment recorded against the PC itself either does not reach the host or arrives padded with printers nobody assigned - and the client installs queues on a bay that never asked for them. """ _assign(db, scene, scene['shopfloorpc'], ['A', 'B'], default='A') response = client.get(HOST_URL % SHOPFLOOR_HOST) assert response.status_code == 200 assert _printerids(response) == {_printerid(scene, 'A'), _printerid(scene, 'B')} assert _defaultprinterid(response) == _printerid(scene, 'A') def test_pc_without_assignment_inherits_from_the_machine_it_controls(client, db, scene): """The reimage case, and the reason the feature exists. A rebuilt bay PC has no rows of its own. It must still come back with the machine's printers through its `controls` edge. If inheritance is lost, every reimage costs a technician visit again and the asset register stops being the source of truth for what a bay prints on. """ _assign(db, scene, scene['machine'], ['A', 'B'], default='B') _controls(db, scene) response = client.get(HOST_URL % SHOPFLOOR_HOST) assert response.status_code == 200 assert _printerids(response) == {_printerid(scene, 'A'), _printerid(scene, 'B')} assert _defaultprinterid(response) == _printerid(scene, 'B') def test_own_assignment_overrides_the_machine_rather_than_merging(client, db, scene): """Own rows shadow inherited ones. They do not add to them. A PC row is how a site records a deliberate exception ("this bay's PC prints to the office laser instead"). Merging would silently reinstate exactly the printers the exception was written to remove, and no amount of editing the PC would ever get rid of them. """ _assign(db, scene, scene['machine'], ['A', 'B'], default='A') _controls(db, scene) _assign(db, scene, scene['shopfloorpc'], ['C'], default='C') response = client.get(HOST_URL % SHOPFLOOR_HOST) assert response.status_code == 200 assert _printerids(response) == {_printerid(scene, 'C')} assert _defaultprinterid(response) == _printerid(scene, 'C') def test_office_pc_controlling_no_machine_resolves_empty(client, db, scene): """A PC with no machine and no assignment is a valid answer, not an error. Most office PCs control nothing. The walk must end quietly and return an empty set: a 404 or a 500 here would make the client script log a failure on every cycle on every office PC, and real failures would drown in it. """ response = client.get(HOST_URL % OFFICE_HOST) assert response.status_code == 200 assert _rows(response) == [] assert _defaultprinterid(response) is None def test_default_is_optional(client, db, scene): """Printers with no default is a legitimate state. A bay with three printers and no default exists on the floor. If the API forces a default, a write either fails or invents one, and the client then changes a user's default printer because ShopDB picked arbitrarily. """ _assign(db, scene, scene['machine'], ['A', 'B', 'C']) _controls(db, scene) response = client.get(HOST_URL % SHOPFLOOR_HOST) assert response.status_code == 200 assert len(_rows(response)) == 3 assert _defaultprinterid(response) is None assert _active_defaults(scene['machine']) == [] def test_setting_a_default_replaces_the_existing_one(client, db, scene, auth_headers): """Two active defaults must be impossible. The unique constraint is (source, target, type), so a second default INSERTs cleanly and nothing complains. Then the read side returns two, the client picks whichever it iterated last, and the bay's default printer flips at random between cycles. """ first = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={ 'printerassetids': [_assetid(scene, 'A'), _assetid(scene, 'B')], 'defaultprinterassetid': _assetid(scene, 'A'), }) assert first.status_code == 200 second = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={ 'printerassetids': [_assetid(scene, 'A'), _assetid(scene, 'B')], 'defaultprinterassetid': _assetid(scene, 'B'), }) assert second.status_code == 200 defaults = _active_defaults(scene['machine']) assert len(defaults) == 1 assert defaults[0].targetassetid == _assetid(scene, 'B') _controls(db, scene) response = client.get(HOST_URL % SHOPFLOOR_HOST) assert _defaultprinterid(response) == _printerid(scene, 'B') def test_default_must_be_one_of_the_assigned_printers(client, db, scene, auth_headers): """A default outside the assignment set is rejected. Otherwise the client is told to default to a queue it was never told to install, fails to set it, and the bay looks broken with nothing in ShopDB showing why. """ response = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={ 'printerassetids': [_assetid(scene, 'A')], 'defaultprinterassetid': _assetid(scene, 'C'), }) assert response.status_code == 400 assert _active_defaults(scene['machine']) == [] def test_unassigning_the_default_printer_clears_the_default(client, db, scene, auth_headers): """Removing a printer takes its default with it. A default row left pointing at an unassigned printer is the dangling case: the printer disappears from the install set while the client is still told to make it the default. Removal is server-side only - it uninstalls nothing. """ assigned = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={ 'printerassetids': [_assetid(scene, 'A'), _assetid(scene, 'B')], 'defaultprinterassetid': _assetid(scene, 'B'), }) assert assigned.status_code == 200 unassigned = client.put(ASSIGN_URL % scene['machine'].assetid, headers=auth_headers, json={ 'printerassetids': [_assetid(scene, 'A')], 'defaultprinterassetid': None, }) assert unassigned.status_code == 200 assert _active_defaults(scene['machine']) == [] _controls(db, scene) response = client.get(HOST_URL % SHOPFLOOR_HOST) assert _printerids(response) == {_printerid(scene, 'A')} assert _defaultprinterid(response) is None def test_unknown_hostname_is_a_404(client, db, scene): """A host ShopDB has never heard of is an error, not an empty set. Empty means "this PC is assigned nothing", which the client treats as a safe no-op. A mistyped or unenrolled hostname returning empty looks exactly the same, so a bay would sit unconfigured with nothing anywhere saying its record is missing. """ response = client.get(HOST_URL % 'NOSUCHHOST') assert response.status_code == 404