Assign printers to a machine, and let the PC that drives it inherit them

Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.

New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.

Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.

GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".

PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.

One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.

printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
This commit is contained in:
cproudlock
2026-08-19 09:33:22 -04:00
parent 03d0754fdc
commit 0dc0ac13c8
10 changed files with 1499 additions and 52 deletions

View File

@@ -0,0 +1,342 @@
"""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/<hostname> what a host should install
PUT /api/printers/assignments/for-asset/<id> 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