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,43 @@
"""Add drivername to printerdrivers (exact INF driver name).
`location` points at the driver package; `name` is what a human calls it.
Add-PrinterDriver needs neither - it needs the driver name exactly as the INF
declares it ('HP Universal Printing PCL 6'), which nothing in the row carried.
Nullable: existing rows have no INF name until someone types it in.
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
column already exists (e.g. a test DB built by db.create_all() from the model).
Revision ID: printers0003drivername
Revises: printers0002supplyalerts
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'printers0003drivername'
down_revision = 'printers0002supplyalerts'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'printerdrivers' not in inspector.get_table_names():
return
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
if 'drivername' not in columns:
op.add_column('printerdrivers',
sa.Column('drivername', sa.String(length=255), nullable=True))
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'printerdrivers' not in inspector.get_table_names():
return
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
if 'drivername' in columns:
op.drop_column('printerdrivers', 'drivername')

View File

@@ -0,0 +1,44 @@
"""Give a printer driver a vendor, so one row can serve a whole make.
HP's and Xerox's universal drivers cover 41 of the reference site's 44 printers
between them, but a driver could only be bound to ONE modelnumberid - so covering
them meant 21 near-duplicate rows all pointing at the same package, a table
nobody would keep true. A driver with no model and a vendor now serves every
printer of that make, and a per-model row still wins where one genuinely differs
(a plotter, a card printer, a label printer).
Nullable and guarded: a re-run is a no-op, and existing rows keep working
unchanged because model matching is still tried first.
Revision ID: printers0004drivervendor
Revises: printers0003drivername
"""
from alembic import op
import sqlalchemy as sa
revision = 'printers0004drivervendor'
down_revision = 'printers0003drivername'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
if 'vendorid' not in columns:
op.add_column('printerdrivers', sa.Column('vendorid', sa.Integer(), nullable=True))
# No FK constraint: printerdrivers is a plugin table and vendors is core.
# ADR-008 keeps plugin chains from writing constraints across that line,
# and the resolver treats a vendor that no longer exists as no match.
op.create_index('idx_printerdriver_vendor', 'printerdrivers', ['vendorid'])
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
if 'vendorid' in columns:
op.drop_index('idx_printerdriver_vendor', table_name='printerdrivers')
op.drop_column('printerdrivers', 'vendorid')

View File

@@ -0,0 +1,85 @@
"""printers: printerobservedqueues table (what a bay reported it HAS).
ShopDB already knows what a bay SHOULD have (usesprinter/defaultprinter rows on
the machine). This table holds the other half: the queues a PC reported through
POST /api/collector/printers. Observed and assigned stay in separate tables on
purpose, so observed drift can never be mistaken for desired state.
One row per observed queue; the latest report for a host replaces all of that
host's rows. The unique index on (hostname, queuename) is the guard that turns a
half-finished replace into an IntegrityError instead of duplicate queues.
Explicit ops rather than create_plugin_tables: the helper builds a per-plugin
MetaData filtered to the plugin's own tables, so the foreign key to the core
assets table cannot resolve at CreateTable-compile time. Same reason the backups
baseline spells its ops out.
Guarded both ways, so a re-run (or a database where db.create_all already built
the table) is a no-op.
Revision ID: printers0005observedqueues
Revises: printers0004drivervendor
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'printers0005observedqueues'
down_revision = 'printers0004drivervendor'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'printerobservedqueues' in inspector.get_table_names():
return
op.create_table(
'printerobservedqueues',
sa.Column('printerobservedqueueid', sa.Integer(), nullable=False),
# Nullable and no index of its own beyond the explicit one below: an
# unenrolled bay still gets to report, and hostname is what identifies
# the report.
sa.Column('assetid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=255), nullable=False),
sa.Column('queuename', sa.String(length=255), nullable=False),
sa.Column('drivername', sa.String(length=255), nullable=True),
sa.Column('portname', sa.String(length=255), nullable=True),
sa.Column('portaddress', sa.String(length=255), nullable=True),
sa.Column('isdefault', sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column('isshared', sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column('observedat', sa.DateTime(), nullable=False),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('printerobservedqueueid'),
# Doubles as the read index for the hostname filter (leftmost column),
# so no separate hostname index is created.
sa.UniqueConstraint('hostname', 'queuename',
name='uq_printerobservedqueue_host_queue'),
)
# Port address is the primary match key from an observed queue back to a
# printer asset, so every comparison read hits it.
op.create_index('idx_printerobservedqueues_portaddress',
'printerobservedqueues', ['portaddress'])
op.create_index('idx_printerobservedqueues_assetid',
'printerobservedqueues', ['assetid'])
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'printerobservedqueues' not in inspector.get_table_names():
return
op.drop_index('idx_printerobservedqueues_assetid',
table_name='printerobservedqueues')
op.drop_index('idx_printerobservedqueues_portaddress',
table_name='printerobservedqueues')
op.drop_table('printerobservedqueues')