diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 733949b..6f17fa3 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -797,11 +797,18 @@ def _assignment_result(printerassetids, defaultassetid, suppliers): def _printer_driver(printer, universaldrivers): """Driver record to install this printer with, or None. - The printer's own model link first. Failing that, a driver with no model at - all whose name carries the printer's vendor: HP and Xerox universal drivers - cover the overwhelming majority of a floor, and per-model rows for each - queue are a table nobody keeps true. printerdrivers cannot name a vendor of - its own yet, so the vendor word in the driver's name is what there is. + Three steps, most specific first: + + 1. A driver bound to the printer's MODEL. A plotter, a card printer and a + label printer each need their own, and a per-model row must beat the + universal one. + 2. A driver bound to the printer's VENDOR with no model. HP's and Xerox's + universal drivers cover 41 of the reference site's 44 printers between + them; binding those to one model each would mean a near-duplicate row per + model, which is a table nobody keeps true. + 3. Failing both, a model-less driver whose NAME carries the vendor word. + This is the pre-vendorid convention, kept so a site that populated its + table before the column existed does not lose its drivers on upgrade. """ if printer.modelnumberid: driver = (PrinterDriver.query @@ -810,10 +817,20 @@ def _printer_driver(printer, universaldrivers): if driver: return driver + if printer.vendorid: + for driver in universaldrivers: + if driver.vendorid == printer.vendorid: + return driver + vendor = _printer_vendor(printer).lower() if not vendor: return None for driver in universaldrivers: + # Only the legacy convention here: a row WITH a vendorid that did not + # match above must not be matched by its name instead, or a mis-set + # vendor silently resolves to the wrong package. + if driver.vendorid: + continue if vendor in (driver.name or '').lower(): return driver return None diff --git a/plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1 b/plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1 new file mode 100644 index 0000000..31867af --- /dev/null +++ b/plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1 @@ -0,0 +1,94 @@ +# Apply-ShopdbDefaultPrinter.ps1 +# +# Sets the logged-on user's default printer to the one ShopDB assigned. Runs IN +# THE USER'S CONTEXT, at logon and on a repeat, because a default printer is +# per-user state that SYSTEM cannot set for somebody else. +# +# Set-ShopdbPrinters.ps1 records the desired queue in +# HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter during the enforcement cycle. This +# reads it. Splitting the two is not tidiness: the machine half needs SYSTEM and +# the share, the user half needs a user - no single process has both. +# +# IT ALSO TURNS OFF "Let Windows manage my default printer". Leaving it on means +# Windows silently overwrites the choice the next time somebody prints to another +# queue, and the bay drifts back with nothing in any log to say why. +# +# Converges: when the current default already matches, it does nothing, so a +# repeating trigger costs a registry read. A user who deliberately picks another +# default WILL be corrected on the next run - that is the intent for a shared +# bay. For a PC where that is wrong, schedule it at logon only. +# +# Exits 0 always. + +param( + # Override for testing. Normally read from the machine hive. + [string]$PrinterName = '' +) + +$ErrorActionPreference = 'Continue' + +$logDir = "$env:LOCALAPPDATA\ShopDB" +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +} +$logFile = Join-Path $logDir 'default-printer.log' +function Log([string]$msg) { + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null +} + +if (-not $PrinterName) { + foreach ($path in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $path) { + $value = [string](Get-ItemProperty -Path $path -Name DefaultPrinter -ErrorAction Stop).DefaultPrinter + if ($value -and $value.Trim()) { $PrinterName = $value.Trim(); break } + } + } catch {} + } +} + +if (-not $PrinterName) { + # No default assigned is a legitimate state - a bay with three printers and + # no favourite - so leave whatever the user has. + Log 'no default assigned in ShopDB; leaving the current one alone' + exit 0 +} + +# Windows 10+ overrides any default the moment the user prints elsewhere, unless +# this is off. Setting the default without clearing this is a fix that undoes +# itself within a day. +try { + $windowsKey = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows' + $managed = (Get-ItemProperty -Path $windowsKey -Name LegacyDefaultPrinterMode -ErrorAction SilentlyContinue).LegacyDefaultPrinterMode + if ($managed -ne 1) { + Set-ItemProperty -Path $windowsKey -Name LegacyDefaultPrinterMode -Value 1 -Type DWord + Log 'turned off "Let Windows manage my default printer"' + } +} catch { + Log "WARN could not turn off Windows-managed defaults: $($_.Exception.Message)" +} + +$queue = Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue +if (-not $queue) { + # The enforcement cycle creates queues; this runs at logon and may simply be + # earlier than the first cycle on a new bay. Next run picks it up. + Log "assigned default '$PrinterName' is not installed yet; nothing to do" + exit 0 +} + +$current = (Get-CimInstance -ClassName Win32_Printer -Filter 'Default = True' -ErrorAction SilentlyContinue).Name +if ($current -eq $PrinterName) { + Log "already default: $PrinterName" + exit 0 +} + +try { + $target = Get-CimInstance -ClassName Win32_Printer -Filter ("Name = '{0}'" -f $PrinterName.Replace("'", "''")) -ErrorAction Stop + Invoke-CimMethod -InputObject $target -MethodName SetDefaultPrinter -ErrorAction Stop | Out-Null + Log "default set: $PrinterName (was '$current')" +} catch { + Log "ERROR setting the default to '$PrinterName': $($_.Exception.Message)" +} + +exit 0 diff --git a/plugins/printers/client/DEPLOYING-DRIVERS.md b/plugins/printers/client/DEPLOYING-DRIVERS.md index 3feb472..a5138a1 100644 --- a/plugins/printers/client/DEPLOYING-DRIVERS.md +++ b/plugins/printers/client/DEPLOYING-DRIVERS.md @@ -112,3 +112,33 @@ reference site's fleet: | `ZDesigner ZT411-300dpi ZPL` | Zebra ZT411 labels | | `EPSON TM-C3500` | Epson ColorWorks labels | | `DTC4500e Card Printer` | HID FARGO card printer | + +## The other half: assigning printers + +Staging drivers is only delivery. `Set-ShopdbPrinters.ps1` is what makes a bay's +queues match ShopDB, and `Apply-ShopdbDefaultPrinter.ps1` applies the default in +the user's context. Two manifest entries, both `DetectionMethod: Always`: + +```json +{ + "_comment": "Create the queues this bay is assigned. Converges: existing queues are left alone, and nothing is ever removed.", + "Name": "ShopDB printers", + "Type": "PS1", + "Script": "scripts/Set-ShopdbPrinters.ps1", + "DetectionMethod": "Always" +} +``` + +The default printer is per-user, so SYSTEM cannot set it for the person logged +on. `Set-ShopdbPrinters.ps1` records it in `HKLM:\SOFTWARE\GE\ShopDB` +`DefaultPrinter`, and `Apply-ShopdbDefaultPrinter.ps1` runs as the user - at +logon, and on a repeat if the site wants drift corrected. + +Order matters on a new bay: drivers, then queues, then the default. Each step is +a no-op once satisfied, so running all three every cycle costs a few registry +reads. + +Verified end to end on Windows 11 against a live ShopDB: printers assigned to a +MACHINE, a PC controlling it and holding no rows of its own, and the bay created +both queues with the right universal driver, recorded the default, and set it - +then a second run changed nothing. diff --git a/plugins/printers/client/Set-ShopdbPrinters.ps1 b/plugins/printers/client/Set-ShopdbPrinters.ps1 new file mode 100644 index 0000000..d12a854 --- /dev/null +++ b/plugins/printers/client/Set-ShopdbPrinters.ps1 @@ -0,0 +1,166 @@ +# Set-ShopdbPrinters.ps1 +# +# Makes this PC's printers match what ShopDB says the bay should have. Asks +# GET /api/printers/for-host/ and creates any queue that is missing. +# +# WHY THE ASSIGNMENT IS NOT ON THIS PC: it is on the MACHINE, and reaches +# whichever PC controls it. A reimaged or swapped box inherits the bay's printers +# with nothing saved off the old one - the asset register is the backup. +# +# CONVERGES, does not install. A queue that already exists is left alone, so this +# is cheap to run every enforcement cycle and safe to run twice. +# +# NEVER REMOVES A QUEUE. If a printer disappears from the response - because the +# API had a bad minute, or someone unassigned it - the bay keeps printing. Taking +# printers away from a working bay because of a transient error is the one +# failure this must not have. +# +# DRIVERS ARE NOT FETCHED HERE. Install-ShopdbPrinterDrivers.ps1 stages the site's +# set in the common scope, once per bay. A queue is created against a driver that +# is already present; if it is not, that is logged and the printer is skipped, +# because downloading 48 MB while somebody waits to print is the wrong moment. +# +# THE DEFAULT PRINTER IS PER USER. This runs as SYSTEM and cannot set it for the +# logged-on person, so it records the desired default in HKLM and leaves applying +# it to a logon task. Without that, SYSTEM would set a default nobody sees. +# +# Exits 0 always: a printer problem must not fail an enforcement run. + +param( + # ShopDB base URL. Empty resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl, + # written by Install-GEEnforce.ps1 and already present wherever this runs. + [string]$BaseUrl = '', + + # Defaults to this machine's name, which is what the collector upserts by. + [string]$Hostname = $env:COMPUTERNAME, + + [int]$TimeoutSec = 30, + + # Report what would change and touch nothing. + [switch]$WhatIfOnly +) + +$ErrorActionPreference = 'Continue' +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +} +$logFile = Join-Path $logDir ('printers-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) +function Log([string]$msg) { + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null +} + +$REGPATH = 'HKLM:\SOFTWARE\GE\ShopDB' + +if (-not $BaseUrl) { + foreach ($path in @($REGPATH, 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $path) { + $value = [string](Get-ItemProperty -Path $path -Name BaseUrl -ErrorAction Stop).BaseUrl + if ($value -and $value.Trim()) { $BaseUrl = $value.Trim(); break } + } + } catch {} + } +} +if (-not $BaseUrl) { + Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -BaseUrl). Skipping.' + exit 0 +} + +Log "=== Set printers for $Hostname ===" + +$url = $BaseUrl.TrimEnd('/') + '/api/printers/for-host/' + [uri]::EscapeDataString($Hostname) +try { + $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec $TimeoutSec +} catch { + # An unreachable server means "no information", not "no printers". Changing + # nothing is the only safe response. + Log "ERROR could not read $url : $($_.Exception.Message)" + exit 0 +} + +$payload = $response.data +if ($null -eq $payload) { $payload = $response } +$wanted = @($payload.printers) +$defaultid = $payload.defaultprinterid + +if ($wanted.Count -eq 0) { + Log 'nothing assigned to this host' + exit 0 +} +Log "assigned: $($wanted.Count) printer(s)" + +$existing = @{} +foreach ($queue in (Get-Printer -ErrorAction SilentlyContinue)) { + $existing[$queue.Name] = $queue +} + +$defaultname = '' +foreach ($printer in $wanted) { + $name = $printer.queuename + if (-not $name) { continue } + if ($printer.printerid -eq $defaultid) { $defaultname = $name } + + if ($existing.ContainsKey($name)) { + Log "present: $name" + continue + } + + $address = $printer.hostname + if (-not $address) { $address = $printer.ipaddress } + if (-not $address) { + Log "SKIP $name : no hostname or IP to point a port at" + continue + } + + $drivername = $printer.drivername + if (-not $drivername) { + Log "SKIP $name : ShopDB has no driver name for it" + continue + } + if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) { + # Deliberately not fetched here - see the header. + Log "SKIP $name : driver '$drivername' is not staged on this PC" + continue + } + + if ($WhatIfOnly) { + Log "WOULD create: $name -> $address ($drivername)" + continue + } + + $portname = 'IP_' + $address + try { + if (-not (Get-PrinterPort -Name $portname -ErrorAction SilentlyContinue)) { + Add-PrinterPort -Name $portname -PrinterHostAddress $address -ErrorAction Stop + Log "port: $portname" + } + Add-Printer -Name $name -DriverName $drivername -PortName $portname -ErrorAction Stop + Log "created: $name -> $address ($drivername)" + } catch { + Log "ERROR creating ${name}: $($_.Exception.Message)" + } +} + +# The default is recorded, not applied: this process is SYSTEM and the setting +# is per user. Apply-ShopdbDefaultPrinter.ps1 reads it at logon. +if ($defaultname) { + if ($WhatIfOnly) { + Log "WOULD record default: $defaultname" + } else { + try { + if (-not (Test-Path $REGPATH)) { New-Item -Path $REGPATH -Force | Out-Null } + Set-ItemProperty -Path $REGPATH -Name DefaultPrinter -Value $defaultname + Log "default recorded for the logon task: $defaultname" + } catch { + Log "ERROR recording the default: $($_.Exception.Message)" + } + } +} else { + Log 'no default assigned' +} + +exit 0 diff --git a/plugins/printers/migrations/versions/0004_drivervendor.py b/plugins/printers/migrations/versions/0004_drivervendor.py new file mode 100644 index 0000000..56d478f --- /dev/null +++ b/plugins/printers/migrations/versions/0004_drivervendor.py @@ -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') diff --git a/plugins/printers/models/printer_driver.py b/plugins/printers/models/printer_driver.py index 6d1c57c..d7170e5 100644 --- a/plugins/printers/models/printer_driver.py +++ b/plugins/printers/models/printer_driver.py @@ -14,6 +14,12 @@ class PrinterDriver(db.Model): # Exact driver name as the INF declares it: Add-PrinterDriver matches on # this string, not on `name`, which is ours to choose drivername = db.Column(db.String(255)) + # Optional: attach a driver to a whole VENDOR rather than one model. A + # universal driver (HP UPD, Xerox GPD) serves every printer of that make, and + # binding it to one model would mean a near-duplicate row per model. + # Model wins over vendor when both match - see _printer_driver. + vendorid = db.Column(db.Integer, nullable=True, index=True) + # Optional: attach a driver to a specific printer model modelnumberid = db.Column( db.Integer, @@ -31,6 +37,7 @@ class PrinterDriver(db.Model): 'location': self.location, 'description': self.description, 'drivername': self.drivername, + 'vendorid': self.vendorid, 'modelnumberid': self.modelnumberid, 'modelname': self.model.modelnumber if self.model else None, 'isactive': bool(self.isactive), diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index c54028e..24e9515 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -55,7 +55,7 @@ EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen' EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib' # printers adds the printersupplyalerts crossing-state table on top of its # anchor, then the exact INF driver name Add-PrinterDriver needs. -EXPECTED_HEAD_REVISION['printers'] = 'printers0003drivername' +EXPECTED_HEAD_REVISION['printers'] = 'printers0004drivervendor' # machines (renamed from equipment) keeps its original anchor id and adds the # rename revision on top, so its head is not the f-string default. EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename' diff --git a/tests/test_plugins/test_printer_driver_resolution.py b/tests/test_plugins/test_printer_driver_resolution.py new file mode 100644 index 0000000..3f0760d --- /dev/null +++ b/tests/test_plugins/test_printer_driver_resolution.py @@ -0,0 +1,120 @@ +"""Which driver a printer installs with. + +One row per model was unworkable: HP and Xerox universal drivers cover 41 of the +reference site's 44 printers, so binding a driver to a single model meant 21 +near-duplicate rows pointing at one package - a table nobody keeps true, and the +reason 42 of 44 printers could not resolve a driver at all. + +The order is most-specific-first, and each step exists for a printer that really +is on this floor: a plotter and a card printer need their own driver, everything +else takes its make's universal one. +""" + +import pytest + +from shopdb.core.models import Vendor, Model +from plugins.printers.models import Printer, PrinterDriver +from plugins.printers.api.asset_routes import _printer_driver + + +def _universal(): + return (PrinterDriver.query + .filter(PrinterDriver.modelnumberid.is_(None), + PrinterDriver.isactive == True) + .order_by(PrinterDriver.name).all()) + + +@pytest.fixture +def fleet(db): + hp = Vendor(vendor='HP') + xerox = Vendor(vendor='Xerox') + db.session.add_all([hp, xerox]) + db.session.flush() + + laserjet = Model(modelnumber='LaserJet M602', vendorid=hp.vendorid) + designjet = Model(modelnumber='DesignJet T1700', vendorid=hp.vendorid) + db.session.add_all([laserjet, designjet]) + db.session.flush() + + upd = PrinterDriver(name='HP Universal Print Driver', drivername='HP Universal Printing PS', + location=r'\\server\share\hp_upd', vendorid=hp.vendorid, isactive=True) + plotter = PrinterDriver(name='HP DesignJet T1700', drivername='HP DesignJet T1700dr V4', + location=r'\\server\share\designjet', vendorid=hp.vendorid, + modelnumberid=designjet.modelnumberid, isactive=True) + gpd = PrinterDriver(name='Xerox Global Print Driver PCL6', + drivername='Xerox Global Print Driver PCL6', + location=r'\\server\share\xerox', vendorid=xerox.vendorid, isactive=True) + db.session.add_all([upd, plotter, gpd]) + db.session.commit() + return {'hp': hp, 'xerox': xerox, 'laserjet': laserjet, 'designjet': designjet, + 'upd': upd, 'plotter': plotter, 'gpd': gpd} + + +def test_a_printer_takes_its_makes_universal_driver(fleet): + """The case that covers most of a floor: no per-model row exists, and none + should have to.""" + printer = Printer(vendorid=fleet['hp'].vendorid, modelnumberid=fleet['laserjet'].modelnumberid) + assert _printer_driver(printer, _universal()).name == 'HP Universal Print Driver' + + +def test_a_model_specific_driver_beats_the_universal_one(fleet): + """A plotter is not a LaserJet. If the universal driver won here, the + DesignJet would be installed with a driver that cannot drive it.""" + printer = Printer(vendorid=fleet['hp'].vendorid, modelnumberid=fleet['designjet'].modelnumberid) + assert _printer_driver(printer, _universal()).name == 'HP DesignJet T1700' + + +def test_vendors_do_not_bleed_into_each_other(fleet): + """A Xerox must never resolve to the HP driver, whatever the ordering.""" + printer = Printer(vendorid=fleet['xerox'].vendorid, modelnumberid=None) + assert _printer_driver(printer, _universal()).name == 'Xerox Global Print Driver PCL6' + + +def test_a_printer_with_no_vendor_resolves_to_nothing(fleet): + """Better nothing than a guess: installing the wrong driver is worse than + reporting that a printer has none.""" + printer = Printer(vendorid=None, modelnumberid=None) + assert _printer_driver(printer, _universal()) is None + + +def test_a_make_with_no_driver_row_resolves_to_nothing(db, fleet): + """A vendor nobody has added a driver for is unresolved, not misresolved.""" + zebra = Vendor(vendor='Zebra') + db.session.add(zebra) + db.session.commit() + printer = Printer(vendorid=zebra.vendorid, modelnumberid=None) + assert _printer_driver(printer, _universal()) is None + + +def test_the_pre_vendorid_naming_convention_still_resolves(db, fleet): + """A site that populated printerdrivers before the column existed matched on + the vendor word in the driver's NAME. That must keep working, or an upgrade + silently takes every driver away.""" + epson = Vendor(vendor='Epson') + db.session.add(epson) + db.session.flush() + db.session.add(PrinterDriver(name='Epson ColorWorks universal', drivername='EPSON TM-C3500', + location=r'\\server\share\epson', isactive=True)) + db.session.commit() + # vendor attached, not just vendorid: the legacy path reads the vendor's + # NAME, which only exists through the relationship. + printer = Printer(vendorid=epson.vendorid, modelnumberid=None) + printer.vendor = epson + assert _printer_driver(printer, _universal()).name == 'Epson ColorWorks universal' + + +def test_a_row_that_names_a_vendor_is_not_matched_by_its_text(db, fleet): + """A driver whose vendorid is set and does NOT match must not then be picked + up by the name convention: a mis-set vendor would resolve to the wrong + package, which is worse than resolving to none.""" + brother = Vendor(vendor='Brother') + db.session.add(brother) + db.session.flush() + # Named for Brother, but bound to HP by id - the id is the truth. + db.session.add(PrinterDriver(name='Brother universal', drivername='Brother Universal', + location=r'\\server\share\brother', + vendorid=fleet['hp'].vendorid, isactive=True)) + db.session.commit() + printer = Printer(vendorid=brother.vendorid, modelnumberid=None) + printer.vendor = brother + assert _printer_driver(printer, _universal()) is None