Printer assignment end to end, and a container that keeps a site's files

Assign printers to a MACHINE and let whichever PC controls it install them, with
the observed half kept strictly apart from the assigned half: a collector report
can never write an assignment row. Adoption is explicit. Adds a singular
relationship type so defaultprinter can mean exactly one, client scripts that
converge a bay's queues and correct a drifted one in place, and a wave gate so a
fleet-wide driver rollout cannot stampede its own share.

On the container side: the compose stack never persisted the instance directory,
so a rebuild discarded plugins.json and every upload. It is a volume now, the
image ships instance/ owned by the app user so the mountpoint is writable, and
the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended
since 8.0 went end of life. The image also could not build at all: the frontend
stage never copied the plugin staging script it runs.

Adds a topology migration guide covering IIS to Docker and back.
This commit is contained in:
2026-08-20 07:46:35 -04:00
parent 09e9d5f047
commit d267813999
45 changed files with 6475 additions and 56 deletions

View File

@@ -0,0 +1,116 @@
"""A relationship type that means "at most one of these per asset".
A PC has ONE default printer. The unique constraint on assetrelationships is
(source, target, type), which accepts two DIFFERENT defaults quite happily - and
the resolver takes the oldest, so setting a new default through the generic
relationships card left the OLD one winning, with nothing to show why.
The rule lives on the type, not in the printers plugin: core's create path is
where every hand-made link passes, and the next type meaning "exactly one" gets
it for free.
"""
import pytest
from shopdb.core.models import Asset, AssetType, AssetRelationship, RelationshipType
@pytest.fixture
def scene(db):
assettype = AssetType.query.filter_by(assettype='printer').first()
if not assettype:
assettype = AssetType(assettype='printer', pluginname='printers',
tablename='printers', description='p')
db.session.add(assettype)
db.session.flush()
pc = Asset(assetnumber='PC-SINGULAR', assettypeid=assettype.assettypeid, isactive=True)
first = Asset(assetnumber='PRN-A', assettypeid=assettype.assettypeid, isactive=True)
second = Asset(assetnumber='PRN-B', assettypeid=assettype.assettypeid, isactive=True)
db.session.add_all([pc, first, second])
singular = RelationshipType.query.filter_by(relationshiptype='defaultprinter').first()
if not singular:
singular = RelationshipType(relationshiptype='defaultprinter',
description='default', isdirectional=True)
db.session.add(singular)
singular.issingular = True
plural = RelationshipType.query.filter_by(relationshiptype='usesprinter').first()
if not plural:
plural = RelationshipType(relationshiptype='usesprinter',
description='installed here', isdirectional=True)
db.session.add(plural)
plural.issingular = False
db.session.commit()
return {'pc': pc, 'first': first, 'second': second,
'singular': singular, 'plural': plural}
def _active(pc, reltype):
return AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, relationshiptypeid=reltype.relationshiptypeid,
isactive=True).all()
def _link(client, headers, source, target, reltype):
return client.post('/api/assets/relationships', headers=headers, json={
'sourceassetid': source.assetid,
'targetassetid': target.assetid,
'relationshiptypeid': reltype.relationshiptypeid,
})
def test_setting_a_second_default_replaces_the_first(client, db, scene, auth_headers):
"""The bug this exists for. Both POSTs succeed today and the table then
holds two defaults."""
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['singular']).status_code == 201
assert _link(client, auth_headers, scene['pc'], scene['second'], scene['singular']).status_code == 201
rows = _active(scene['pc'], scene['singular'])
assert len(rows) == 1
assert rows[0].targetassetid == scene['second'].assetid
def test_the_replaced_row_is_soft_deleted_not_destroyed(client, db, scene, auth_headers):
"""Consistent with every other delete here, and it keeps the history."""
_link(client, auth_headers, scene['pc'], scene['first'], scene['singular'])
_link(client, auth_headers, scene['pc'], scene['second'], scene['singular'])
old = AssetRelationship.query.filter_by(
sourceassetid=scene['pc'].assetid,
targetassetid=scene['first'].assetid,
relationshiptypeid=scene['singular'].relationshiptypeid).first()
assert old is not None
assert old.isactive is False
def test_a_plural_type_is_untouched(client, db, scene, auth_headers):
"""usesprinter means "installed here" and a bay has several. If the rule
leaked to every type, assigning a second printer would remove the first."""
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['plural']).status_code == 201
assert _link(client, auth_headers, scene['pc'], scene['second'], scene['plural']).status_code == 201
assert len(_active(scene['pc'], scene['plural'])) == 2
def test_setting_the_same_default_twice_is_still_a_conflict(client, db, scene, auth_headers):
"""Replacing is for a DIFFERENT target. The same link twice is the existing
duplicate case and must keep answering 409, or the card loses its only
signal that nothing changed."""
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['singular']).status_code == 201
assert _link(client, auth_headers, scene['pc'], scene['first'], scene['singular']).status_code == 409
assert len(_active(scene['pc'], scene['singular'])) == 1
def test_another_asset_keeps_its_own_default(client, db, scene, auth_headers):
"""The rule is per SOURCE. One PC's default must not disturb another's."""
other = Asset(assetnumber='PC-OTHER', assettypeid=scene['pc'].assettypeid, isactive=True)
db.session.add(other)
db.session.commit()
_link(client, auth_headers, scene['pc'], scene['first'], scene['singular'])
_link(client, auth_headers, other, scene['second'], scene['singular'])
assert len(_active(scene['pc'], scene['singular'])) == 1
assert len(_active(other, scene['singular'])) == 1