11 Commits

Author SHA1 Message Date
cproudlock
3f7cc37b00 Spread a driver rollout across waves so the fleet cannot stampede its own share
Some checks failed
CI / backend (push) Failing after 7m16s
CI / naming (push) Failing after 7m10s
CI / frontend (push) Failing after 7m10s
CI / migrations-mysql (push) Failing after 7m14s
GE-Enforce gives each PC a start offset of SHA256(hostname) % 5 MINUTES and then
repeats every five minutes. That was sized for reading a few KB of manifest
JSON. A driver set is 100 MB for the two universals and 226 MB for a full site,
so the day a driver entry lands, every bay pulls it inside one five-minute
window: roughly 30 GB across 300 bays, at something like 800 Mbps, on the same
share the whole floor needs for everything else. The failure mode is not slow
printers, it is a floor that stops converging.

-WaveStart with -Waves spreads that out. Each PC derives its own wave from its
hostname, so there is no central coordination, no per-bay configuration, and no
list of who has had it yet. The hash is the same idiom Register-GEEnforce.ps1
already uses for its start offset, SHA-256 rather than MD5 because FIPS-enforced
bays disable MD5 outright and would throw.

Measured over 300 hostnames at 10 waves: 23 to 44 bays per wave against a mean of
30, so the peak wave moves about 4.3 GB rather than the 3 GB an average implies.
Hash bucketing is uneven and the peak is what sizes a link, so do not quote the
mean.

THE GATE RUNS BEFORE THE MANIFEST IS READ, because the manifest is on the share
too. A bay that is not due must not touch the share at all - one read is cheap,
300 bays deciding to read in the same five minutes is the entire problem.

It FAILS CLOSED on an unparseable date. Failing open would restore exactly the
stampede this exists to prevent, and 30 GB cannot be un-sent, whereas a typo that
installs nothing says so in the log every cycle and is fixed in a minute.

A bay powered off during its wave installs on its next cycle instead. The wave is
an earliest-time, not a deadline, so nothing needs chasing afterwards.

-TestOnly reports a bay whose wave has not opened as COMPLIANT, because not
installed is genuinely its desired state today; DSC would otherwise call
SetScript every pass to be told to wait. -IgnoreWave is for proving a pilot bay
before opening anything.

Verified on Windows 11 build 26200, six paths: not-due installs nothing and exits
0; TestOnly while not due exits 0; a garbage date exits 1 having installed
nothing; -IgnoreWave installs against a future start; an opened wave installs;
and no wave arguments at all installs, which is what imaging needs.
2026-08-19 17:16:58 -04:00
cproudlock
2d09fa3201 Collect what bays actually have, separately from what they are told to have
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m22s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s
ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the
observed half makes a rollout a review instead of a typing exercise: the floor
reports itself in, you look, and you adopt.

The collection uses the mechanism that already exists rather than a new one.
POST /api/collector/printers dispatches to the printers plugin's
apply_collector_payload, the same ADR-006 hook the computers and backups plugins
implement. New client script, new plugin-owned table, no new transport and no new
credential.

OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A
collector report can never write an assignment row: _reconcile_edges is the only
function that writes usesprinter/defaultprinter, it has two call sites, and both
are authenticated routes a human calls. If a drifted bay's own state were allowed
to become what it is told to install, every configuration error would become
permanent the next time that PC checked in.

Seeding an assignment from observed state is explicit -
POST /assignments/seed-from-observed - because a rollout adopts many machines at
once. It routes through the same _reconcile_edges as the editor, so there is one
write path with two doors, and a queue matching no known printer is REFUSED
rather than guessed into an assignment. That last rule is the lesson from the
measuring tools: adopting on a weak key produced 43 duplicate instruments.

Two fixes on top of what the agents built. The replace deleted a host's previous
rows by exact case-folded name while the read path treats a short name and its
FQDN as one machine, so a PC that changed spelling appeared to hold every queue
twice - which reads as drift that is not there. And the client sent 'reportedat'
where the declared schema said 'observedat'.

Also here: the legacy loader now imports machines.printerid, the classic system's
record of each machine's default printer, which it silently dropped - the
production import would have lost every one. And Set-ShopdbPrinters.ps1 finally
registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to
C:\ProgramData first because the share it lives on is mounted only during the
enforcement cycle and the task runs at logon when it is gone.

VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these
scripts happily and executes none of the spooler branches.

The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl
and CollectorKey from HKLM when given no arguments; suppresses the virtual queues
by port; resolves port addresses; and reads the CONSOLE USER's default out of
HKU rather than SYSTEM's own, which is a different and usually wrong answer.

Two results matter more than the rest. With the spooler stopped, both the cmdlet
and the CIM path fail and the script posts NOTHING - verified against a capture
server that recorded zero requests, where an empty list would instead have
erased that host's observed rows and read as a bay that lost its printers. A
genuinely empty host still posts [], because that is a real and different fact.

The logon task registers as the Users group at Limited, and falls back to the
well-known SID S-1-5-32-545 when the group name will not resolve, as it will not
on localised Windows. It was then run with the source directory RENAMED AWAY, to
stand in for the share being unmounted, and it still moved the user's default -
which is the whole reason the script is staged to C:\ProgramData rather than run
from where it lives.

The guarantees against damage were re-checked rather than assumed: an empty
assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly
leaves no queue, no task, no staged file and no registry value behind, and a
drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their
default keeps it.

Not covered by any of this: the driver-staging path, which needs a real vendor
package rather than the class drivers a VM ships with.
2026-08-19 15:32:18 -04:00
cproudlock
1a5a1cd43d Correct a drifted print queue instead of leaving it wrong
A queue was matched by NAME alone, so a bay whose printer had moved, or whose
queue was built on a driver the site has since replaced, looked converged and
printed to the wrong device. Absence was fixed; drift was not.

Set-ShopdbPrinters.ps1 now repoints a queue whose port does not match the
address ShopDB holds, and swaps a queue built on the wrong driver. Both are
things ShopDB is authoritative about: where the printer IS, and what drives it.

CORRECTED IN PLACE with Set-Printer, never removed and recreated. The queue keeps
its name, its sharing, its permissions, and whoever has it as their default keeps
it - which is what makes this safe to run every cycle on a live floor. There is
still no removal code path in this script at all.

Two guards, because a repair that breaks a working printer is worse than drift:
the driver is only swapped when the wanted one is actually staged, and
-WhatIfOnly reports both kinds of correction without making either.

Verified on Windows against a queue that had the right name, the wrong port, the
wrong driver AND was the logged-on user's default: both fields were corrected and
the queue was still the default afterwards. The earlier no-op guarantees were
re-run and still hold - nothing assigned changes nothing, and an assignment with
no default leaves the user's own default alone.
2026-08-19 13:07:51 -04:00
cproudlock
e2c45d33bc One printer picker for machines and PCs, and one default per asset
The assignment belongs to the MACHINE, and until now there was no way to set it
except the generic relationships card or the API - the form for the thing the
feature is about did not exist. MachineForm now carries the picker, and PCForm
uses the SAME component rather than its own copy: the PC's set overrides the
machine's, and two implementations of that would drift, with the two ends of an
override disagreeing being exactly the bug nobody would spot.

The shared picker also fixes what PCForm did on save. It wrote row at a time
through the generic relationship endpoints, which is a non-atomic reconcile: an
HTTP failure part way left a PC half-assigned with nothing recording what was
meant. It now calls the reconcile endpoint, which validates the default before
writing anything.

A relationship type can now say it allows one active row per asset
(relationshiptypes.issingular, migration 7d34), and defaultprinter says it.
Cardinality belongs to the type rather than the printers plugin: core's create
path is where every hand-made link passes, and the next type meaning "exactly
one" gets the rule for free. Setting a second default REPLACES the first instead
of refusing, because "make this the default" means that - and a card answering
409 would leave the user hunting for the old row.

Without it the schema was happy to hold two defaults: the unique constraint is
(source, target, type), so two different targets are two valid rows, and the
resolver takes the OLDEST - the new default silently lost. Proven by disabling
the new rule and watching the tests fail.

FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read
.data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages
has already unwrapped every page. The whole parallel load threw into the catch,
so every dropdown on the machine edit form came up empty and the machine's own
values never loaded. A build cannot see this; only opening the page can.

GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment,
without inheritance, because the editor must show what this asset's rows say -
otherwise a machine's printers appear ticked on the PC that inherits them and
unticking one silently creates an override.
2026-08-19 11:22:48 -04:00
cproudlock
72b3904f71 Release 0.11.3
Some checks failed
CI / backend (push) Failing after 7m14s
CI / naming (push) Failing after 7m18s
CI / frontend (push) Failing after 7m9s
CI / migrations-mysql (push) Failing after 7m18s
The last of the buildings-and-levels bugs, and the shop-floor clients brought
into the product.

Anyone on 0.11.0 through 0.11.2 should take this. Every hover mini-map in the
product reported "this asset has a position but no level" - the levelid prop
added in 0.11.0 was passed by none of its seven call sites - and the map PDF
printed markers from every floor onto one sheet, which nobody can correct once it
is carried onto the floor. The legacy import loader, still to run against
production, created markers with no level at all.

The gate that should have caught all three asked whether a FILE mentions levelid
rather than whether each position does. It now checks per occurrence.

Also: printers can be assigned to a MACHINE and reach whichever PC controls it,
so a reimaged bay reinstalls its own printers with nothing saved off the old PC;
printerdrivers can name a vendor, so two rows cover 41 of 44 printers instead of
twenty-one near-duplicates; and the collector reporter and EventSaver now live in
the repository with no site baked into either, the reporter generated per site on
request.

The client scripts were validated on Windows 11 against a live ShopDB, not only
by the suite: a bay with no rows of its own created both queues from its
machine's assignment, bound them to the right universal drivers, and set the
per-user default.

The version and the changelog are the release; the detail is in the entry.
2026-08-19 10:56:52 -04:00
cproudlock
8cedf674fb Resolve a driver by vendor, and converge a bay's printers from ShopDB
Some checks failed
CI / naming (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / backend (push) Has been cancelled
Two rows now cover 41 of 44 printers. printerdrivers could only bind a driver to
ONE modelnumberid, so the HP and Xerox universal drivers - which between them
cover almost the whole floor - would have needed 21 near-duplicate rows pointing
at the same package. That is a table nobody keeps true, and it is why 42 of 44
printers resolved no driver at all.

printerdrivers gains vendorid, and resolution runs most-specific-first: the
printer's model, then its vendor, then the pre-vendorid convention of matching
the vendor word in the driver's name so a site that populated the table before
the column existed does not silently lose every driver on upgrade. A row that
names a vendor is never matched by its text, because a mis-set vendor resolving
to the wrong package is worse than resolving to none.

Six rows now resolve 44 of 44 printers at the reference site, and the DesignJet
correctly takes its own driver over the HP universal one.

Set-ShopdbPrinters.ps1 is the client half: ask for-host, create the queues that
are missing, record the desired default. It NEVER removes a queue - a bad minute
from the API must not take printers away from a working bay - and it never
fetches a driver, because downloading 48 MB while somebody waits to print is the
wrong moment. The common scope stages those.

Apply-ShopdbDefaultPrinter.ps1 applies the default in the USER's context, which
is the only context that can: SYSTEM cannot set a per-user default for somebody
else. It also turns off "Let Windows manage my default printer", without which
Windows silently overwrites the choice the next time anyone prints elsewhere -
a fix that undoes itself within a day.

VALIDATED ON WINDOWS 11 AGAINST A LIVE SHOPDB, not only by tests. Printers were
assigned to a MACHINE; a PC controlling it, holding no rows of its own, created
both queues bound to the right universal drivers, recorded the default and set
it, and a second run changed nothing. The first attempt failed with
"Relationship types are not seeded - run: flask seed reference-data", which is
the deployment trap the plan predicted, caught by an explicit error rather than
silently resolving nothing.
2026-08-19 10:24:53 -04:00
cproudlock
0dc0ac13c8 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.
2026-08-19 09:33:22 -04:00
cproudlock
03d0754fdc Stage printer drivers as a deployable set, for the common scope
Assigning a printer to a bay is useless if the bay cannot install it, and the
fleet data says why that mattered: 42 of 44 printers could not resolve a driver.
This is the delivery half - the drivers themselves, staged once per bay, so that
creating a queue never waits on a download.

Install-ShopdbPrinterDriver.ps1 does one driver: trust the package's signer, then
pnputil /add-driver, then Add-PrinterDriver. Install-ShopdbPrinterDrivers.ps1
does a site's whole set from drivers.json, and answers a compliance question with
-TestOnly, which is what makes it a clean DSC Script resource rather than a
fire-and-forget install.

Deliberately SEPARATE from assignment. Drivers are large, near-identical across a
fleet and change rarely; assignments are small, per-bay and change often. Staging
the set in the GE-Enforce common scope means the assignment client only ever
creates a queue - it never fetches a 48 MB package while somebody is waiting to
print, or discovers the share is unmounted at the worst moment.

THE SIGNER TRUST STEP IS THE WHOLE TRICK, and it took a real driver to find it.
certutil -addstore on the .cat file satisfied the Xerox package and failed every
HP INF with "The publisher of an Authenticode(tm) signed catalog has not yet been
established as trusted" - a coin toss, not a mechanism. The certificate is now
extracted with Get-AuthenticodeSignature and added to Trusted Publishers, for
every catalog under the package rather than the first INF's neighbours. On a
locked bay there is no prompt to answer, so the old failure was silent.

Verified on Windows against real packages, not by reading: all six drivers this
site needs install through the script, a second run is a no-op, a wrong driver
name fails with the names the package actually offers, and the DSC cycle behaves
- TestOnly exits 1 on a clean box, install exits 0, TestOnly then exits 0.

The packages themselves stay out of git: they are licensed vendor binaries, and
they belong on the share beside the other imaging payloads.

DEPLOYING-DRIVERS.md carries the GE-Enforce entry, the DSC configuration and the
Intune shape, plus the constraint that has cost a session before: the SFLD share
is mounted only during the enforcement cycle, so this runs as a manifest entry
and never as its own scheduled task.
2026-08-19 09:33:05 -04:00
cproudlock
2083029ff2 Generate the collector script per site, and bring EventSaver into the repo
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
A site adopting ShopDB had to be handed two files and told what to edit in them.
Both are now the product's, and one of them the server writes for you.

GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with
this site's values already in it: site_base_url becomes the -ApiUrl default and
the new computers_routableranges setting becomes -AllowedRanges. Only the
PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays
runnable, so there is no second version to drift from the first - and everything
stamped stays overridable by argument or registry, because a bay may need to
differ from its site. Settings > Computers > Asset reporter edits the ranges,
downloads the script and shows its SHA-256.

The collector key is deliberately not stamped in, and a test fails if it ever
is. That file lands on every shop-floor PC, and a token spread across hundreds
of bays cannot be rotated quietly; it stays in the registry, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.

The routable ranges are the last thing that was hardcoded in that script. They
are now a setting, so West Jefferson's two CIDRs move out of source code and
into that site's own configuration - which is what ADR-015 asks for - and a site
that sets nothing still works, because the script falls back to the NIC carrying
the default route.

EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and
EventSaver.ini, no compiled .scr - a binary is a release asset, like the
installer exe. The share path that was compiled into Config.Folder is gone. It
used to be the fallback when the ini was missing, which silently pointed a new
site at the reference site's file server; it is now empty, and failing visibly
beats displaying another site's slides. Verified by compiling the edited source
in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0.

Also: the DSC example in the adoption guide gains a CollectorRanges resource and
stops passing -ApiUrl to a script that already reads BaseUrl from the registry
the same example writes, and the guide points at the generated download instead
of hand-editing a URL.

The contract test caught the endpoint importing shopdb directly for the version
string, which ADR-002 forbids a plugin from doing. The product and contract
versions are in app.config now, which a plugin reads through current_app.

Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and
let the bay install them, with what the fleet data says about drivers - HP and
Xerox cover 41 of 44 printers with universal drivers, there are no Brother
printers at all despite 208 files of Brother inkjet drivers in the installer,
and printerdrivers holds one row pointing at a per-model folder instead of a
universal driver.
2026-08-18 15:51:14 -04:00
cproudlock
96f127f8c8 Bring the collector script into the repo, with no site in it
Report-AssetToShopDB.ps1 lived on one site's imaging share and was, per the
adoption guide, "provided on request" - which is not a distribution mechanism for
a product meant to be adopted. It now lives in plugins/computers/client/, beside
the collector contract it implements (ADR-006), so the two version together.

Three things named West Jefferson and no longer do (ADR-015):

- The server. It resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl - the value
  Install-GEEnforce.ps1 already writes and the enforcement client cannot run
  without - or from -ApiUrl. With neither it logs and exits 0 rather than
  posting somewhere wrong. Any bay running this script runs the enforcement
  client, so the value is present wherever it is deployed.
- The corporate VLANs. Two hardcoded CIDRs decided which NIC's address was
  reported, with a comment reading "update if site re-VLANs". A site may now
  name its ranges (-AllowedRanges, or a CollectorRanges registry value); with
  none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
  "the routable NIC, not the controller NIC" without knowing any site's
  addressing.

VERIFIED IN THE WINDOWS VM, not by reading it - and the VM earned its keep. The
local array was called $allowedRanges, which is the SAME VARIABLE as the [string]
parameter $AllowedRanges because PowerShell names are case-insensitive; the array
was silently coerced to an empty string, and .Count on a scalar string is 1. The
script therefore believed a range was configured, skipped the default-route
fallback, and reported no IP at all. Linux pwsh parsed it happily. Renamed to
$rangeList, and the four paths were then confirmed on Windows: no config skips
cleanly, BaseUrl resolves from the registry, an unconfigured site picks the
default-route NIC, a configured range selects or excludes as asked.
2026-08-18 09:59:51 -04:00
cproudlock
f34b9ca710 Carry the level everywhere a position is drawn, and gate it per occurrence
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 11s
CI / migrations-mysql (push) Failing after 7s
The hover mini-map said "This asset has a position (2835, 1410) but no level"
for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid
prop, NONE of its seven call sites were taught to pass one - printer, machine and
PC detail pages, the toner report, enforcement reports, the warranty chip and the
dashboard cards - so the component correctly reported a missing level and the
preview never drew. Two payloads behind those views also emitted mapx/mapy with
no level: the toner report and the enforcement report.

The map PDF export had the ORIGINAL bug still in it: it plotted every filtered
asset onto the sheet, so exporting the ground floor printed second-floor markers
on it. Worse than on screen, because nobody can correct a sheet once it has been
printed and carried onto the floor. It now exports only the level being viewed.

The legacy import loader sent mapleft/maptop with no level at three call sites.
That loader is the one still to run against production, and every marker it
created would have been undrawable. It now resolves the site's default level -
the legacy schema predates levels and has one floor plan, so that is what its
coordinates mean.

THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid',
not whether each position does: one module emitted 'mapx' six times and 'levelid'
once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/
and plugins/, and fails any Vue file that binds tooltip coordinates without
:levelid. Both new rules were confirmed to fail the build against planted
violations before being relied on.

Printer QR labels: the asset number is no longer printed. A label now reads name
(8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber
because that is where sites actually keep it - every printer here has an empty
name field, so preferring the Windows queue name alone would have printed a blank
line on every label.
2026-08-18 09:36:45 -04:00
62 changed files with 7773 additions and 465 deletions

View File

@@ -10,6 +10,70 @@ ADR-007 and ADR-002.
## [Unreleased]
## [0.11.3] - 2026-08-19
Fixes the last of the buildings-and-levels bugs, and brings the shop-floor
clients into the product: the collector reporter, EventSaver and a printer
assignment feature that lets a bay install its own printers.
**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this
asset has a position but no level" for every asset, and the map PDF printed
markers from every floor onto one sheet.
### Fixed
- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a
levelid, none of its seven call sites were taught to pass one - printer,
machine and PC detail, the toner report, enforcement reports, the warranty chip
and the dashboard cards. Two payloads behind them also emitted coordinates with
no level.
- **Map PDF export printed other floors' markers.** It plotted every filtered
asset onto one sheet, which is worse than the on-screen version was: nobody can
correct a sheet once it is printed and carried onto the floor.
- **The legacy import loader created undrawable markers**, sending mapleft/maptop
with no level at three call sites. That loader is the one still to run against
production.
- The build gate that should have caught all of this asked whether a FILE
mentions `levelid`, not whether each position does - one module emitted `mapx`
six times and `levelid` once and passed. It now checks per occurrence, covers
`scripts/`, and fails any Vue file that binds tooltip coordinates without a
level.
### Added
- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC
controls it, so a reimaged or swapped PC comes back with the bay's printers and
nothing had to be saved off the old one. New `usesprinter` relationship type,
propagating through `controls` at read time; `GET /api/printers/for-host/<host>`
for the client; `PUT /api/printers/assignments/for-asset/<id>` to reconcile a
whole set in one call.
- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal
drivers cover 41 of the reference site's 44 printers; binding a driver to a
single model meant 21 near-duplicate rows. Resolution is model, then vendor,
then the pre-vendorid name convention.
- **Client scripts** in `plugins/printers/client/`: stage a site's driver set
(`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a
bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default
(`Apply-ShopdbDefaultPrinter.ps1`).
- **The collector reporter is in the repository** at
`plugins/computers/client/`, with no site in it, and the server generates a
copy carrying this site's URL and ranges: Settings > Computers > Asset
reporter, or `GET /api/computers/client-script`. The collector key is
deliberately never stamped into it.
- **EventSaver source** at `plugins/slides/client/`. The site UNC that was
compiled into it is gone; a missing ini now fails visibly rather than pointing
a new site at the reference site's file server.
- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the
buildings-and-levels admin gains editable width AND height.
### Changed
- The map editor accepts a click. Its handler was bound only if the map was
already a picker when it mounted, and the editor opens with nothing selected,
so placing a marker by hand was impossible for the life of the page.
- Global search orders every query before truncating, so the same search cannot
return a different subset twice.
## [0.11.2] - 2026-08-17
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.

View File

@@ -22,12 +22,12 @@ at all yet.
| Artifact | Where it is today |
|---|---|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
| `Report-AssetToShopDB.ps1` | **Not in this repository.** It lives on the reference site's imaging share and is provided on request. It is planned to move to `plugins/computers/client/` so it versions with the collector contract it implements. |
| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). |
| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
Ask the maintainers for the two that are not here. A site that would rather not
run a binary it cannot rebuild should take EventSaver's source and compile it
locally - the build needs no SDK and is one command.
A site that would rather not run a binary it cannot rebuild should take
EventSaver's source and compile it locally - the build needs no SDK and is one
command.
---
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
| Input | Where it comes from |
|---|---|
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` |
| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
generates this script with your server's URL and ranges already in the parameter
defaults, and shows its SHA-256. The key is deliberately not included.
### Minting the key on your ShopDB
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
Ensure = 'Present'
}
# Optional. Only for a site whose bays carry both a controller NIC and a
# corporate one AND whose default route is not the corporate NIC. Leave
# this resource out otherwise - the script picks the default-route NIC.
Registry CollectorRanges
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'CollectorRanges'
ValueData = '10.20.0.0/23,10.21.4.0/26'
ValueType = 'String'
Ensure = 'Present'
}
Script ReportingTask
{
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
SetScript = {
# No -ApiUrl: BaseUrl above is where the script reads it from,
# and one source beats two that can disagree.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"'
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force

View File

@@ -8,6 +8,8 @@ a real caller (the GE-Enforce fleet agent) to it.
- Server code: `shopdb/core/api/collector.py`
- Computers schema + upsert: `plugins/computers/plugin.py`
(`get_collector_schema` / `apply_collector_payload`)
- Printers schema + replace (observed print queues): `plugins/printers/plugin.py`
(same two hooks)
- Contract rationale: `docs/adr/ADR-006-collector-contract.md`
---
@@ -321,6 +323,137 @@ classic reporter posts a full `networkInterfaces` array; the collector accepts
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
the PowerShell below).
### Printers plugin field mapping (observed print queues)
`POST /api/collector/printers`. The other half of the printer story. ShopDB has
always known what a bay SHOULD have - `GET /api/printers/for-host/<hostname>`,
applied by `Set-ShopdbPrinters.ps1` - and has never known what it actually has.
This payload is that missing half. Same dispatcher, same auth, same audit row as
the computers collector; only the payload and the plugin differ.
- Schema + apply: `get_collector_schema` / `apply_collector_payload` in
`plugins/printers/plugin.py`.
- Client: `plugins/printers/client/Report-PrintersToShopDB.ps1` (SYSTEM context,
`Type=PS1` / DetectionMethod `Always` manifest entry, logs to
`C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`, always exits 0).
- Storage: `printerobservedqueues`, one row per observed queue, owned by the
printers plugin. It is a separate table from the assignment on purpose - see
"Observed is not assigned" below.
The identity field is `hostname`. Unlike the computers payload this one is NOT
patch-style: `queues` is required, and the reported set replaces everything
previously recorded for that host.
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|---|---|---|
| `hostname` (required) | string | Identity of the report. `COMPUTERNAME` or its FQDN; matched case-insensitively. Rows are keyed by the NAME, so a bay that reports before its PC record exists still records everything - the report resolves to an asset the moment that record appears. An unknown hostname is a warning, never an error. |
| `queues` (required) | array of objects | Every real print queue on the host. This REPLACES the host's previous set. An empty array is a valid report meaning "this bay has no queues" and clears the host's rows. A missing key is rejected (400), because one client bug that dropped the field would otherwise erase the fleet's observed state host by host. |
| `queues[].queuename` (required) | string | Windows printer name. A queue with no name is skipped with a warning; a repeated name within one report is dropped with a warning (Windows cannot hold two queues of one name). |
| `queues[].drivername` | string | Driver name verbatim, as the INF spells it. Compared against the assignment's driver to detect drift. |
| `queues[].portname` | string | Windows port name. Also tried as a match key, since a port created outside the client is usually named after the host address. |
| `queues[].portaddress` | string | `PrinterHostAddress` of a TCP/IP port - an IP or FQDN. The PRIMARY key for matching a queue to a printer asset. Omit it for a non-TCP port (USB, WSD, redirected); such a queue is still worth reporting and matches on name alone. |
| `queues[].isdefault` | boolean | True on the one queue that is the user's default. If several arrive true, the first is kept and the rest are cleared with a warning - a bay has exactly one default, and keeping both would leave a seed picking one at random. |
| `queues[].isshared` | boolean | True when the queue is shared off this PC. Recorded, not acted on. |
| `observedat` | ISO-8601 datetime | Accepted and ignored, as is any other client timestamp. The server stamps `observedat` at ingest, one value for the whole report, so a bay with a wrong clock cannot report itself fresh or stale. |
Response `data` is the standard collector shape: `action` is always `updated`
(this endpoint replaces rows and creates no asset - calling an identical
re-report `noop` would hide that the bay is still checking in), `assetid` is the
resolved PC or `null`, `extra.queuecount` is how many rows were stored, and
`warnings` carries the soft problems above.
Schema source of truth: `get_collector_schema` in `plugins/printers/plugin.py`.
If you change the payload, change it there and re-check this table.
```
POST /api/collector/printers
X-API-Key: SECRET
Content-Type: application/json
{
"hostname": "WORKSTATION01",
"queues": [
{
"queuename": "Bay Label Printer",
"drivername": "Generic / Text Only",
"portname": "IP_192.0.2.40",
"portaddress": "192.0.2.40",
"isdefault": true
},
{
"queuename": "Office Laser",
"drivername": "HP Universal Printing PCL 6",
"portname": "IP_192.0.2.41",
"portaddress": "192.0.2.41",
"isdefault": false
}
]
}
```
#### The latest report replaces the previous one
This is CURRENT STATE, not history. Each report deletes every row previously
recorded for that hostname and writes the reported set in its place, inside the
dispatcher's transaction. Nothing accumulates, "what does this bay have" is
never a question about time, and a queue removed from a bay disappears from
ShopDB on the next cycle without anyone tidying up.
That has one sharp edge, and it belongs to the client: an empty `queues` array
is a legitimate report that wipes the host's rows. A client whose enumeration
FAILED must therefore send nothing at all rather than an empty list. Reporting
nothing loses one cycle; reporting `[]` after a WMI hiccup deletes real state
and reads as a bay that lost its printers. `Report-PrintersToShopDB.ps1` tracks
this with an `$enumerated` flag and exits without posting when both the cmdlet
and the WMI fallback failed.
#### Observed is not assigned
Stated plainly, because it is the whole point of keeping two tables:
**A collector report NEVER becomes an assignment.** Nothing on this path writes
a `usesprinter` or `defaultprinter` row. The moment a drifted bay's observed
state is treated as correct, enforcement stops meaning anything - a bay that
installed the wrong printer would make itself right simply by reporting it.
Observed state becomes assigned state only when a person asks for it, through
`POST /api/printers/assignments/seed-from-observed/<assetid>` (requires
`printers.edit`), after reviewing the comparison. That route refuses rather than
guesses: it will not seed from an ambiguous host, will not seed queues that
match no printer unless told to, and will not write an empty set.
#### Matching, and what "unknown" means
Nothing is matched at ingest - the raw observed strings are stored as reported,
and resolution to a printer asset happens at READ time, so a printer added to
ShopDB tomorrow matches yesterday's report without the bay reporting again.
At read time each queue is matched by PORT ADDRESS first (an IP or FQDN names
one device unambiguously), then by queue name. There is no fuzzier fallback: a
queue that matches nothing is reported as `unknown` rather than guessed, because
a wrong match seeds a wrong assignment, which is worse than no assignment.
#### Reading it back
| Endpoint | Purpose |
|---|---|
| `GET /api/printers/observed/<hostname>` | What the host last reported, every queue classified against the assignment it resolves to (`matching`, `drifted`, `extra`, `missing`, `unknown`), with `observedat`, a summary, and a `seedcandidate` preview. Read-only. Needs `printers.view`. |
| `POST /api/printers/assignments/seed-from-observed/<assetid>` | The one path from observed to assigned, human-triggered. Needs `printers.edit`. Normally posted against the MACHINE, so the assignment survives a reimage. |
#### Key delivery for the printers reporter
The reporter reads its server and its key from the registry the enforcement
client already provisions (`HKLM:\SOFTWARE\GE\ShopDB`, values `BaseUrl` and
`CollectorKey`), or from `-ApiUrl` / `-ApiKey` in the manifest entry's `Args`.
Nothing is baked into the script or the manifest JSON on the share - the same
rule as the computers reporter, for the same reason.
Server side, the key resolves per-plugin first: set `COLLECTOR_API_KEY_PRINTERS`
if you scope keys per collector, or rely on the shared `COLLECTOR_API_KEY`. A
site that has scoped the computers key per-plugin and set no printers key gets
401 on this endpoint until one of the two is in place. A `collector.ingest`
managed token works here exactly as it does for computers.
---
## 2. Breaking change: querystring `api_key` removed
@@ -703,8 +836,13 @@ fleet's key is scoped to the computers collector only.
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
| `No collector registered for plugin printers` | 404 | The printers plugin is disabled or not loaded on that instance. Enable it. A lean site without it simply does not collect observed queues. |
| `queues is required; send an empty array for a host with no queues` | 400 | The printers payload omitted `queues` entirely. Absent and empty are not the same thing here - see "The latest report replaces the previous one". |
| A bay's observed queues all vanished | 200 | It reported `queues: []`, which legitimately clears the host. Check the client log for `posting NOTHING` (a failed enumeration correctly sends nothing) versus `reporting an empty set`. If the bay really has queues, the enumeration on that host is the thing to fix. |
| Observed queues show as `unknown` | 200 | The queue matched no printer asset by port address or by name. Add the printer to ShopDB (with its IP) and re-read - matching happens at read time, so the bay does not need to report again. Never seeded automatically, by design. |
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`.
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`;
for the printer-queue reporter: `C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`.
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
schema failures there).

View File

@@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor.
**Folder mode** predates the server and stays as a fallback for a site with no
ShopDB instance yet, or for content nobody wants in the database.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the
> binary falls back to a path compiled into `EventSaver.cs` - and that path
> belongs to the site it was first built for. Ship the ini. A missing ini is not
> a neutral default.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
> no source to read and the screensaver shows nothing. That is deliberate: the
> compiled-in fallback used to be the path of the site it was first built for, so
> a missing ini silently pointed a new site at someone else's file server. It is
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
> the ini.
## What decides the running order
@@ -98,8 +100,9 @@ covering something someone needed to see.
## Building it
No SDK required - it compiles with the in-box .NET Framework compiler on any
Windows 10 or 11 machine:
The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
compiles with the in-box .NET Framework compiler on any Windows 10 or 11
machine:
```
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^

View File

@@ -11,7 +11,7 @@ never by editing this file.
| series | value | governed by |
|---|---|---|
| product `__version__` | `0.11.2` | ADR-007 |
| product `__version__` | `0.11.3` | ADR-007 |
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 |
They move independently. A contract bump is not a release.
@@ -23,7 +23,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| chain | head |
|---|---|
| core | `7d33_buildings_and_levels` |
| core | `7d34_singular_relationship_types` |
| backups | `backups0003clearlastseen` |
| computers | `computers0001anchor` |
| employees | `employees0002photo` |
@@ -34,7 +34,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| network | `network0003prefix` |
| notifications | `notifications0005boardorder` |
| printedparts | `printedparts0004txnrev` |
| printers | `printers0002supplyalerts` |
| printers | `printers0005observedqueues` |
| slides | `slides0001anchor` |
| usb | `usb0002dropmachineid` |
| warranty | `warranty0002proof` |
@@ -85,6 +85,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size
- test functions defined: **1087** (parametrised cases collect higher)
- documented API paths: **276** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
- test functions defined: **1140** (parametrised cases collect higher)
- documented API paths: **282** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -1697,6 +1697,14 @@
"params": "protocol_id in path; body: any of protocolname, port, isactive",
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
},
{
"method": "GET",
"path": "/api/computers/client-script",
"auth": "jwt + role:admin",
"params": "none",
"purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script"
}
],
"surface": "plugin-computers"
@@ -2880,6 +2888,46 @@
"purpose": "Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp); used by installer to preselect map hotspot.",
"example": "curl 'http://localhost:5001/api/printers/pc-default?machine=0421&format=text'"
},
{
"method": "GET",
"path": "/api/printers/for-host/<hostname>",
"auth": "jwt-optional",
"params": "path: hostname (matched case-insensitively, as sent by the client's COMPUTERNAME); no query parameters",
"purpose": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.",
"example": "curl http://localhost:5001/api/printers/for-host/workstation01"
},
{
"method": "GET",
"path": "/api/printers/observed/<hostname>",
"auth": "permission:printers.view",
"params": "path: hostname (case-insensitive, short name or FQDN, as the reporting client sent it); no query parameters",
"purpose": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST /api/collector/printers), each queue judged against the resolved assignment it SHOULD have. Read-only - nothing here changes an assignment, however wrong the bay looks. Every queue is classified matching / drifted (same printer, different port address or driver) / extra (a printer ShopDB knows, installed unassigned) / missing (assigned, not reported) / unknown (matches no printer, never guessed), each entry carrying both sides plus driftfields; the response adds observedat, the assignment source (own rows or the machine this PC controls), a per-classification summary, and a seedcandidate preview of what a seed would write. 404 only when the hostname has neither a report nor a computer record; a known PC that never reported returns an empty queue list.",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/workstation01"
},
{
"method": "GET",
"path": "/api/printers/observed/for-asset/<asset_id>",
"auth": "permission:printers.view",
"params": "path: asset_id (a machine or a PC); no query parameters",
"purpose": "The same observed-against-assigned comparison as /api/printers/observed/<hostname>, reached from an asset page, with one block per reporting host. Assigned state lives on the MACHINE while observed state is reported by the PCs, so a machine answers with a block for each active PC that controls it (its own hostname first when the asset is itself a PC); blocks rather than one merged list because two PCs legitimately share one machine and the actionable part of drift is which box to walk to. Each block carries the same fields as the by-hostname route: queues classified matching / drifted / extra / missing / unknown, observedat, the assignment source, a summary, and a seedcandidate preview. Read-only. An asset nothing has reported for is an empty hosts list and a 200, not a 404.",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/for-asset/312"
},
{
"method": "PUT",
"path": "/api/printers/assignments/for-asset/<asset_id>",
"auth": "permission:printers.edit",
"params": "path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)",
"purpose": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.",
"example": "curl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312"
},
{
"method": "POST",
"path": "/api/printers/assignments/seed-from-observed/<asset_id>",
"auth": "permission:printers.edit",
"params": "path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those.",
"purpose": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background job reaches this route, so a bay that installed the wrong printer can never make itself right by reporting it. Writes the matched observed queues through the same reconcile as PUT /printers/assignments/for-asset (usesprinter rows plus the single defaultprinter, set only when the observed default itself matched a printer). Returns what was written, the skipped queues, and warnings - including one when the target controls another asset, because its own rows then shadow that machine's assignment for good. Normally posted against the MACHINE so the assignment survives a reimage.",
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"hostname\":\"workstation01\"}' http://localhost:5001/api/printers/assignments/seed-from-observed/312"
},
{
"method": "GET",
"path": "/api/printers/<printer_id>",
@@ -3023,6 +3071,14 @@
"params": "none",
"purpose": "Dashboard card: printers needing a cartridge, one row per printer. Reuses the low-supplies query and its five-minute cache, so the card costs the same as the report",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/dashboard/supplies"
},
{
"method": "GET",
"path": "/api/printers/assignments/for-asset/<asset_id>",
"auth": "optional jwt",
"params": "asset_id in path",
"purpose": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The editor has to show what this asset's own rows say, or a machine's printers would appear ticked on the PC that inherits them and unticking one would silently create an override. /printers/for-host is the resolved view the client uses; this is the editable one",
"example": "curl http://localhost:5001/api/printers/assignments/for-asset/14"
}
]
},

View File

@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "ShopDB Flask API",
"version": "0.10.0",
"version": "0.11.3",
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
},
"servers": [
@@ -9892,6 +9892,44 @@
}
}
},
"/api/computers/client-script": {
"get": {
"tags": [
"plugin-computers"
],
"summary": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the...",
"description": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/measuringtools/types": {
"get": {
"tags": [
@@ -16717,6 +16755,324 @@
}
}
},
"/api/printers/for-host/{hostname}": {
"get": {
"tags": [
"plugin-printers"
],
"summary": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones...",
"description": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.\n\n**Auth:** jwt-optional\n\n**Params:** path: hostname (matched case-insensitively, as sent by the client's COMPUTERNAME); no query parameters\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/for-host/workstation01\n```",
"security": [
{},
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "hostname",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/printers/observed/{hostname}": {
"get": {
"tags": [
"plugin-printers"
],
"summary": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST...",
"description": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST /api/collector/printers), each queue judged against the resolved assignment it SHOULD have. Read-only - nothing here changes an assignment, however wrong the bay looks. Every queue is classified matching / drifted (same printer, different port address or driver) / extra (a printer ShopDB knows, installed unassigned) / missing (assigned, not reported) / unknown (matches no printer, never guessed), each entry carrying both sides plus driftfields; the response adds observedat, the assignment source (own rows or the machine this PC controls), a per-classification summary, and a seedcandidate preview of what a seed would write. 404 only when the hostname has neither a report nor a computer record; a known PC that never reported returns an empty queue list.\n\n**Auth:** permission:printers.view\n\n**Params:** path: hostname (case-insensitive, short name or FQDN, as the reporting client sent it); no query parameters\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/workstation01\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "hostname",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/printers/observed/for-asset/{asset_id}": {
"get": {
"tags": [
"plugin-printers"
],
"summary": "The same observed-against-assigned comparison as /api/printers/observed/<hostname>, reached from an asset page, with...",
"description": "The same observed-against-assigned comparison as /api/printers/observed/<hostname>, reached from an asset page, with one block per reporting host. Assigned state lives on the MACHINE while observed state is reported by the PCs, so a machine answers with a block for each active PC that controls it (its own hostname first when the asset is itself a PC); blocks rather than one merged list because two PCs legitimately share one machine and the actionable part of drift is which box to walk to. Each block carries the same fields as the by-hostname route: queues classified matching / drifted / extra / missing / unknown, observedat, the assignment source, a summary, and a seedcandidate preview. Read-only. An asset nothing has reported for is an empty hosts list and a 200, not a 404.\n\n**Auth:** permission:printers.view\n\n**Params:** path: asset_id (a machine or a PC); no query parameters\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/for-asset/312\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/printers/assignments/for-asset/{asset_id}": {
"put": {
"tags": [
"plugin-printers"
],
"summary": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates...",
"description": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)\n\n**Example:**\n```\ncurl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)"
}
}
}
}
},
"get": {
"tags": [
"plugin-printers"
],
"summary": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The...",
"description": "This asset's OWN printer assignment - printerassetids and defaultprinterassetid - deliberately WITHOUT inheritance. The editor has to show what this asset's own rows say, or a machine's printers would appear ticked on the PC that inherits them and unticking one would silently create an override. /printers/for-host is the resolved view the client uses; this is the editable one\n\n**Auth:** optional jwt\n\n**Params:** asset_id in path\n\n**Example:**\n```\ncurl http://localhost:5001/api/printers/assignments/for-asset/14\n```",
"security": [
{},
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/printers/assignments/seed-from-observed/{asset_id}": {
"post": {
"tags": [
"plugin-printers"
],
"summary": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background...",
"description": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background job reaches this route, so a bay that installed the wrong printer can never make itself right by reporting it. Writes the matched observed queues through the same reconcile as PUT /printers/assignments/for-asset (usesprinter rows plus the single defaultprinter, set only when the observed default itself matched a printer). Returns what was written, the skipped queues, and warnings - including one when the target controls another asset, because its own rows then shadow that machine's assignment for good. Normally posted against the MACHINE so the assignment survives a reimage.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those.\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"hostname\":\"workstation01\"}' http://localhost:5001/api/printers/assignments/seed-from-observed/312\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "asset_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those."
}
}
}
}
}
},
"/api/printers/{printer_id}": {
"get": {
"tags": [

View File

@@ -0,0 +1,331 @@
# Proposal: assign printers to a machine in ShopDB, let the PC install them
Status: ACCEPTED. Server half being built 2026-08-18 (relationship types and
rails, resolution helper, two endpoints, PC form section, `drivername`). The
client script is not built.
Author: planning session 2026-08-18.
## 1. What this is
Today a printer reaches a shop-floor PC because a person walks up to it, opens
the printer installer, finds the printer on a floor plan and clicks it. That is
fine for someone choosing a printer, and wrong for a bay whose printers are a
property of the bay.
This proposal makes the assignment data. The printers belong to the MACHINE, not
to the box currently driving it: tick the printers that belong on the machine,
mark one default, and the assignment reaches whichever PC controls that machine.
The PC converges on its next GE-Enforce cycle - installing what is missing and
setting the default - and keeps converging, so a reimaged bay comes back with
its printers and a bay that drifts is corrected.
The point of putting the assignment on the machine is that a reimaged PC needs
no backup and no restore step. The asset register is the source of truth, and a
replacement PC that inherits the `controls` edge inherits the printers with it.
The map installer stays, for the case it is actually good at: a person at an
unmanaged or office PC picking a printer that nobody assigned.
## 2. Why it is worth doing
- **The assignment becomes a record.** "Which printers does that bay have" is a
question ShopDB can answer, and today it cannot.
- **A reimage stops costing a visit.** The bay reinstalls its own printers, from
the machine's record, with nothing saved off the old PC.
- **Swapping the PC keeps the printers.** They were never the PC's.
- **Drift is corrected, not just detected.** A queue deleted by a user comes
back.
- **It removes the walk-up from the common case.** The installer's map remains
for the uncommon one.
## 3. What already exists
Most of the model is in place, which is why this is a small feature rather than
a project.
| piece | state |
|---|---|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" |
| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position |
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
| Per-printer install path | `Printer.installpath` |
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
## 4. The data model
### 4.1 One new relationship type
`usesprinter`, directional, source -> printer, meaning "this printer is
installed here". It is seeded next to `defaultprinter`, which already exists and
means "which of them is the default". Both are seed data, not a migration, which
is how every other relationship type shipped.
### 4.2 Two propagation rails, consumed at READ time
`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both
are rows in `relationshiptypepropagations`, the same mechanism map positions
use. Inventing a second mechanism for this was the alternative, and it was
rejected.
The rails are inert at write time on purpose. The create-time fan-out
(`propagate_relationship`) skips directional through-types, and `controls` is
directional, so assigning a printer to a machine does not copy rows onto its PC.
The walk happens when something asks, which is what makes the next rule possible.
### 4.3 Resolution order for a PC
1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any.
2. Otherwise, one hop out along its `controls` edges to the machines it drives,
and those machines' rows instead, tagged as inherited.
Own beats inherited, whole set at a time: a PC with its own assignment is
overriding the bay, not adding to it. An office PC controls no machine and still
works, because step 1 is the normal case for it.
The override is a real trap and the UI has to say so. A tech who "fixes" a bay
by editing the PC has shadowed the machine's record, and the machine will keep
disagreeing until someone clears the PC's own rows.
### 4.4 One default, optional, and never dangling
The unique constraint is `(source, target, type)`, which happily accepts two
different defaults. So the rule is enforced in the API on write:
- Exactly one `defaultprinter` per asset. Setting a default replaces the
existing one.
- A default is OPTIONAL. A bay with three printers and no default is valid.
- The default must be one of the assigned printers. Unassigning the printer that
is currently default clears the default rather than leaving it dangling.
### 4.5 One column on `printerdrivers`
`drivername` - the driver's exact name as the INF declares it, e.g.
`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not
on `name`, which is ours to choose, and a mismatch is the usual failure.
Deriving it by parsing the INF on hundreds of bays is fragile; a human
confirming it once in ShopDB is not.
It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a
re-run is a no-op. `printerdrivers` was created by a core migration but its DDL
moved to the printers chain at the ADR-008 cutover.
The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being
built. See section 8: if production confirms no Brother printers, everything is
`pnputil` and the column has no second value to hold.
## 5. What has to be built
### 5.1 A resolution helper in core
The read-time walk of section 4.3, beside `resolve_asset_position` and exported
on the `shopdb.api` contract surface (an additive minor bump). It has to live in
core because `RelationshipTypePropagation` is not on the contract surface, and a
plugin may not reach past it (ADR-002).
The through-type comes from the seeded rails, not from a hardcoded `'controls'`,
so a site that adds a rail gets the behaviour without a code change.
### 5.2 Two endpoints
```
GET /api/printers/for-host/<hostname>
```
The desired printer set for one PC, resolved per section 4.3, each entry with
what a client needs to install it: queue name, host or IP, port, driver name,
driver location, and which one is default.
Resolved by hostname, not machine number: the collector already upserts PCs by
hostname, and an office PC has no machine number. Matched case-insensitively -
`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not.
An unknown host, a site without the computers plugin, or 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/<asset_id>
```
The whole assignment for one asset - machine or PC - in one call:
`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather
than inserting: rows that went away are soft-deleted, rows that come back
REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a
blind insert is an integrity error on assign, unassign, re-assign), new rows are
created, and the single default is replaced.
The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic
relationships path leave two-default windows and know nothing of the subset rule.
**Removing an assignment NEVER uninstalls anything.** Server-side the row simply
goes: no cascade, no side effects, nothing queued for the client to undo.
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
and PC type and sync broadly; putting per-PC rows there would leak every bay's
configuration to every bay and grow without limit. One manifest entry runs one
script that asks the API what THIS host gets - the mirror image of
`Report-AssetToShopDB.ps1`.
### 5.3 UI on the PC form
A printer multi-select plus a default dropdown whose options are only the
currently selected printers, clearing itself when its printer is deselected.
Saved through the reconcile endpoint against the PC's asset.
The computers plugin does not depend on the printers plugin and must not start:
the section hides itself when the printers API is not there.
The machine-side picker is out of scope for now, which means the machine's
assignment is editable only through the generic relationships card. That is the
side the design says is primary, so it is the obvious next piece of UI.
### 5.4 One client script, in two contexts
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
*As SYSTEM, every cycle:*
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
2. For each assigned printer with no queue: trust the driver catalog's cert,
`pnputil /add-driver`, create the port, create the queue
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
4. Ensure the per-user task exists
*In the user's context, at logon and on a repeat:*
5. Read that value, compare with the current default, set it if it differs, and
clear "Let Windows manage my default printer" - otherwise Windows silently
overrides the choice the next time someone prints elsewhere
The default printer is per-user state, which is the only reason this needs two
contexts. Everything else is machine state and belongs to the cycle.
Converge, do not reinstall: when the state matches, the script does nothing.
Nothing here needs the manifest to know when a printer changes, because the
desired state is fetched, not declared.
## 6. Desired state and observed state are not the same thing
Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in
this feature knows what IS installed on it. The client reads the desired state,
converges toward it, and reports nothing back.
Reporting the observed state is the obvious next feature and is deliberately not
this one. If the collector sent the installed queues per host - name, port,
driver, which is default - then comparing that against the resolved assignment
gives drift detection for free: "this bay is missing the label printer", "this
PC has three queues nobody assigned", "the default is not the assigned one".
Keeping them apart is a rule, not a preference:
- **Observed data never writes `usesprinter` rows.** A register that learns from
what it finds mirrors the drift instead of correcting it, and the fault
becomes the desired state.
- **Observed data belongs on the computer record, timestamped**, like the rest
of the collector payload. It is an observation with an age, not a decision.
- **An empty answer from the API means "nothing assigned", not "nothing
installed"**, which is exactly why section 5.2 refuses to make removal
uninstall anything.
- The two can disagree indefinitely and that is a report to read, not an error
to resolve automatically.
## 7. Decisions to take before writing the client
1. **Never remove a queue by default.** A transient API failure would otherwise
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
user who chose their own default - correct for a locked bay, irritating on an
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
rather than one global answer.
3. **Failure is silent and safe**: unreachable API means change nothing, log,
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
Open on the server side, and each one changes the response contract:
4. **How a universal driver resolves.** `PrinterDriver` links to a printer by
exact `modelnumberid`, and the target state is roughly four rows dominated by
HP UPD and Xerox GPD, which match no single model. Either the driver row
gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's
resolved vendor name. Until this is settled, `for-host` returns no driver for
41 of 44 printers.
5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose
job it is to apply it - server or script - has to be written down once.
6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous;
the collector and the GE-Enforce fetch use scoped service tokens. This one
discloses per-PC configuration keyed by hostname.
7. **Two inherited defaults.** A PC can legitimately control both bays of a
dual-bay machine, or several machines. The union of assigned printers is
easy; the default needs a deterministic rule, or none when it is ambiguous.
8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they
predate the type. Either an active default implies assignment on read
(zero-touch, preferred) or a one-time backfill writes the missing rows.
Otherwise existing defaults vanish from `for-host` while still showing in
`pc-default`.
9. **Deletions through the generic relationships card bypass the reconcile
endpoint** and can strand an active default pointing at an unassigned
printer. Either the resolver drops dangling defaults or the core delete path
learns the rule.
## 8. What the fleet data says, and the one prerequisite
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
Xerox Global Print Driver). One driver record each serves every queue of that
make.
- **There are no Brother printers at all**, yet the installer carries 208 files
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
with no Printer-class INF, which is the only reason a second staging method
(DPInst) exists. If production confirms no Brother, that payload and that code
path can both go - and with them the `installmethod` column.
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
a fourth special case - a PostScript device the UPD does not cover.
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
universal driver - the opposite of how a UPD should be used. The table needs
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
payload is restored. Each needs `drivername` copied verbatim from its INF.
Nothing in this proposal works until a printer can resolve to a driver.
## 9. Deployment constraint that shapes the design
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
a share path must run as a manifest entry inside that cycle, never as its own
scheduled task. The failure is silent - the task reports 0 processed, 0
installed, 0 failed - and it has cost a session before.
This is why driver staging belongs in the cycle even though the per-user default
does not, and why "the assignment script schedules a task that installs drivers"
is the wrong shape.
## 10. Upgrading an existing site
Three steps, and the third is the one that gets forgotten:
1. `flask db upgrade` - no core migration in this feature, but a deploy runs it.
2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic
1054 unknown-column error.
3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and
both propagation rails do not exist, and `for-host` resolves nothing, quietly,
because empty is also the healthy answer.
Pair the upgrade with a smoke check against a known bay. A site with reversed
legacy `controls` rows (machine -> PC) should run
`flask relationships fix-controls-direction` first, or inheritance resolves for
none of those PCs.
## 11. What this does not change
- The printer installer keeps working, for walk-up and self-service.
- Nothing about how printers are modelled, mapped or reported.
- The collector contract. Section 6 would change it; this feature does not.
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.

View File

@@ -1,6 +1,6 @@
{
"name": "shopdb-frontend",
"version": "0.11.2",
"version": "0.11.3",
"private": true,
"type": "module",
"scripts": {

View File

@@ -306,6 +306,25 @@ export const printersApi = {
list(params = {}) {
return api.get('/printers', { params })
},
// Printer assignment for one asset - a MACHINE normally, since the assignment
// belongs to the bay and reaches whichever PC controls it. Reads and writes
// that asset's OWN rows: the resolved view (own, else inherited) is
// /printers/for-host, which the client uses, not the editor.
assignment: {
get(assetid) {
return api.get(`/printers/assignments/for-asset/${assetid}`)
},
// The WHOLE set in one call. A row-at-a-time edit is a non-atomic
// reconcile: an HTTP failure part way leaves an asset half-assigned, with
// nothing recording what was meant.
set(assetid, printerassetids, defaultprinterassetid) {
return api.put(`/printers/assignments/for-asset/${assetid}`, {
printerassetids,
defaultprinterassetid: defaultprinterassetid ?? null
})
}
},
// Every printer, paged past the backend's 100-row cap. Batch label printing
// and "pick any record" dropdowns must use this: list() with a large
// perpage is clamped to 100 and still returns a success response, so

View File

@@ -22,7 +22,7 @@
<!-- With coordinates, the name carries the same floor-plan preview
the asset's own page uses. Without them, a plain link. -->
<LocationMapTooltip v-if="row.link && row.maphover"
:left="row.maphover.x" :top="row.maphover.y"
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
:machineName="row.maphover.label">
<router-link :to="row.link" class="dc-row-title"
:title="row.titletip || undefined">

View File

@@ -0,0 +1,168 @@
<template>
<template v-if="enabled">
<h4 class="printer-heading">Printers</h4>
<div class="form-group">
<label :for="`printersearch-${uid}`">Assigned printers</label>
<input
:id="`printersearch-${uid}`"
v-model="search"
type="text"
class="form-control"
placeholder="Filter printers..."
/>
<div class="printer-list">
<label v-for="option in filtered" :key="option.assetid" class="printer-item">
<input
type="checkbox"
:checked="assigned.includes(option.assetid)"
@change="toggle(option.assetid, $event.target.checked)"
/>
<span>{{ label(option) }}</span>
<span v-if="option.printer?.modelname" class="printer-meta">
{{ option.printer.modelname }}
</span>
</label>
<span v-if="!filtered.length" class="muted">No printers match.</span>
</div>
<small class="form-hint">{{ hint }}</small>
</div>
<div class="form-group">
<label :for="`defaultprinter-${uid}`">Default printer</label>
<select :id="`defaultprinter-${uid}`" v-model="defaultAssetId" class="form-control">
<option :value="null">No default</option>
<!-- Only what is assigned: a default the bay was never told to install
fails to apply, and nothing in ShopDB shows why. -->
<option v-for="option in assignedOptions" :key="option.assetid" :value="option.assetid">
{{ label(option) }}
</option>
</select>
<small class="form-hint">
Optional. Applied per user at logon, because a default printer is a
per-user setting that SYSTEM cannot set for somebody else.
</small>
</div>
</template>
</template>
<script setup>
// One picker, used by the machine form and the PC form.
//
// The assignment belongs to the MACHINE - that is what makes a reimaged PC come
// back with the bay's printers - and the PC form writes the same shape as an
// override. Two copies of this UI would drift, and the two ends of an override
// disagreeing is exactly the bug nobody would spot.
import { ref, computed, watch, onMounted } from 'vue'
import { printersApi } from '@/api'
const props = defineProps({
// The asset being edited. Null while creating: save(assetid) is called with
// the new id once it exists.
assetid: { type: Number, default: null },
// Wording only. What it means to assign here differs: a machine's set is the
// bay's, a PC's set overrides the machine it controls.
scope: { type: String, default: 'machine' }
})
const uid = Math.random().toString(36).slice(2, 8)
const enabled = ref(false)
const printers = ref([])
const assigned = ref([])
const defaultAssetId = ref(null)
const search = ref('')
const filtered = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return printers.value
return printers.value.filter(option => label(option).toLowerCase().includes(term))
})
const assignedOptions = computed(() =>
printers.value.filter(option => assigned.value.includes(option.assetid)))
const hint = computed(() => {
const count = assigned.value.length
if (props.scope === 'pc') {
return `${count} assigned. Printers ticked here belong to this PC and REPLACE `
+ 'whatever the machine it controls is assigned - the whole set, not added to it.'
}
return `${count} assigned. These belong to the machine, so whichever PC controls `
+ 'it installs them - including a replacement PC after a reimage.'
})
function label(option) {
// A real fleet has printers whose name is the literal string 'NONE' - an
// import artefact - and showing that as the label makes two different
// printers indistinguishable in the list.
const name = option.name && option.name.toUpperCase() !== 'NONE' ? option.name : ''
return name || option.assetnumber || `Printer ${option.assetid}`
}
function toggle(assetid, checked) {
if (checked) {
if (!assigned.value.includes(assetid)) assigned.value.push(assetid)
} else {
assigned.value = assigned.value.filter(id => id !== assetid)
// Unassigning the default clears it rather than leaving a row pointing at a
// printer the bay is no longer told to install.
if (defaultAssetId.value === assetid) defaultAssetId.value = null
}
}
async function loadOptions() {
try {
// listAll: perpage is clamped server-side, and a picker that stops at 100
// silently hides printers sorting late in the alphabet.
printers.value = await printersApi.listAll()
enabled.value = true
} catch (error) {
// A site without the printers plugin has no section at all.
enabled.value = false
}
}
async function loadAssignment(assetid) {
if (!enabled.value || !assetid) return
try {
const response = await printersApi.assignment.get(assetid)
const data = response.data.data || {}
assigned.value = data.printerassetids || []
defaultAssetId.value = data.defaultprinterassetid ?? null
} catch (error) {
assigned.value = []
defaultAssetId.value = null
}
}
// Called by the parent AFTER the asset exists, so a new record can be assigned
// in the same save.
async function save(assetid) {
if (!enabled.value || !assetid) return
await printersApi.assignment.set(assetid, assigned.value, defaultAssetId.value)
}
defineExpose({ save })
onMounted(async () => {
await loadOptions()
await loadAssignment(props.assetid)
})
watch(() => props.assetid, assetid => loadAssignment(assetid))
</script>
<style scoped>
.printer-heading { margin-top: 1.5rem; margin-bottom: 1rem; }
.printer-list {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35rem 0.5rem;
margin-top: 0.35rem;
}
.printer-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; }
.printer-meta { color: var(--text-light); font-size: 0.85em; }
.muted { color: var(--text-light); }
</style>

View File

@@ -127,7 +127,15 @@ export function mapHover(card, item) {
const x = item[spec.x]
const y = item[spec.y]
if (x === null || x === undefined || y === null || y === undefined) return null
return { x, y, label: spec.label ? (item[spec.label] || '') : '' }
// The level travels with the coordinates (ADR-017) - they are pixels of ONE
// drawing. Read `levelid` unless the card names another field, so a card that
// predates levels still previews on the right floor instead of none.
const levelid = item[spec.level || 'levelid']
return {
x, y,
levelid: levelid === undefined ? null : levelid,
label: spec.label ? (item[spec.label] || '') : '',
}
}
export function cardRows(card) {

View File

@@ -289,7 +289,12 @@ async function exportPdf() {
try {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
// Only this level's markers. The sheet is one drawing, so a marker
// positioned against another level would be printed on the wrong floor
// plan - the same failure the on-screen map had, in a form nobody can
// correct after it is printed and carried onto the floor.
assets: filteredAssets.value.filter(
asset => (asset.levelid ?? null) === shownLevelId.value),
// blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper.

View File

@@ -0,0 +1,46 @@
"""Let a relationship type say "at most one of these per asset".
A PC has one default printer. The unique constraint on assetrelationships is
(source, target, type), which happily accepts two DIFFERENT defaults on one
asset - and the resolver then takes the oldest, so setting a new default through
the generic relationships card left the old one winning, silently.
Cardinality belongs to the TYPE, not to the printers plugin: the next type that
means "exactly one" (a primary user, a primary location) gets the rule for free,
and core's create path is where every write already passes.
Revision ID: 7d34_singular_relationship_types
Revises: 7d33_buildings_and_levels
"""
from alembic import op
import sqlalchemy as sa
revision = '7d34_singular_relationship_types'
down_revision = '7d33_buildings_and_levels'
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('relationshiptypes')}
if 'issingular' not in columns:
op.add_column('relationshiptypes',
sa.Column('issingular', sa.Boolean(), nullable=False,
server_default=sa.false()))
# defaultprinter is the type this exists for, and it is already in use, so
# set it here rather than waiting for a re-seed: a site that upgrades and
# does not re-seed would otherwise keep the old silent behaviour.
op.execute("UPDATE relationshiptypes SET issingular = 1 "
"WHERE LOWER(relationshiptype) = 'defaultprinter'")
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = {column['name'] for column in inspector.get_columns('relationshiptypes')}
if 'issingular' in columns:
op.drop_column('relationshiptypes', 'issingular')

View File

@@ -1,13 +1,13 @@
"""Computers plugin API endpoints."""
from flask import Blueprint, request
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from shopdb.api import require_permission, apply_import_timestamps
from shopdb.api import require_permission, require_role, apply_import_timestamps
computers_bp = Blueprint('computers', __name__)
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
out.sort(key=lambda r: -r['pccount'])
return success_response(out)
# =============================================================================
# Collector client script
# =============================================================================
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
def _client_script_path():
"""The reporter shipped with this plugin, which is the single source."""
import os
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'client', CLIENT_SCRIPT_NAME)
def _setting_value(key):
row = Setting.query.filter_by(key=key).first()
return ((row.value if row else '') or '').strip()
def _generate_client_script(source: str, baseurl: str, ranges: str,
version: str, generatedon: str) -> str:
"""Stamp a site's own values into the reporter's parameter defaults.
ONLY the defaults are substituted, never the body: the file in the repo
stays runnable as-is, so there is no second copy to drift. Everything
stamped here is overridable at runtime - the parameter still wins, then the
registry - because a bay may need to differ from its site.
The collector key is NOT stamped in. This file lands on every shop-floor PC,
and a token in a file on hundreds of bays cannot be rotated quietly; it is
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.
"""
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
header = (
'# GENERATED by ShopDB {version} on {generatedon}\n'
'# for {baseurl}\n'
'#\n'
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
'# collector contract. Edits here are lost on the next download - change\n'
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
'#\n'
'# The collector key is deliberately NOT in this file. Provision it as\n'
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
'\n'
).format(version=version, generatedon=generatedon,
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
out = source
if apiurl:
old = "[string]$ApiUrl = ''"
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
if ranges:
old = "[string]$AllowedRanges = ''"
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
return header + out
@computers_bp.route('/client-script', methods=['GET'])
@jwt_required()
@require_role('admin')
def download_client_script():
"""The collector reporter, stamped with THIS site's values.
Admin-only. It carries no secret, but it does state a site's URL and its
internal ranges, which is configuration rather than something to hand out.
"""
import datetime
import hashlib
import os
path = _client_script_path()
if not os.path.isfile(path):
return error_response(ErrorCodes.NOT_FOUND,
'The collector script is not present in this install',
http_code=404)
with open(path, 'r', encoding='utf-8') as handle:
source = handle.read()
# A site that has not set its public URL still gets a usable script: the
# browsing origin is the server the admin is talking to right now.
baseurl = _setting_value('site_base_url') or request.url_root
# From config, not an import: a plugin reaching into core is an ADR-002
# violation and the contract test fails the build for it.
version = current_app.config.get('VERSION') or 'unknown'
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
try:
body = _generate_client_script(
source, baseurl.strip(), _setting_value('computers_routableranges'),
version, generatedon)
except AssertionError as exc:
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
return Response(
body,
mimetype='text/plain; charset=utf-8',
headers={
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
# Published so a deployment can verify what it fetched, the same way
# the installer publishes one.
'X-Script-Sha256': digest,
})

View File

@@ -0,0 +1,328 @@
# Report-AssetToShopDB.ps1
#
# Reports a PC's identity to ShopDB (Flask) so the computers/machines record
# stays current with whatever the PC actually is right now: hostname, BIOS
# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when
# present) and its corp/AESFMA IPv4 address.
#
# TARGET: the ADR-006 collector API.
# POST <shopdb>/api/collector/computers
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
# which Install-GEEnforce.ps1 provisions and the enforcement client already
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
# product code is a defect, and this script ships to every site.
# The computers plugin's apply_collector_payload upserts idempotently by
# hostname (create if missing, patch-style update if present - only the fields
# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
# key into the manifest JSON on the share.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
# identity with no machineNo, so no PC-to-machine link is built - by design.
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are
# logged, never thrown.
#
# WHY ONE NIC ONLY:
# Some bays carry two NICs - a private controller NIC and the routable
# corporate NIC. Only the routable one belongs in ShopDB. A site may name its
# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with
# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
# the same intent without knowing any site's addressing.
param(
# Flask collector endpoint for the computers plugin. Empty resolves from
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
[string]$ApiUrl = '',
# Comma-separated CIDRs naming this site's routable ranges, e.g.
# '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead.
[string]$AllowedRanges = '',
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
# store in the registry. Override with -ApiKey via Args for testing.
[string]$ApiKey = '',
[int]$TimeoutSec = 30
)
$ErrorActionPreference = 'Continue'
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
# protocol the site rejects; the collector POST is HTTPS.
[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 ('report-asset-{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
}
# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
if (-not $ApiUrl) {
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
try {
if (Test-Path $regPath) {
$base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl
if ($base -and $base.Trim()) {
$ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers'
break
}
}
} catch {}
}
}
if (-not $ApiUrl) {
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
exit 0
}
# A site may name its routable ranges rather than rely on the default route.
if (-not $AllowedRanges) {
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges
if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break }
}
} catch {}
}
}
# collector key from the GE-Enforce secret store when not passed via Args.
if (-not $ApiKey) {
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey
if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break }
}
} catch {}
}
}
if (-not $ApiKey) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an
# unconfigured site falls through to the default-route NIC below (ADR-015).
#
# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so
# that collides with the [string] parameter above and the array is silently
# COERCED to a string. .Count on a string is 1, so the script then believes a
# range is configured and never falls back to the default route.
$rangeList = @()
foreach ($cidr in ($AllowedRanges -split ',')) {
$cidr = $cidr.Trim()
if (-not $cidr) { continue }
$parts = $cidr -split '/'
if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue }
$rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] }
}
function ConvertTo-Uint32([string]$ip) {
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
[Array]::Reverse($bytes)
return [BitConverter]::ToUInt32($bytes, 0)
}
function Test-InAllowedRange([string]$ip) {
try {
$ipInt = ConvertTo-Uint32 $ip
foreach ($r in $rangeList) {
$netInt = ConvertTo-Uint32 $r.Network
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true }
}
} catch {}
return $false
}
Log '=== Report asset to ShopDB (collector) ==='
# hostname - the collector identity field. required.
$hostname = $env:COMPUTERNAME
# BIOS serial - optional now (collector keys on hostname). Sent when present.
$serialNumber = ''
try {
$serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
if ($serialNumber) { $serialNumber = $serialNumber.Trim() }
} catch {
Log "WARN could not read BIOS serial: $($_.Exception.Message)"
}
# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber
# server-side (9999 placeholder skipped there and here).
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
# 3. C:\Enrollment\machine-number.txt - imaging value.
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
$machineNo = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
if ($machineNo) { break }
try {
if (Test-Path $regPath) {
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
}
} catch {
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
}
}
if (-not $machineNo) {
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
if (Test-Path -LiteralPath $cmmFile) {
try {
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
}
}
if (-not $machineNo) {
$mnFile = 'C:\Enrollment\machine-number.txt'
if (Test-Path -LiteralPath $mnFile) {
try {
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
}
}
# OS name string (caption + feature-update + build), e.g.
# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each
# distinct string into operatingsystems.
$osVersion = ''
$lastBootTime = ''
try {
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$osVersion = "$($os.Caption)".Trim()
$displayVersion = ''
try {
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
} catch {}
if ($displayVersion) { $osVersion += " $displayVersion" }
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
$osVersion = $osVersion.Trim()
# ISO 8601 - collector parses via datetime.fromisoformat.
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
} catch {}
# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName
# (console session owner). empty when nobody logged on -> omitted so an
# unattended bay does not blank the last-known user.
$loggedInUser = ''
try {
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
} catch {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
# when present; absent -> server leaves existing pctype untouched (a bare report
# never re-types a PC). Unmapped values return a warning, not an error.
$pcType = ''
$ptFile = 'C:\Enrollment\pc-type.txt'
if (Test-Path -LiteralPath $ptFile) {
try {
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
}
# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when
# present so a WMI read failure does not blank the model.
$manufacturer = ''
$model = ''
try {
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
$manufacturer = "$($cs.Manufacturer)".Trim()
$model = "$($cs.Model)".Trim()
} catch {
Log "WARN could not read make/model: $($_.Exception.Message)"
}
# The routable IPv4 - a physical, connected NIC. The collector schema takes a
# single ipaddress string, so report the corporate NIC and drop any
# controller/machine-LAN NIC. With ranges configured the IP must be in one;
# with none, the NIC carrying the default route is the routable one by
# definition, which needs no knowledge of a site's addressing.
$corpIp = ''
$defaultRouteIfIndexes = @()
if ($rangeList.Count -eq 0) {
try {
$defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
Sort-Object RouteMetric |
Select-Object -ExpandProperty InterfaceIndex -Unique)
} catch {
Log "WARN could not read the route table: $($_.Exception.Message)"
}
}
try {
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
foreach ($ipo in $ipObjs) {
if ($corpIp) { break }
$adapter = $null
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
if (-not $adapter) { continue }
if (-not $adapter.HardwareInterface) { continue }
if ($adapter.Status -ne 'Up') { continue }
if ($rangeList.Count -gt 0) {
if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress }
} elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) {
$corpIp = $ipo.IPAddress
}
}
} catch {
Log "WARN interface enumeration failed: $($_.Exception.Message)"
}
if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' }
# collector schema fields (lowercase concatenated). hostname is required; the
# rest are sent only when present so a partial read never blanks a good value.
$body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType }
if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model }
if ($osVersion) { $body['osname'] = $osVersion }
if ($lastBootTime) { $body['lastboottime'] = $lastBootTime }
if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser }
if ($corpIp) { $body['ipaddress'] = $corpIp }
$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
$json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
}
exit 0

View File

@@ -39,6 +39,12 @@ export default [
meta: { requiresAuth: true, plugin: 'computers' }
},
// Computer-specific settings
{
path: 'settings/collector',
name: 'collector-settings',
component: () => import('./views/CollectorSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
},
{
path: 'settings/pctypes',
name: 'pctypes',

View File

@@ -0,0 +1,134 @@
<template>
<div>
<div class="page-header">
<h2>Asset reporter</h2>
</div>
<p class="setting-description">
Shop-floor PCs report what they are to this server. The script below is
generated with THIS site's values, so it downloads ready to deploy - there
is nothing in it to find and edit.
</p>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label>Routable ranges</label>
<input v-model="ranges" type="text" class="form-control"
placeholder="10.20.0.0/23,10.21.4.0/26" />
<p class="field-hint">
Comma-separated CIDRs for this site's corporate network. A bay with two
NICs - a private controller NIC and a routable one - reports the
address in these ranges. Leave it empty and the PC reports whichever
NIC carries the default route, which is correct at most sites and needs
no configuration.
</p>
</div>
<button class="btn btn-primary" @click="save" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card form-card">
<h3>Download the reporter</h3>
<p class="field-hint">
Stamped with this server's URL and the ranges above, and with the version
that generated it, so a script found on a bay can be traced back here.
Re-download after upgrading ShopDB.
</p>
<button class="btn btn-secondary" @click="download" :disabled="downloading">
{{ downloading ? 'Generating...' : 'Download Report-AssetToShopDB.ps1' }}
</button>
<p v-if="digest" class="field-hint mono">
SHA-256 {{ digest }}
</p>
<p class="field-hint">
<strong>The collector key is not in this file, deliberately.</strong> It
lands on every shop-floor PC, and a token spread across hundreds of bays
cannot be rotated quietly. Mint a token scoped to
<code>collector.ingest</code> and provision it as
<code>HKLM:\SOFTWARE\GE\ShopDB</code> value <code>CollectorKey</code> -
the adoption guide has worked examples for Intune, DSC and GE-Enforce.
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api from '@/api'
const RANGES_KEY = 'computers_routableranges'
const ranges = ref('')
const saving = ref(false)
const downloading = ref(false)
const digest = ref('')
const message = ref('')
const error = ref('')
onMounted(async () => {
try {
const response = await settingsApi.list({ category: 'computers' })
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
if (row) ranges.value = row.value || ''
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)
}
})
async function save() {
saving.value = true
message.value = ''
error.value = ''
try {
await settingsApi.update(RANGES_KEY, String(ranges.value ?? ''))
message.value = 'Saved. Re-download the script so it carries the new ranges.'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
async function download() {
downloading.value = true
error.value = ''
try {
// responseType text: this is a script, not JSON, and the hash the server
// publishes is of exactly these bytes.
const response = await api.get('/computers/client-script', { responseType: 'text' })
digest.value = response.headers['x-script-sha256'] || ''
const blob = new Blob([response.data], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'Report-AssetToShopDB.ps1'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (downloadError) {
error.value = downloadError.response?.status === 403
? 'Only an administrator can download the reporter'
: 'Could not generate the script'
console.error(downloadError)
} finally {
downloading.value = false
}
}
</script>
<style scoped>
.mono { font-family: monospace; word-break: break-all; }
.form-card h3 { margin-top: 0; }
</style>

View File

@@ -185,6 +185,7 @@
v-if="computer.mapx != null && computer.mapy != null"
:left="computer.mapx"
:top="computer.mapy"
:levelid="computer.levelid"
:machineName="computer.assetnumber"
>
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>

View File

@@ -245,6 +245,12 @@
</div>
</div>
<!-- Printers assigned to this PC ITSELF, which replace whatever the
machine it controls is assigned. Same component the machine form
uses: two copies of this UI would drift, and the two ends of an
override disagreeing is the bug nobody would spot. -->
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="pc" />
<div class="form-group">
<label for="notes">Notes</label>
<textarea
@@ -317,16 +323,21 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
printersApi, relationshipTypesApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
@@ -338,6 +349,7 @@ const isEdit = computed(() => !!route.params.id)
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
@@ -412,6 +424,7 @@ const models = ref([])
const locations = ref([])
const operatingsystems = ref([])
// Default PC Number to serial while the user hasn't typed their own (new PC only)
watch(() => form.value.serialnumber, (serial) => {
if (!isEdit.value && !manualPcNumber.value && serial) {
@@ -438,7 +451,8 @@ onMounted(async () => {
modelsApi.listAll(), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }),
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list()
computersApi.protocols.list(),
// Handles its own failure: a site without printers still gets a form.
])
pcTypes.value = ptRes.data.data || []
@@ -570,6 +584,22 @@ async function savePC() {
}
}
// Printer assignment through the shared picker, which reconciles the whole
// set in one call rather than row at a time.
//
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
// whose Save would create a second PC is the worse failure - but an
// assignment that quietly did not happen is the bug this feature exists to
// stop, so it has to be said out loud.
if (assetId && printerPickerRef.value) {
try {
await printerPickerRef.value.save(assetId)
} catch (printerError) {
console.error('Error saving printer assignment:', printerError)
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
}
}
router.push('/pcs')
} catch (err) {
console.error('Error saving PC:', err)
@@ -603,6 +633,30 @@ async function savePC() {
color: var(--text-light);
}
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
dozens of printers. */
.printer-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
gap: 8px;
}
.printer-meta {
color: var(--text-light);
font-size: 0.85rem;
}
.map-location-control {
display: flex;
align-items: center;

View File

@@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin):
},
}
def get_settings_cards(self) -> List[dict]:
"""The asset reporter's own settings page.
It is a settings card rather than a docs page because it does two things
an operator needs at the same moment: name this site's routable ranges,
and download the reporter that carries them.
"""
return [
{
'group': 'Computers',
'to': '/settings/collector',
'icon': 'download',
'title': 'Asset reporter',
'description': 'Download the collector script stamped with this '
'site\'s URL and ranges',
'position': 26,
},
]
def get_settings_defaults(self) -> List[dict]:
"""Settings this plugin owns.
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
'description': 'Hours without a collector report before a PC '
'is listed as not reporting on the dashboard.',
},
{
'key': 'computers_routableranges',
'value': '',
'valuetype': 'string',
'category': 'computers',
'description': 'Comma-separated CIDRs naming this site\'s '
'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. '
'Stamped into the collector script this server '
'generates, so a bay with a controller NIC and a '
'corporate NIC reports the right one. Blank uses '
'the NIC carrying the default route, which needs '
'no knowledge of a site\'s addressing.',
},
{
'key': 'computers_machinelink_alerts',
'value': 'false',

View File

@@ -1223,6 +1223,9 @@ def list_reports():
'location': known.get('location'),
'mapx': known.get('mapx'),
'mapy': known.get('mapy'),
# Coordinates are pixels of ONE level (ADR-017), so the level goes
# with them or the hover preview has nothing to draw on.
'levelid': known.get('levelid'),
'machinenumber': known.get('machinenumber'),
'machineassetid': known.get('machineassetid'),
'machinepluginid': known.get('machinepluginid'),

View File

@@ -51,7 +51,7 @@
empty map would be worse than none. -->
<LocationMapTooltip
v-if="report.mapx != null && report.mapy != null"
:left="report.mapx" :top="report.mapy"
:left="report.mapx" :top="report.mapy" :levelid="report.levelid"
:machineName="report.hostname"
>
<span class="map-pin" :title="report.location || 'On the floor plan'">&#9678;</span>

View File

@@ -167,6 +167,7 @@
v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx"
:top="machine.mapy"
:levelid="machine.levelid"
:machineName="machine.assetnumber"
>
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>

View File

@@ -344,6 +344,10 @@
<!-- Site-defined custom fields for machines -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
<!-- Printers belong to the MACHINE, so whichever PC controls it installs
them - including a replacement after a reimage. -->
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="machine" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -366,6 +370,7 @@ import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { apiError } from '@/utils/apiError'
@@ -380,6 +385,7 @@ const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
const loading = ref(true)
@@ -479,7 +485,11 @@ onMounted(async () => {
locations.value = locRes.data.data || []
models.value = allModels
businessunits.value = buRes.data.data || []
pcs.value = pcsRes.data.data || []
// listAll resolves to the ARRAY, not a response: fetchAllPages already
// unwrapped every page. Reading .data.data off it threw, and the whole
// parallel load went to the catch - so every dropdown on this form came up
// empty and the machine's own values never loaded.
pcs.value = pcsRes || []
// Load relationship types separately
try {
@@ -633,6 +643,17 @@ async function saveMachine() {
}
}
// Printer assignment, after the asset exists so a new machine can be
// assigned in the same save. A failure here must not lose the machine the
// user just entered, so it is reported and not thrown.
if (assetId && printerPickerRef.value) {
try {
await printerPickerRef.value.save(assetId)
} catch (printerErr) {
console.error('Error saving printer assignment:', printerErr)
}
}
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
} catch (err) {
console.error('Error saving machine:', err)

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -0,0 +1,144 @@
# Deploying the printer driver set
The driver set is a package: `Install-ShopdbPrinterDrivers.ps1`, the
single-driver worker it wraps, a `drivers.json` naming each driver and where its
files are, and the driver packages themselves.
Staging drivers is deliberately SEPARATE from assigning printers. Drivers are
large, change rarely and are identical across a fleet; assignments are small,
per-bay and change often. Keeping them apart means creating a queue never waits
on a download, and a driver never has to be fetched at the moment someone is
trying to print.
## The shape
```
ShopdbPrinterDrivers\
Install-ShopdbPrinterDrivers.ps1 the whole set, manifest driven
Install-ShopdbPrinterDriver.ps1 one driver (this does the work)
drivers.json what this site deploys
drivers\
hp_upd_ps\ xerox_gpd\ hp_designjet\ zebra_zt411\ ...
```
`drivers.json` paths may be relative to the package or absolute. A site whose
packages already live on a share points at the share and ships only the two
scripts and the manifest.
## GE-Enforce, in the `common` scope
Every shop-floor PC gets every driver, once. After the first cycle each run is a
`Get-PrinterDriver` check per driver and nothing else, so the cost is a few
milliseconds, not a re-install.
```json
{
"_comment": "Stage the site's printer drivers. Runs in-cycle because the share is only mounted then. Idempotent: a driver already present is skipped.",
"Name": "ShopDB printer drivers",
"Type": "PS1",
"Script": "scripts/Install-ShopdbPrinterDrivers.ps1",
"DetectionMethod": "Always"
}
```
**It must be a manifest entry, not its own scheduled task.** The SFLD share is
mounted only for the duration of the enforcement cycle; off-cycle the paths
simply do not exist and every run logs "package not found" forever.
## Azure Machine Configuration / DSC
The script answers a compliance question, which is what makes it a clean `Script`
resource: `-TestOnly` reports whether every driver in the manifest is present and
exits 0 or 1 without changing anything.
```powershell
Configuration ShopdbPrinterDrivers
{
Import-DscResource -ModuleName PSDesiredStateConfiguration
Node localhost
{
Script PrinterDrivers
{
GetScript = {
@{ Result = (Get-PrinterDriver | Select-Object -ExpandProperty Name) -join ', ' }
}
TestScript = {
$p = Start-Process -FilePath 'powershell.exe' -PassThru -Wait -WindowStyle Hidden `
-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',
'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1','-TestOnly'
return ($p.ExitCode -eq 0)
}
SetScript = {
Start-Process -FilePath 'powershell.exe' -Wait -WindowStyle Hidden `
-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',
'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1'
}
}
}
}
```
Deliver the package to `C:\ProgramData\ShopDB\Drivers` however that estate
already delivers files - a Win32 app, a File resource, or the imaging step.
## Intune
Package the folder as a Win32 app.
- Install: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-ShopdbPrinterDrivers.ps1`
- Detection: a script running the same file with `-TestOnly`, exit 0 = detected
- Run as SYSTEM. Adding a printer driver has required administrator rights since
the 2021 print hardening, and SYSTEM satisfies it.
## Why not have the assignment client fetch drivers
It was considered and rejected. A bay would then download a driver at the moment
a printer is assigned, which is the worst time: someone is waiting, the share may
be unmounted, and a 48 MB package would be pulled per bay per change. Staging the
set in `common` makes assignment a queue creation and nothing more.
## One driver per package, named exactly
`drivers.json` carries the driver name as its INF declares it - `Add-PrinterDriver`
matches that string and nothing else. The names verified on Windows for the
reference site's fleet:
| driver | covers |
|---|---|
| `HP Universal Printing PS` | HP office printers |
| `Xerox Global Print Driver PCL6` | Xerox office printers |
| `HP DesignJet T1700dr V4` | DesignJet plotters (a v4 class driver) |
| `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.

View File

@@ -0,0 +1,193 @@
# Install-ShopdbPrinterDriver.ps1
#
# Stages a printer driver into the Windows Driver Store and makes it available
# to the spooler, silently and offline. Deployable as a DSC Script resource, an
# Intune platform script, or a GE-Enforce manifest entry - it needs no user, no
# network beyond the driver source, and no vendor setup.exe.
#
# WHY NOT THE VENDOR INSTALLER: HP's and Xerox's universal drivers are ordinary
# INF driver packages. pnputil stages them without a UI, which is the only way
# this works on a locked bay with nobody logged in. The vendor bundles add a
# wizard and a service nobody wants.
#
# WHY IT IS SILENT: the signing certificate is added to Trusted Publishers first.
# Without that, pnputil prompts to trust the publisher and the install stalls
# forever behind a dialog no one will ever see. This mirrors the sequence the
# printer installer has used in production.
#
# IDEMPOTENT: if the spooler already has the driver by name, it does nothing.
# Safe to run every enforcement cycle.
#
# DRIVER NAME: -DriverName must be the name the INF declares, verbatim, e.g.
# 'HP Universal Printing PCL 6'. A near-miss fails at Add-PrinterDriver with an
# unhelpful error, which is why ShopDB stores the name rather than guessing it.
#
# SHARE PATHS: on a GE-Enforce site the driver source usually lives on the SFLD
# share, which is mounted ONLY during the enforcement cycle. Run this as a
# manifest entry inside that cycle, never as its own scheduled task - off-cycle
# the path is simply absent and this logs "source not reachable" forever.
#
# Exits 0 always. A driver that cannot be staged is logged, not thrown: a failed
# printer must never fail an enforcement run.
param(
# Exact driver name from the INF, e.g. 'Xerox Global Print Driver PCL6'.
[Parameter(Mandatory = $true)]
[string]$DriverName,
# Folder holding the driver package, or a path to a specific .inf.
# UNC or local. This is PrinterDriver.location in ShopDB.
[Parameter(Mandatory = $true)]
[string]$Source,
# Stage every .inf found under Source rather than picking one. Universal
# driver packages ship several INFs and the needed one is not always
# obvious; staging all of them is cheap and avoids guessing.
[switch]$AllInf,
[int]$TimeoutSec = 600
)
$ErrorActionPreference = 'Continue'
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
$logFile = Join-Path $logDir ('printer-drivers-{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
}
Log "=== Install printer driver: $DriverName ==="
# Already present: nothing to do. This is the common case on every cycle after
# the first, so it is checked before anything touches the share.
$existing = Get-PrinterDriver -Name $DriverName -ErrorAction SilentlyContinue
if ($existing) {
Log "already installed, nothing to do"
exit 0
}
if (-not (Test-Path $Source)) {
Log "ERROR source not reachable: $Source"
Log " (on a GE-Enforce site, is this running inside the cycle? the share is"
Log " mounted only for the duration of the run.)"
exit 0
}
# Collect the INFs to stage.
$infs = @()
if ((Get-Item $Source).PSIsContainer) {
$found = Get-ChildItem -Path $Source -Filter '*.inf' -Recurse -ErrorAction SilentlyContinue
if (-not $AllInf) {
# Prefer an INF whose name hints at the architecture in use; otherwise
# take them all. Staging a surplus INF costs disk, missing one costs a
# site visit.
$infs = @($found)
} else {
$infs = @($found)
}
} elseif ($Source -like '*.inf') {
$infs = @(Get-Item $Source)
}
if (-not $infs -or $infs.Count -eq 0) {
Log "ERROR no .inf found under $Source"
exit 0
}
Log "found $($infs.Count) inf file(s)"
# Trust the package's SIGNER FIRST, or pnputil refuses with "The publisher of an
# Authenticode(tm) signed catalog has not yet been established as trusted" - and
# on a bay with nobody logged in there is no prompt to answer, so the install
# simply never happens.
#
# The certificate is EXTRACTED from the catalog and added to Trusted Publishers.
# Adding the .cat file itself with certutil -addstore is not the same thing: it
# stores the catalog, not the publisher, and whether that satisfies pnputil
# varies by vendor. It worked for one universal driver and failed for another,
# which is a coin toss, not a mechanism.
#
# Every catalog under the source is trusted, not just the ones beside the first
# INF: a universal driver package holds several, and the one that matters is not
# predictably the first.
$cats = @(Get-ChildItem -Path $Source -Filter '*.cat' -Recurse -ErrorAction SilentlyContinue)
$trusted = 0
if ($cats.Count -gt 0) {
try {
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
'TrustedPublisher', 'LocalMachine')
$store.Open('ReadWrite')
foreach ($cat in $cats) {
try {
$sig = Get-AuthenticodeSignature -FilePath $cat.FullName -ErrorAction Stop
if ($sig -and $sig.SignerCertificate) {
$store.Add($sig.SignerCertificate)
$trusted++
} else {
Log "WARN no signer certificate on $($cat.Name)"
}
} catch {
Log "WARN could not trust $($cat.Name): $($_.Exception.Message)"
}
}
$store.Close()
} catch {
Log "WARN could not open the Trusted Publishers store: $($_.Exception.Message)"
}
}
Log "trusted $trusted of $($cats.Count) catalog(s)"
# Stage into the Driver Store. Deliberately WITHOUT /install: that runs a PnP
# device-match pass which is pointless for a network printer and slow across a
# universal driver's thousands of models. Add-PrinterDriver binds it afterwards.
$staged = $false
foreach ($inf in $infs) {
$null = & pnputil.exe /add-driver $inf.FullName 2>&1
# 259 = no more data (nothing new to add), 3010 = success, reboot queued.
if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 259 -or $LASTEXITCODE -eq 3010) {
$staged = $true
} else {
Log "WARN pnputil exit $LASTEXITCODE for $($inf.Name)"
}
}
if (-not $staged) {
Log "ERROR nothing staged from $Source"
exit 0
}
Log "staged into the driver store"
# Make it known to the spooler under the name ShopDB holds.
try {
Add-PrinterDriver -Name $DriverName -ErrorAction Stop
Log "installed: $DriverName"
} catch {
Log "ERROR Add-PrinterDriver failed for '$DriverName': $($_.Exception.Message)"
# Display names live in the INF's [Strings] section as token="Some Name",
# referenced elsewhere as %token%. Reading the model lines instead just
# reports the manufacturer, which is no help to whoever has to fix this.
Log " the name must match the INF verbatim. Names these packages offer:"
$offered = @()
foreach ($inf in $infs) {
$hits = Select-String -Path $inf.FullName -Encoding unicode `
-Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue
if (-not $hits) {
$hits = Select-String -Path $inf.FullName `
-Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue
}
foreach ($h in $hits) {
$value = $h.Matches[0].Groups[1].Value
# A driver name has a space in it; version strings and paths do not.
if ($value -match '^[A-Za-z].*\s') { $offered += $value }
}
}
foreach ($name in ($offered | Sort-Object -Unique | Select-Object -First 10)) {
Log " $name"
}
exit 0
}
exit 0

View File

@@ -0,0 +1,202 @@
# Install-ShopdbPrinterDrivers.ps1
#
# Installs a SITE'S WHOLE DRIVER SET from a manifest, so a bay ends up with every
# printer driver it might need in one converging run. Wraps
# Install-ShopdbPrinterDriver.ps1, which does one driver.
#
# DESIGNED FOR DSC / Intune / GE-Enforce. It declares state rather than
# performing an install: a driver already present is skipped, so this is safe to
# run on a schedule and cheap when there is nothing to do. That is what lets a
# DSC Script resource call it from TestScript as well as SetScript.
#
# THE MANIFEST, not arguments, is the contract. drivers.json lists each driver by
# the name its INF declares - what Add-PrinterDriver matches on, verbatim - and
# where its package lives. Paths are relative to this script, or absolute (a UNC
# path on a site's share is normal).
#
# EXIT CODE: 0 when every driver in the manifest is present at the end, 1 when
# one or more could not be installed. DSC needs a real answer here, unlike the
# single-driver script which never fails an enforcement run. The per-driver log
# says which and why.
#
# SHARE PATHS: on a GE-Enforce site the packages usually live on the SFLD share,
# which is mounted ONLY during the enforcement cycle. Run this as a manifest
# entry inside that cycle, not as its own scheduled task.
#
# WAVES, because a driver package is not a manifest check. GE-Enforce gives each
# PC a start offset of SHA256(hostname) % 5 MINUTES and then repeats every 5
# minutes, which was sized for reading a few KB of JSON. A driver set is 100 MB
# for the two universals and 226 MB for a full site, so the day an entry lands
# the whole fleet pulls it inside one 5-minute window: roughly 30 GB across 300
# bays, on the same share every bay needs for everything else. The failure is not
# slow drivers, it is a floor that stops converging.
#
# -WaveStart with -Waves spreads that out. Each PC derives its own wave from its
# hostname and does nothing until its turn, so there is no central coordination,
# no per-bay configuration and nothing to reconcile afterwards. The hash is the
# same idiom Register-GEEnforce.ps1 uses for its offset, SHA-256 rather than MD5
# because FIPS-enforced bays disable MD5 outright.
#
# A bay switched off during its wave installs on its next cycle instead. Late is
# a non-event here; the wave sets the earliest moment, not a deadline.
#
# Unparseable wave arguments FAIL CLOSED - nothing installs, and it says so every
# cycle. Failing open would restore precisely the stampede this exists to avoid,
# and 30 GB cannot be un-sent, whereas a typo that installs nothing is loud in
# the log and fixed in a minute.
param(
# Defaults to drivers.json beside this script.
[string]$Manifest = '',
# Install only these driver names; everything else in the manifest is
# ignored. For a bay that needs one driver out of a site-wide set.
[string[]]$Only = @(),
# Report what is missing and change nothing. This is what a DSC TestScript
# calls: exit 0 means compliant. A bay whose wave has not arrived is
# compliant BY DESIGN - not installed is its desired state today.
[switch]$TestOnly,
# Date the rollout opens, e.g. '2026-08-25' or '2026-08-25 22:00'. Empty
# means no gating at all, which is right for imaging time: a bay being built
# is one bay, and it should come off the line complete.
[string]$WaveStart = '',
# How many waves to spread the fleet across. 0 or 1 means everyone at once.
# 300 bays over 10 daily waves is ~30 bays and ~3 GB a day.
[int]$Waves = 0,
# Wave spacing. Hours finishes a 10-wave rollout inside a day; Days is the
# cautious setting when nobody is watching the share.
[ValidateSet('Days', 'Hours')]
[string]$WaveUnit = 'Days',
# Pilot bays skip the gate. Use this to prove one bay before the fleet.
[switch]$IgnoreWave
)
$ErrorActionPreference = 'Continue'
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
if (-not $Manifest) { $Manifest = Join-Path $here 'drivers.json' }
$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
function Log([string]$msg) {
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
"$ts [set] $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}
# The gate runs BEFORE the manifest is read, because the manifest lives on the
# share too. A bay that is not due this wave must not touch the share at all -
# reading it is cheap, but 300 bays deciding to read it in the same 5 minutes is
# how this whole problem starts.
if ($WaveStart -and -not $IgnoreWave) {
$start = [datetime]::MinValue
if (-not [datetime]::TryParse($WaveStart, [ref]$start)) {
Log "ERROR -WaveStart '$WaveStart' is not a date. Refusing to install."
Log ' (failing closed on purpose: guessing here would release the whole fleet at once)'
exit 1
}
$waveCount = $Waves
if ($waveCount -lt 1) { $waveCount = 1 }
# Same hostname hash as the enforcer's own start offset, so a bay's wave is
# stable for the life of its name: it cannot drift between cycles, and a
# rerun never moves a PC into a different wave.
$hostHash = [System.BitConverter]::ToUInt32(
[System.Security.Cryptography.SHA256]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes([System.Environment]::MachineName)), 0)
$wave = [int]($hostHash % [uint32]$waveCount)
if ($WaveUnit -eq 'Hours') { $due = $start.AddHours($wave) }
else { $due = $start.AddDays($wave) }
if ((Get-Date) -lt $due) {
Log ("wave {0} of {1} for {2}; not due until {3}. Nothing to do." -f `
$wave, $waveCount, [System.Environment]::MachineName,
$due.ToString('yyyy-MM-dd HH:mm'))
exit 0
}
Log ("wave {0} of {1} opened {2}; proceeding" -f `
$wave, $waveCount, $due.ToString('yyyy-MM-dd HH:mm'))
}
if (-not (Test-Path $Manifest)) {
Log "ERROR manifest not found: $Manifest"
exit 1
}
try {
$config = Get-Content -Raw -Path $Manifest | ConvertFrom-Json
} catch {
Log "ERROR manifest is not valid JSON: $($_.Exception.Message)"
exit 1
}
$wanted = @($config.drivers)
if ($Only.Count -gt 0) {
$wanted = @($wanted | Where-Object { $Only -contains $_.drivername })
}
if ($wanted.Count -eq 0) {
Log "nothing to do: the manifest selects no drivers"
exit 0
}
$single = Join-Path $here 'Install-ShopdbPrinterDriver.ps1'
if (-not (Test-Path $single)) {
Log "ERROR Install-ShopdbPrinterDriver.ps1 is not beside this script"
exit 1
}
$missing = @()
foreach ($driver in $wanted) {
$name = $driver.drivername
if (-not $name) { continue }
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
Log "present: $name"
continue
}
if ($TestOnly) {
Log "MISSING: $name"
$missing += $name
continue
}
# Relative paths are resolved against the package, so the whole thing can be
# copied anywhere - a share, C:\ProgramData, an Intune staging folder - and
# still find its own payloads.
$path = $driver.path
if ($path -and -not [System.IO.Path]::IsPathRooted($path)) {
$path = Join-Path $here $path
}
if (-not $path -or -not (Test-Path $path)) {
Log "ERROR package not found for '$name': $path"
$missing += $name
continue
}
Log "installing: $name"
& $single -DriverName $name -Source $path | Out-Null
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
Log "installed: $name"
} else {
Log "FAILED: $name (see the per-driver lines above)"
$missing += $name
}
}
if ($missing.Count -gt 0) {
Log ("not present: {0}" -f ($missing -join ', '))
exit 1
}
Log "all $($wanted.Count) driver(s) present"
exit 0

View File

@@ -0,0 +1,314 @@
# Report-PrintersToShopDB.ps1
#
# Reports the print queues this PC ACTUALLY has to ShopDB, so the register can
# be compared against what the bay is SUPPOSED to have. ShopDB knows the
# assignment (GET /api/printers/for-host/<hostname>, applied by
# Set-ShopdbPrinters.ps1); it has never known what is really installed. This is
# that missing half.
#
# TARGET: the ADR-006 collector API.
# POST <shopdb>/api/collector/printers
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
# which Install-GEEnforce.ps1 provisions and the enforcement client already
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
# product code is a defect, and this script ships to every site.
#
# READ ONLY. It calls nothing that creates, changes or removes a queue, a port
# or a driver - only Get-*. Convergence is Set-ShopdbPrinters.ps1's job and
# stays there; a reporter that also fixes things cannot be trusted to tell you
# what was broken.
#
# OBSERVED IS NOT ASSIGNED. The server stores this in its own table and never
# turns it into an assignment on its own. A drifted bay reporting its drift must
# not be able to redefine what correct means.
#
# THE LATEST REPORT REPLACES THE PREVIOUS ONE for this hostname, which makes an
# empty queues list a legitimate "this bay has no printers" and wipes the host's
# observed rows. So a FAILED enumeration must send NOTHING rather than an empty
# list - see the $enumerated flag below. Reporting nothing loses one cycle;
# reporting [] after a WMI hiccup deletes real state and reads as a bay that
# lost its printers.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (the same secret store Report-AssetToShopDB.ps1 uses; provisioned
# at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake
# the key into the manifest JSON on the share.
#
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
# the SYSTEM task. Always exits 0 so a printer problem never fails an
# enforcement run; failures are logged, never thrown.
param(
# Flask collector endpoint for the printers plugin. Empty resolves from
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
[string]$ApiUrl = '',
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
# store in the registry. Override with -ApiKey via Args for testing.
[string]$ApiKey = '',
# Identity field of the payload. Defaults to this machine's name, which is
# what the assignment side (for-host) and the computers collector both key on.
[string]$Hostname = $env:COMPUTERNAME,
[int]$TimeoutSec = 30,
# Enumerate and log the payload, post nothing. For proving what a bay would
# report before a site is pointed at a live server.
[switch]$WhatIfOnly
)
$ErrorActionPreference = 'Continue'
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
# protocol the site rejects; the collector POST is HTTPS.
[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 ('report-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
}
$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')
function Get-ShopdbRegValue([string]$name) {
foreach ($path in $REGPATHS) {
try {
if (Test-Path $path) {
$value = [string](Get-ItemProperty -Path $path -Name $name -ErrorAction Stop).$name
if ($value -and $value.Trim()) { return $value.Trim() }
}
} catch {}
}
return ''
}
Log "=== Report printers to ShopDB (collector) : $Hostname ==="
# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
if (-not $ApiUrl) {
$base = Get-ShopdbRegValue 'BaseUrl'
if ($base) { $ApiUrl = $base.TrimEnd('/') + '/api/collector/printers' }
}
if (-not $ApiUrl -and -not $WhatIfOnly) {
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
exit 0
}
# collector key from the GE-Enforce secret store when not passed via Args.
if (-not $ApiKey) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' }
if (-not $ApiKey -and -not $WhatIfOnly) {
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
exit 0
}
# Queues that are not devices: the Windows-supplied virtual printers plus the
# Office writers. They exist on every image, match nothing in the register, and
# would each land as an UNKNOWN row on every bay in the fleet.
#
# Matched on the PORT, not the queue name, because the name is whatever a user
# renamed it to while the port of a virtual device is fixed. Two of them are
# matched on driver as well, since a redirected-port queue can share PORTPROMPT.
$VIRTUALPORTS = @('PORTPROMPT:', 'SHRFAX:', 'XPSPort:', 'nul:', 'NUL:')
$VIRTUALDRIVERS = @(
'Microsoft XPS Document Writer',
'Microsoft XPS Document Writer v4',
'Microsoft Print To PDF',
'Microsoft Shared Fax Driver',
'Send to Microsoft OneNote Driver',
'Microsoft Software Printer Driver'
)
function Test-VirtualQueue([string]$portname, [string]$drivername) {
foreach ($p in $VIRTUALPORTS) {
if ($portname -and $portname.Trim().ToLower() -eq $p.ToLower()) { return $true }
}
# OneNote's port is a per-install GUID path, so it can only be caught here.
if ($portname -and $portname -like 'Microsoft.Office.OneNote*') { return $true }
foreach ($d in $VIRTUALDRIVERS) {
if ($drivername -and $drivername.Trim().ToLower() -eq $d.ToLower()) { return $true }
}
return $false
}
# Port address is the primary match key server-side: an IP or FQDN is
# unambiguous where a queue name is a local habit. Built once as a lookup so a
# bay with 8 queues does not re-enumerate ports 8 times.
#
# A port with no host address (USB, WSD, a redirected port) reports a null
# address and matches on name alone, which is correct: a locally attached
# printer is still a real printer worth seeing.
$portAddresses = @{}
$portsRead = $false
try {
foreach ($port in (Get-PrinterPort -ErrorAction Stop)) {
$address = ''
if ($port.PSObject.Properties['PrinterHostAddress']) {
$address = [string]$port.PrinterHostAddress
}
if ($port.Name) { $portAddresses[[string]$port.Name] = $address.Trim() }
}
$portsRead = $true
} catch {
Log "WARN Get-PrinterPort failed, falling back to WMI ports: $($_.Exception.Message)"
}
if (-not $portsRead) {
# PS 5.1-era hosts without the PrintManagement module, and images where the
# spooler cmdlets are broken but WMI still answers.
try {
foreach ($port in (Get-CimInstance -ClassName Win32_TCPIPPrinterPort -ErrorAction Stop)) {
if ($port.Name) { $portAddresses[[string]$port.Name] = ([string]$port.HostAddress).Trim() }
}
$portsRead = $true
} catch {
# Not fatal: queues still report, just without an address to match on.
Log "WARN could not read printer ports at all: $($_.Exception.Message)"
}
}
# The queues themselves. $enumerated stays false unless a read actually
# succeeded, because "no queues" and "could not look" are the same empty list
# and the server treats them very differently (see the header).
$queues = @()
$enumerated = $false
try {
foreach ($printer in (Get-Printer -ErrorAction Stop)) {
$queues += [pscustomobject]@{
queuename = [string]$printer.Name
drivername = [string]$printer.DriverName
portname = [string]$printer.PortName
}
}
$enumerated = $true
} catch {
Log "WARN Get-Printer failed, falling back to WMI queues: $($_.Exception.Message)"
}
if (-not $enumerated) {
try {
foreach ($printer in (Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop)) {
$queues += [pscustomobject]@{
queuename = [string]$printer.Name
drivername = [string]$printer.DriverName
portname = [string]$printer.PortName
}
}
$enumerated = $true
} catch {
Log "ERROR could not enumerate printers: $($_.Exception.Message)"
}
}
if (-not $enumerated) {
# Deliberately posts nothing. An empty report REPLACES this host's observed
# rows, so a failed read must not be able to claim the bay has no printers.
Log 'ERROR enumeration failed; posting NOTHING so the last good report stands.'
exit 0
}
# Which queue the interactive user actually prints to. This process is SYSTEM,
# and the default printer is per user, so Win32_Printer.Default here describes
# the SYSTEM session and is usually wrong. Read the console user's own value
# first: HKU\<sid>\...\Windows Device holds "<queue>,winspool,<port>".
$defaultName = ''
try {
$consoleUser = [string](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
if ($consoleUser) {
$sid = (New-Object System.Security.Principal.NTAccount($consoleUser)).Translate(
[System.Security.Principal.SecurityIdentifier]).Value
$devicePath = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows NT\CurrentVersion\Windows"
$device = [string](Get-ItemProperty -Path $devicePath -Name Device -ErrorAction Stop).Device
if ($device) { $defaultName = ($device -split ',')[0].Trim() }
if ($defaultName) { Log "default for $consoleUser : $defaultName" }
}
} catch {
# Nobody logged on, a roaming hive not loaded, or a name that will not
# translate. Not worth a warning every cycle on an unattended bay.
}
if (-not $defaultName) {
# Falls back to whatever this session sees. Marked in the log because a
# SYSTEM-session default is weak evidence and a reviewer should know which
# one they are looking at before seeding an assignment from it.
try {
$sysDefault = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop |
Where-Object { $_.Default } | Select-Object -First 1
if ($sysDefault) {
$defaultName = [string]$sysDefault.Name
Log "default from the SYSTEM session (no console user): $defaultName"
}
} catch {}
}
$reported = @()
$skipped = 0
foreach ($queue in $queues) {
if (-not $queue.queuename) { continue }
if (Test-VirtualQueue $queue.portname $queue.drivername) { $skipped++; continue }
$portAddress = ''
if ($queue.portname -and $portAddresses.ContainsKey($queue.portname)) {
$portAddress = [string]$portAddresses[$queue.portname]
}
$row = @{
queuename = $queue.queuename
isdefault = ($defaultName -and $queue.queuename -eq $defaultName)
}
# Sent only when present: a null is "not known", and an empty string would
# read as a driver or a port genuinely named nothing.
if ($queue.drivername) { $row['drivername'] = $queue.drivername }
if ($queue.portname) { $row['portname'] = $queue.portname }
if ($portAddress) { $row['portaddress'] = $portAddress }
$reported += $row
Log ("queue: {0} | driver={1} | port={2} | address={3} | default={4}" -f `
$queue.queuename, $queue.drivername, $queue.portname, $portAddress, $row['isdefault'])
}
Log "reporting $($reported.Count) queue(s), $skipped virtual queue(s) skipped"
if ($reported.Count -eq 0) {
# Legitimate and meaningful: it clears this host's observed rows so the
# comparison shows every assigned printer as missing, which is exactly what
# a bay with no queues is.
Log 'no real queues on this host; reporting an empty set (clears observed state)'
}
# Collector schema fields (lowercase concatenated). hostname is the identity
# field. observedat is sent for the record and named to match the declared
# collector schema; the server stamps its own and ignores this one.
$body = @{
hostname = $Hostname
queues = @($reported)
observedat = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
}
# Depth 4 covers hostname -> queues -> row -> value; the default of 2 flattens
# the rows to type names.
$json = $body | ConvertTo-Json -Compress -Depth 4
if ($WhatIfOnly) {
Log "WOULD POST $ApiUrl $json"
exit 0
}
Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count)
try {
$response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $ApiKey } `
-TimeoutSec $TimeoutSec -ErrorAction Stop
Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4))
} catch {
Log "ERROR POST failed: $($_.Exception.Message)"
}
exit 0

View File

@@ -0,0 +1,379 @@
# Set-ShopdbPrinters.ps1
#
# Makes this PC's printers match what ShopDB says the bay should have. Asks
# GET /api/printers/for-host/<hostname> 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.
#
# IT ALSO REGISTERS THAT LOGON TASK, and stages a LOCAL copy of
# Apply-ShopdbDefaultPrinter.ps1 for it to run. Recording a default that nothing
# ever applies was the gap: the queues appeared, the default never moved. The
# local copy is not tidiness - the share this script runs from is mounted only
# for the enforcement cycle, and the task fires at logon when it is gone.
#
# 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,
# Where the per-user logon script is staged. Anywhere is fine as long as it
# is on this PC and every user can read it.
[string]$LocalScriptDir = (Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'ShopDB'),
[string]$LogonTaskName = 'ShopDB default printer',
# The task runs as a GROUP, not a person: a shared bay has no one owner and
# the default must be applied for whoever logs on. If this name does not
# resolve - it is localised on non-English Windows - the well-known SID is
# tried instead.
[string]$UsersGroup = 'BUILTIN\Users',
# 0 means at logon only. A shared bay where people pick their own default
# can be pulled back on a repeat; a single-user PC should not be, so the
# neutral default is the one that does not argue with the user.
[int]$RepeatMinutes = 0,
# For a site that deploys the logon task by GPO instead.
[switch]$NoLogonTask,
# 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
}
# Resolved once, at script scope: $PSScriptRoot is empty when the file is piped
# into powershell rather than run by path, and the logon script sits beside this
# one.
$SCRIPTDIR = $PSScriptRoot
if (-not $SCRIPTDIR -and $MyInvocation.MyCommand.Path) {
$SCRIPTDIR = Split-Path -Parent $MyInvocation.MyCommand.Path
}
function Ensure-LogonTask {
# Half of this feature is per-user state that SYSTEM cannot write. All SYSTEM
# can do is arrange for something to run AS the user later, which is this
# task. Nothing else registered it, so the default was recorded every cycle
# and applied never.
if ($NoLogonTask) {
Log 'logon task: skipped (-NoLogonTask)'
return
}
$source = ''
if ($SCRIPTDIR) { $source = Join-Path $SCRIPTDIR 'Apply-ShopdbDefaultPrinter.ps1' }
if (-not $source -or -not (Test-Path $source)) {
Log "SKIP logon task: Apply-ShopdbDefaultPrinter.ps1 is not beside this script"
return
}
# THE LOCAL COPY IS LOAD-BEARING. This script runs from a share that is
# mounted only for the enforcement cycle; the task fires at logon, when the
# share is gone. A task pointing at the share never runs and says nothing.
$localscript = Join-Path $LocalScriptDir 'Apply-ShopdbDefaultPrinter.ps1'
$refreshed = $false
try {
if (-not (Test-Path $LocalScriptDir)) {
# Inherited ACL is what is wanted here: every user can read it, only
# admins can write it, so the task cannot be pointed somewhere else.
New-Item -ItemType Directory -Path $LocalScriptDir -Force -ErrorAction Stop | Out-Null
}
$stale = $true
if (Test-Path $localscript) {
$stale = (Get-FileHash -Path $localscript -Algorithm SHA256).Hash -ne
(Get-FileHash -Path $source -Algorithm SHA256).Hash
}
if ($stale) {
if ($WhatIfOnly) {
Log "WOULD stage the logon script at $localscript"
} else {
Copy-Item -Path $source -Destination $localscript -Force -ErrorAction Stop
$refreshed = $true
Log "staged the logon script at $localscript"
}
}
} catch {
# No local copy means no task worth registering - a task pointing at a
# file that is not there is worse than no task, because it looks fine.
Log "ERROR staging ${localscript}: $($_.Exception.Message)"
return
}
$arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$localscript`""
$task = Get-ScheduledTask -TaskName $LogonTaskName -ErrorAction SilentlyContinue
if ($task -and -not $refreshed) {
# Re-registering every cycle throws away the task's run history, which
# is the only evidence it ever fired. So it is replaced only when it
# points somewhere other than the local copy, or has no group principal
# - a task left behind running as one person applies one person's
# default. Matched on the PATH rather than the whole argument string
# because Task Scheduler is free to normalise quoting, and an exact
# compare would churn over a difference that changes nothing.
$registered = @($task.Actions)[0]
$pointslocal = $registered -and $registered.Arguments -and
$registered.Arguments.IndexOf($localscript, [StringComparison]::OrdinalIgnoreCase) -ge 0
if ($pointslocal -and $task.Principal.GroupId) {
Log "logon task present: $LogonTaskName"
return
}
}
if ($WhatIfOnly) {
Log "WOULD register the logon task: $LogonTaskName -> $localscript"
return
}
try {
$triggers = @(New-ScheduledTaskTrigger -AtLogOn)
if ($RepeatMinutes -gt 0) {
$triggers += New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes $RepeatMinutes)
}
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -StartWhenAvailable
} catch {
# An SKU without the ScheduledTasks module is the likely reason. Nothing
# to register with, and still not a reason to fail the run.
Log "ERROR building the logon task: $($_.Exception.Message)"
return
}
# Limited, not Highest: setting your own default printer needs no elevation,
# and a task the whole Users group can trigger should not have any.
$candidates = @($UsersGroup)
if ($UsersGroup -ne 'S-1-5-32-545') { $candidates += 'S-1-5-32-545' }
$lasterror = 'no principal accepted'
foreach ($groupid in $candidates) {
try {
$principal = New-ScheduledTaskPrincipal -GroupId $groupid -RunLevel Limited -ErrorAction Stop
Register-ScheduledTask -TaskName $LogonTaskName -Action $action -Trigger $triggers `
-Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
Log "registered the logon task: $LogonTaskName as $groupid"
return
} catch {
$lasterror = $_.Exception.Message
}
}
# A missing logon task means the default is not applied. It does not mean the
# queues are wrong, so it is logged and the run carries on.
Log "ERROR registering ${LogonTaskName}: $lasterror"
}
$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 ==="
# Before the API call on purpose: the task depends on files on this PC, not on
# the server. A bad minute from the API must not leave a bay with no way to apply
# the default it was already told about.
Ensure-LogonTask
$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)"
function Ensure-Port([string]$address) {
$portname = 'IP_' + $address
if (-not (Get-PrinterPort -Name $portname -ErrorAction SilentlyContinue)) {
Add-PrinterPort -Name $portname -PrinterHostAddress $address -ErrorAction Stop
Log "port: $portname"
}
return $portname
}
function Repair-Queue($queue, [string]$address, [string]$drivername) {
$name = $queue.Name
# The address ShopDB holds is the truth about where the printer IS. A queue
# left pointing at the old address prints into the void, and looks fine.
if ($address) {
$wantedport = 'IP_' + $address
if ($queue.PortName -ne $wantedport) {
if ($WhatIfOnly) {
Log "WOULD repoint $name : $($queue.PortName) -> $wantedport"
} else {
try {
$portname = Ensure-Port $address
Set-Printer -Name $name -PortName $portname -ErrorAction Stop
Log "repointed $name : $($queue.PortName) -> $portname"
} catch {
Log "ERROR repointing ${name}: $($_.Exception.Message)"
}
}
}
}
# A queue built on a driver the site has moved off keeps using it forever.
# Only corrected when the wanted driver is actually staged - swapping a queue
# onto a driver that is not installed would break a working printer.
if ($drivername -and $queue.DriverName -ne $drivername) {
if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) {
Log "SKIP driver fix for $name : '$drivername' is not staged"
} elseif ($WhatIfOnly) {
Log "WOULD re-driver $name : $($queue.DriverName) -> $drivername"
} else {
try {
Set-Printer -Name $name -DriverName $drivername -ErrorAction Stop
Log "re-drivered $name : $($queue.DriverName) -> $drivername"
} catch {
Log "ERROR re-drivering ${name}: $($_.Exception.Message)"
}
}
}
if ($queue.PortName -eq ('IP_' + $address) -and
($drivername -eq '' -or $queue.DriverName -eq $drivername)) {
Log "present: $name"
}
}
$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 }
$address = $printer.hostname
if (-not $address) { $address = $printer.ipaddress }
$drivername = $printer.drivername
if ($existing.ContainsKey($name)) {
# A queue with the right NAME can still be wrong: pointing at a printer
# that has moved, or built on a driver that has since been replaced.
# Absence used to be the only thing fixed, so a bay with a stale queue
# looked converged and printed to the wrong device.
#
# Corrected IN PLACE with Set-Printer, never removed and recreated: the
# queue keeps its name, its sharing, its permissions, and whoever has it
# as their default keeps it.
Repair-Queue $existing[$name] $address $drivername
continue
}
if (-not $address) {
Log "SKIP $name : no hostname or IP to point a port at"
continue
}
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
}
try {
$portname = Ensure-Port $address
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. The logon task registered above runs
# Apply-ShopdbDefaultPrinter.ps1, which reads this value in the user's context.
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

View File

@@ -0,0 +1,35 @@
{
"_comment": "Driver set for a site. Each entry names a driver EXACTLY as its INF declares it (what Add-PrinterDriver matches on) and where its package lives, relative to the package root or as an absolute UNC path. Copy to drivers.json and edit for the site.",
"drivers": [
{
"drivername": "HP Universal Printing PS",
"path": "drivers/hp_upd_ps",
"covers": "HP office printers (universal)"
},
{
"drivername": "Xerox Global Print Driver PCL6",
"path": "drivers/xerox_gpd",
"covers": "Xerox office printers (universal)"
},
{
"drivername": "HP DesignJet T1700dr V4",
"path": "drivers/hp_designjet",
"covers": "DesignJet T1700 / T1700dr plotters"
},
{
"drivername": "ZDesigner ZT411-300dpi ZPL",
"path": "drivers/zebra_zt411",
"covers": "Zebra ZT411 label printers"
},
{
"drivername": "EPSON TM-C3500",
"path": "drivers/epson_tmc3500",
"covers": "Epson ColorWorks C3500 label printers"
},
{
"drivername": "DTC4500e Card Printer",
"path": "drivers/hid_dtc4500e",
"covers": "HID FARGO DTC4500e card printer"
}
]
}

View File

@@ -169,6 +169,7 @@
v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx"
:top="printer.mapy"
:levelid="printer.levelid"
:machineName="printer.name || printer.assetnumber"
>
<span class="location-link">View on Map</span>

View File

@@ -43,14 +43,13 @@
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
>
<template v-if="page[pos - 1]">
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
<div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
<div class="qr-container">
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
<div class="info-inner">
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
<div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
</div>
</div>
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const printers = ref([])
const selectedPrinters = ref([])
@@ -91,6 +91,7 @@ const pages = computed(() => {
onMounted(async () => {
try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
// listAll: perpage is clamped to 100, and a batch sheet must cover every
// printer, not the first page of them.
printers.value = await printersApi.listAll()
@@ -129,6 +130,29 @@ async function generateQRCodes() {
qrImages.value = next
}
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
// from the IP through the printer_hostname_template setting (ADR-015), which is
// how PrinterForm and the toner report derive it.
const hostnameTemplate = ref('')
function fqdnFor(item) {
const stored = item?.printer?.hostname
if (stored) return stored
const ip = getIp(item)
if (!ip || !hostnameTemplate.value) return ''
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
}
// What goes on the label's prominent top line: the printer's NAME, e.g.
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
// queue name when one is set, otherwise the asset's name, and in practice most
// printers carry it as the assetnumber and nothing else, so that is the last
// fallback rather than a blank label. This is a name, not an identifier line:
// the label deliberately carries no separate "Asset #".
function labelName(item) {
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
}
function displayName(printer) {
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
}
@@ -286,7 +310,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
.empty-label { color: #999; font-size: 14px; }
@media print {

View File

@@ -24,14 +24,13 @@
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
>
<template v-if="pos === parseInt(position)">
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
<div class="csf-name">{{ labelName }}</div>
<div class="qr-container">
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
</div>
<div class="info-section">
<div class="csf-name">{{ printer.assetnumber }}</div>
<div class="info-inner">
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
<div v-if="fqdn" class="info-row">{{ fqdn }}</div>
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
</div>
</div>
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
import { printersApi } from '@/api'
import { renderQrDataUrl } from '@/utils/codes'
import { buildQrUrl } from '@/utils/qrTarget'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
const route = useRoute()
const loading = ref(true)
@@ -58,6 +58,18 @@ const position = ref('1')
// in print output, images print every time.
const qrImage = ref('')
// What goes on the label's prominent top line: the printer's NAME, e.g.
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
// queue name when one is set, otherwise the asset's name, and in practice most
// printers carry it as the assetnumber and nothing else, so that is the last
// fallback rather than a blank label. This is a name, not an identifier line:
// the label deliberately carries no separate "Asset #".
const labelName = computed(() =>
printer.value?.printer?.windowsname
|| printer.value?.name
|| printer.value?.assetnumber
|| '')
const ipAddress = computed(() => {
// Check direct ipaddress field first (from list API)
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
@@ -67,8 +79,21 @@ const ipAddress = computed(() => {
return primary?.ipaddress || primary?.address || null
})
// The printer's FQDN. A stored hostname wins; otherwise the site builds one from
// the IP through the printer_hostname_template setting (ADR-015), the same way
// PrinterForm and the toner report derive it.
const hostnameTemplate = ref('')
const fqdn = computed(() => {
const stored = printer.value?.printer?.hostname
if (stored) return stored
if (!ipAddress.value || !hostnameTemplate.value) return ''
return hostnameTemplate.value.replace('{ip}', ipAddress.value.replace(/\./g, '-'))
})
onMounted(async () => {
try {
hostnameTemplate.value = await getPrinterHostnameTemplate()
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
@@ -169,7 +194,7 @@ function print() {
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
.info-inner { text-align: left; }
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
@media print {
/* Force rendered images/colors to print even when "Background graphics" is

View File

@@ -66,6 +66,7 @@
v-if="printer.mapx != null && printer.mapy != null"
:left="printer.mapx"
:top="printer.mapy"
:levelid="printer.levelid"
:machineName="printer.printername || printer.assetnumber"
>
<router-link :to="`/printers/${printer.printerid}`">

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')

View File

@@ -9,6 +9,7 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping
CAPACITY_TIERS,
)
from .supply_alert import PrinterSupplyAlert # per-printer toner alert state
from .printer_observation import PrinterObservedQueue # observed queues per bay
__all__ = [
'Printer',
@@ -16,6 +17,7 @@ __all__ = [
'PrinterDriver',
'ModelSupply',
'PrinterSupplyAlert',
'PrinterObservedQueue',
'SUPPLY_TYPES',
'SUPPLY_COLORS',
'CAPACITY_TIERS',

View File

@@ -11,6 +11,15 @@ class PrinterDriver(db.Model):
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
location = db.Column(db.String(500), nullable=False)
description = db.Column(db.Text)
# 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,
@@ -27,6 +36,8 @@ class PrinterDriver(db.Model):
'name': self.name,
'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),

View File

@@ -0,0 +1,109 @@
"""What a bay actually HAS: one row per printer queue a PC reported.
This is the observed half of the printer loop. The assigned half already exists
as usesprinter/defaultprinter relationship rows on the machine, and the two are
kept apart on purpose: the moment a drifted bay's observed state is allowed to
write assignment rows, enforcement stops meaning anything. Nothing in this table
is desired state, and no code may promote it to desired state without a person
asking for that explicitly.
Current state, not history. The latest report for a host REPLACES every row that
host had before, so "what does this bay have" is a plain filter and never a
question about time. An append-only table would grow with every GE-Enforce cycle
and answer that question wrong. The audit log already records each ingest, which
is where the history lives.
Rows are keyed by hostname as reported, with assetid as a resolved convenience:
a bay can report before anyone creates its computer record, and the report must
still land. Matching an observed queue back to a ShopDB printer asset happens at
READ time (port address first, then queue name) so a printer added tomorrow
matches without the bay re-reporting.
Replacement is a hard DELETE of the host's rows, not a soft one: the inherited
isactive flag is not a soft-delete marker here, because a queue that is gone
from the bay is not observed state that has been retired, it is state that was
never observed again.
"""
from shopdb.api import db, BaseModel
class PrinterObservedQueue(BaseModel):
"""One Windows print queue seen on one reporting PC at one point in time."""
__tablename__ = 'printerobservedqueues'
printerobservedqueueid = db.Column(db.Integer, primary_key=True)
# The reporting PC, resolved at ingest. Nullable because an unenrolled bay
# still gets to report, and no backref: core assets must not grow a
# dependency on this plugin.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=True,
comment='Reporting PC asset, resolved from hostname at ingest',
)
asset = db.relationship('Asset', lazy='select', viewonly=True)
# Authoritative identity of the report, stored as sent. assetid can be null
# or can go stale after a rename; hostname is what the replace keys on.
hostname = db.Column(
db.String(255),
nullable=False,
comment='Reporting PC hostname, as sent by the collector',
)
queuename = db.Column(
db.String(255),
nullable=False,
comment='Windows printer (queue) name',
)
drivername = db.Column(
db.String(255),
comment='Windows driver name, verbatim; comparable to printerdrivers.drivername',
)
portname = db.Column(
db.String(255),
comment='Windows port name, freeform',
)
# Primary match key: an IP or FQDN identifies a device unambiguously, where
# a queue name is only ever a convention. Null for non-TCP/IP ports.
portaddress = db.Column(
db.String(255),
comment='Host address the port points at (IP or FQDN)',
)
isdefault = db.Column(
db.Boolean,
nullable=False,
default=False,
comment='Was the default queue for the reporting context',
)
isshared = db.Column(
db.Boolean,
nullable=False,
default=False,
comment='Queue is shared off this PC',
)
# Server-stamped once per report, so every row of one report carries the
# same value and "when did this bay last report" needs no aggregate.
observedat = db.Column(
db.DateTime,
nullable=False,
comment='When the report that produced this row was ingested',
)
__table_args__ = (
# Windows queue names are unique per host, so this doubles as the read
# index for the hostname filter and turns a botched partial replace into
# an IntegrityError instead of silent duplicate queues.
db.UniqueConstraint('hostname', 'queuename',
name='uq_printerobservedqueue_host_queue'),
db.Index('idx_printerobservedqueues_portaddress', 'portaddress'),
db.Index('idx_printerobservedqueues_assetid', 'assetid'),
)
def __repr__(self):
return f"<PrinterObservedQueue {self.hostname}:{self.queuename}>"

View File

@@ -3,6 +3,8 @@
import json
import logging
from pathlib import Path
import re
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
@@ -12,13 +14,49 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .models import (
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert,
PrinterObservedQueue
)
from .api import printers_asset_bp
from .services import ZabbixService
logger = logging.getLogger(__name__)
# Widths of the text columns on printerobservedqueues. A Windows queue name
# tops out well below this, but a report is unattended machine input: one
# oversized string must not turn into a 500 the bay retries every cycle.
OBSERVEDTEXTLIMIT = 255
def _observed_text(value, fieldname, warnings):
"""Trim one reported string to what the column holds, or None if blank."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if len(text) > OBSERVEDTEXTLIMIT:
warnings.append('truncated {} longer than {} characters'.format(
fieldname, OBSERVEDTEXTLIMIT))
text = text[:OBSERVEDTEXTLIMIT]
return text
def _observed_bool(value):
"""Coerce a reported flag to bool.
PowerShell's ConvertTo-Json emits real booleans, but hand-built payloads
and older clients send 'True'/'true'/1, and a bare truthiness test would
read the string 'False' as a default printer.
"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
return value.strip().lower() in ('true', '1', 'yes')
return False
class PrintersPlugin(BasePlugin):
"""
@@ -79,6 +117,7 @@ class PrintersPlugin(BasePlugin):
PrinterDriver, # driver links (SMB/HTTP)
ModelSupply, # model -> toner/drum/waste part numbers
PrinterSupplyAlert, # per-printer toner alert crossing state
PrinterObservedQueue, # what a bay reports it ACTUALLY has
]
def get_services(self) -> Dict[str, Type]:
@@ -109,11 +148,16 @@ class PrintersPlugin(BasePlugin):
logger.info("Printers plugin installed")
def get_settings_defaults(self) -> List[dict]:
"""Low-toner alert settings.
"""Low-toner alert settings plus the dashboard threshold.
The framework seeds these at install, at enable, and on every
`flask plugin upgrade-all`, so a key added in a later version reaches a
site that installed an earlier one.
ONE definition only. There were two, and the later one shadowed this
list outright: every alert setting below was silently never declared,
so the alerts settings page wrote keys the plugin did not own and a new
site seeded none of them. Add keys here; do not add a second method.
"""
return [
{
@@ -164,6 +208,16 @@ class PrintersPlugin(BasePlugin):
'description': 'Toner percent remaining at or below which a '
'critical email fires',
},
{
'key': 'printers_dashboardpercent',
'value': '5',
'valuetype': 'integer',
'category': 'printers',
'description': 'Supply percentage at or below which a printer '
'appears on the dashboard. Tighter than the '
'low-supplies report, which is for planning an '
'order rather than walking out to change one.',
},
]
def _ensure_asset_type(self) -> None:
@@ -286,21 +340,6 @@ class PrintersPlugin(BasePlugin):
return [printerscli]
def get_settings_defaults(self) -> List[Dict]:
"""Settings this plugin owns for the dashboard card."""
return [
{
'key': 'printers_dashboardpercent',
'value': '5',
'valuetype': 'integer',
'category': 'printers',
'description': 'Supply percentage at or below which a printer '
'appears on the dashboard. Tighter than the '
'low-supplies report, which is for planning an '
'order rather than walking out to change one.',
},
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard card: printers needing a cartridge.
@@ -379,3 +418,258 @@ class PrintersPlugin(BasePlugin):
('printers.edit', 'Edit printers', 'printers'),
('printers.delete', 'Delete printers', 'printers'),
]
# ---- ADR-006 collector contract -------------------------------------
def get_collector_schema(self) -> Optional[dict]:
"""What a bay reports it ACTUALLY has (POST /api/collector/printers).
The observed half of the printer story. ShopDB already knows what a
host SHOULD have (/api/printers/for-host); this is what enumerating the
host found, kept apart from the assignment so drift stays visible.
Declaring this schema is what registers the endpoint - the dispatcher
in shopdb/core/api/collector.py discovers it, and brings the collector
key / managed-token auth and the audit row with it.
"""
return {
'identityfield': 'hostname',
'fields': {
'type': 'object',
'required': ['hostname', 'queues'],
'properties': {
'hostname': {
'type': 'string',
'description': 'Reporting PC (COMPUTERNAME or its '
'FQDN). The identity of the report: '
'the PC asset is resolved from it, but '
'the rows are keyed by the name, so an '
'unenrolled bay still reports.',
},
'queues': {
'type': 'array',
'description': "Every real print queue on the host. "
"This REPLACES the host's previous set, "
"so an empty array is a valid report "
"that clears it. A client whose "
"enumeration FAILED must send nothing "
"at all - never an empty array.",
'items': {
'type': 'object',
'required': ['queuename'],
'properties': {
'queuename': {
'type': 'string',
'description': 'Windows printer name.',
},
'drivername': {
'type': 'string',
'description': 'Driver name verbatim, as '
'the INF spells it.',
},
'portname': {
'type': 'string',
'description': 'Windows port name.',
},
'portaddress': {
'type': 'string',
'description': 'PrinterHostAddress of a '
'TCP/IP port - an IP or '
'FQDN. The primary key for '
'matching this queue to a '
'printer asset; omit it for '
'a non-TCP port.',
},
'isdefault': {
'type': 'boolean',
'description': 'True on the one queue that '
'is the default printer.',
},
'isshared': {
'type': 'boolean',
'description': 'True when the queue is '
'shared off this PC.',
},
},
},
},
'observedat': {
'type': 'string',
'format': 'date-time',
'description': 'Accepted and ignored. The server stamps '
'observedat at ingest, so a bay with a '
'wrong clock cannot report itself fresh '
'or stale.',
},
},
},
}
def apply_collector_payload(self, payload: dict) -> dict:
"""Replace one host's observed queue set (ADR-006).
THIS NEVER WRITES AN ASSIGNMENT. Observed and assigned are separate
tables on purpose: the moment a drifted bay's report is allowed to
become what that bay is told to install, enforcement stops meaning
anything.
Seeding an assignment from observed state is a human action through
PUT /api/printers/assignments/for-asset/<id>.
Replace, not append: this is current state, so the latest report is the
whole truth for that host. Nothing is matched to a printer asset here
either - resolution happens at read time, so a printer added to ShopDB
tomorrow matches yesterday's report without the bay reporting again.
"""
from datetime import datetime, timezone
warnings = []
hostname = (payload.get('hostname') or '').strip()
if not hostname:
raise ValueError('hostname is required')
queues = payload.get('queues')
if queues is None:
# Absent and empty are NOT the same thing. [] is a host that
# genuinely has no queues and clears its rows; a missing key is a
# malformed report, and treating it as a wipe would let one client
# bug erase the observed state of the fleet host by host.
raise ValueError('queues is required; send an empty array for a '
'host with no queues')
if not isinstance(queues, list):
raise ValueError('queues must be an array')
# One stamp for the whole report, so "when did this bay last report"
# reads off any row of it rather than a MAX over the set.
observedat = datetime.now(timezone.utc).replace(tzinfo=None)
# Resolved BEFORE the rows are written: assetid is a convenience for
# the read paths, and hostname stays the identity that the replace
# keys on, so an unresolved host still records everything it reported.
assetid = self._observed_assetid(hostname, warnings)
# Case-folded on both sides: one script sends COMPUTERNAME uppercase
# and another the lowercase FQDN, and MySQL forgives that while SQLite
# does not. Uncompared, the replace would leave the other spelling's
# rows in place and the host would appear to have every queue twice.
#
# A bulk delete so the DELETE reaches the database BEFORE the inserts
# below: a re-report repeats the same queue names, and the
# (hostname, queuename) unique index rejects the new rows if the old
# ones are still there. synchronize_session='fetch' costs one select
# and keeps the session's identity map honest, so a caller that read
# these rows earlier in the same request does not keep deleted ones.
# Every spelling of this host, not just the one it sent. The READ path
# treats a short name and its FQDN as the same machine, so a delete that
# matched only the exact string would leave the other spelling's rows
# behind and the host would appear to have every queue twice - the bug
# this replace exists to prevent. A PC that enrolls short and later
# reports fully qualified is normal, not exotic.
shortname = hostname.lower().split('.')[0]
predicate = db.or_(
db.func.lower(PrinterObservedQueue.hostname) == hostname.lower(),
db.func.lower(PrinterObservedQueue.hostname) == shortname)
if re.match(r'^[a-z0-9-]+$', shortname):
# Prefix match only for a plain name, as the read path does: a
# wildcard built from arbitrary input would delete another PC's rows.
predicate = db.or_(
predicate,
db.func.lower(PrinterObservedQueue.hostname).like(shortname + '.%'))
db.session.query(PrinterObservedQueue).filter(predicate).delete(
synchronize_session='fetch')
seennames = set()
defaultqueue = None
stored = 0
for entry in queues:
if not isinstance(entry, dict):
warnings.append('ignored a queue entry that was not an object')
continue
queuename = _observed_text(entry.get('queuename'), 'queuename',
warnings)
if not queuename:
warnings.append('ignored a queue with no queuename')
continue
if queuename.lower() in seennames:
# Windows cannot hold two queues of one name on a host, so this
# is a doubled line in the report. Dropping it keeps the
# (hostname, queuename) unique index from failing the whole
# report over one bad row.
warnings.append(
'ignored duplicate queue {!r}'.format(queuename))
continue
seennames.add(queuename.lower())
isdefault = _observed_bool(entry.get('isdefault'))
if isdefault and defaultqueue is not None:
# A host has exactly one default printer. Two means the client
# misread it, and keeping both would leave the seed candidate
# picking one at random.
warnings.append(
'more than one queue reported as default; kept {!r}'.format(
defaultqueue))
isdefault = False
if isdefault:
defaultqueue = queuename
db.session.add(PrinterObservedQueue(
hostname=hostname,
queuename=queuename,
drivername=_observed_text(entry.get('drivername'), 'drivername',
warnings),
portname=_observed_text(entry.get('portname'), 'portname',
warnings),
portaddress=_observed_text(entry.get('portaddress'),
'portaddress', warnings),
isdefault=isdefault,
isshared=_observed_bool(entry.get('isshared')),
assetid=assetid,
observedat=observedat,
))
stored += 1
# flush, not commit: the collector dispatcher owns the transaction and
# commits after writing its AuditLog row. Committing here would leave
# an unaudited report behind if that write then failed.
db.session.flush()
# Always 'updated'. This endpoint replaces observed rows and creates no
# asset, so 'created' never applies, and calling an identical re-report
# 'noop' would hide that the bay is still checking in.
return {
'action': 'updated',
'assetid': assetid,
'warnings': warnings,
'extra': {'queuecount': stored},
}
def _observed_assetid(self, hostname, warnings):
"""Computer asset this hostname belongs to, or None with a warning.
Reuses the resolver behind /api/printers/for-host rather than repeating
it: if the two ever disagreed, a bay would be compared against the
assignment of a different PC than the one it was told to install from.
An unknown hostname is a WARNING, not an error. A bay reporting before
its PC record exists is normal on a new build, the rows are keyed by
hostname and resolve the moment that record appears, and a 500 here
would make the client retry and log a failure on every cycle forever.
"""
try:
from .api.asset_routes import _computer_by_hostname
row = _computer_by_hostname(hostname)
except ImportError:
# A lean site can run without the computers plugin (ADR-013). The
# observed rows are still worth keeping - they just stay unresolved.
warnings.append('computers plugin not installed; observed queues '
'stored against the hostname only')
return None
if row is None:
warnings.append(
'hostname {!r} does not match a known PC; observed queues '
'stored unresolved'.format(hostname))
return None
# _computer_by_hostname returns the (Computer, Asset) pair.
return row[1].assetid

View File

@@ -0,0 +1,495 @@
// EventSaver - shopfloor event-advert screensaver.
// Two source modes, set in EventSaver.ini next to the .scr (no recompile):
// url=https://.../shopdb/api/slides/feed?surface=shopfloor -> pull from shopdb over
// HTTP, cache images locally, rotate the cache (no file share needed).
// folder=\\server\share\path -> read an SMB/local folder.
// url wins if both set. Strict order + per-slide seconds via order.txt (or the
// API's slides[].seconds). Cache survives a network blip (keeps last-good).
//
// Screensaver arg contract:
// /s show (fullscreen)
// /p <hwnd> preview (we no-op - keeps Windows happy)
// /c config (points user at the ini)
//
// Build (in-box .NET Framework, no SDK):
// C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
// /target:winexe /out:EventSaver.scr ^
// /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^
// EventSaver.cs
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Web.Script.Serialization;
using System.Windows.Forms;
namespace EventSaver
{
internal static class Program
{
[STAThread]
private static void Main(string[] args)
{
string mode = "/s";
if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim();
// strip a trailing ":hwnd" some callers append (e.g. /p:12345)
if (mode.StartsWith("/p")) mode = "/p";
if (mode.StartsWith("/c")) mode = "/c";
if (mode.StartsWith("/s")) mode = "/s";
if (mode == "/test")
{
// headless self-test: print the resolved playlist order and exit.
// lets CI / a display-less VM verify folder-read + order.txt + sort.
// winexe has no console in session 0, so write results to a
// file next to the exe (and Console too, for interactive runs).
Config tc = Config.Load();
string tf = tc.SourceFolder(true); // http mode: sync cache first
List<Slide> pl = Playlist.Build(tf, tc.Shuffle);
List<string> lines = new List<string>();
lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + " cache=" : "folder=") + tf);
lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle);
lines.Add("count=" + pl.Count);
for (int i = 0; i < pl.Count; i++)
lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds));
foreach (string l in lines) Console.WriteLine(l);
try
{
string outDir = Path.GetDirectoryName(Application.ExecutablePath);
File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray());
}
catch { }
return;
}
if (mode == "/render")
{
// headless render check: draw the first slide onto a 1280x720
// black canvas with the same fit logic as OnPaint, save a PNG.
// Lets a display-less VM prove decode + letterbox actually work.
Config rc = Config.Load();
List<Slide> rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle);
string outPng = args.Length > 1 ? args[1] : Path.Combine(
Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png");
using (Bitmap canvas = new Bitmap(1280, 720))
using (Graphics g = Graphics.FromImage(canvas))
{
g.Clear(Color.Black);
if (rpl.Count > 0)
{
using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (Image img = Image.FromStream(fs))
{
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size);
g.DrawImage(img, r);
}
}
canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png);
}
return;
}
if (mode == "/c")
{
MessageBox.Show(
"Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.",
"EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (mode == "/p")
{
// preview pane - do nothing, exit clean
return;
}
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Config cfg = Config.Load();
// one form per screen: primary shows slideshow, others stay black
List<Form> forms = new List<Form>();
Screen primary = Screen.PrimaryScreen;
foreach (Screen scr in Screen.AllScreens)
{
bool isPrimary = scr.Equals(primary);
SaverForm f = new SaverForm(scr, isPrimary ? cfg : null);
forms.Add(f);
}
foreach (Form f in forms) f.Show();
// Keep the monitor awake while the screensaver shows, so a shorter
// monitor-sleep policy can't blank the ads out from under us. Held
// for the life of the message loop, released on exit.
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
Application.Run(forms[0]);
SetThreadExecutionState(ES_CONTINUOUS);
}
[DllImport("kernel32.dll")]
private static extern uint SetThreadExecutionState(uint esFlags);
private const uint ES_CONTINUOUS = 0x80000000;
private const uint ES_DISPLAY_REQUIRED = 0x00000002;
private const uint ES_SYSTEM_REQUIRED = 0x00000001;
}
// ------------------------------------------------------------------ config
internal sealed class Config
{
// No default: a share path belongs to a site, not to this program
// (ADR-015). Folder mode is the fallback for a site with no HTTP
// reach to ShopDB, and it must name its own path in EventSaver.ini.
public string Folder = "";
public string Url = ""; // set -> HTTP mode (pull from shopdb)
public string CacheDir = ""; // local cache for HTTP mode (computed)
public int IntervalSeconds = 10;
public bool Shuffle = false;
public int FadeMs = 600;
public static Config Load()
{
Config c = new Config();
try
{
string dir = Path.GetDirectoryName(Application.ExecutablePath);
string ini = Path.Combine(dir, "EventSaver.ini");
if (!File.Exists(ini)) return c;
foreach (string raw in File.ReadAllLines(ini))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
int eq = line.IndexOf('=');
if (eq <= 0) continue;
string key = line.Substring(0, eq).Trim().ToLowerInvariant();
string val = line.Substring(eq + 1).Trim();
if (key == "url" && val.Length > 0) c.Url = val;
else if (key == "folder" && val.Length > 0) c.Folder = val;
else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; }
else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true");
else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; }
}
}
catch { /* bad ini - fall back to defaults */ }
return c;
}
// Folder the playlist reads: the local cache in HTTP mode (synced first
// when sync=true), else the configured share/folder. HTTP failures leave
// the last-good cache in place.
public string SourceFolder(bool sync)
{
if (Url.Length == 0) return Folder;
if (CacheDir.Length == 0)
CacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EventSaver", "cache");
if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } }
return CacheDir;
}
}
// ------------------------------------------------------------- http sync
// Pull the slide list from shopdb (/api/slides/feed), download images into a
// local cache dir, and write order.txt there so the normal Playlist logic
// reads the cache exactly like a folder. Idempotent: only downloads images
// not already cached, prunes ones no longer listed, keeps last-good on error.
internal static class HttpSync
{
public static void Sync(string apiUrl, string cacheDir)
{
try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { }
if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir);
string json;
using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); }
JavaScriptSerializer js = new JavaScriptSerializer();
IDictionary<string, object> root = js.DeserializeObject(json) as IDictionary<string, object>;
if (root == null) return;
object ok;
if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return;
string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : "";
object slidesObj;
if (!root.TryGetValue("slides", out slidesObj)) return;
object[] arr = slidesObj as object[];
if (arr == null) return;
Uri apiUri = new Uri(apiUrl);
// The feed's basepath is host-absolute (/api/slides/img/...) and omits
// the app's mount (e.g. /shopdb) - the web client adds it via withBase,
// so we must too, else images resolve to the host root and 404. Derive
// the mount from the feed URL's path (everything before "/api/").
string mount = "";
int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase);
if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx);
HashSet<string> keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
List<string> order = new List<string>();
order.Add("# generated by EventSaver from " + apiUrl);
foreach (object o in arr)
{
IDictionary<string, object> s = o as IDictionary<string, object>;
if (s == null) continue;
string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null;
if (string.IsNullOrEmpty(fn)) continue;
string safe = Path.GetFileName(fn); // strip any path component
if (safe.Length == 0) continue;
int secs = 0;
if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; }
string local = Path.Combine(cacheDir, safe);
if (!File.Exists(local))
{
try
{
// Host-absolute basepath -> prepend the mount; a full URL passes through.
string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath;
Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe));
using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); }
}
catch { continue; } // couldn't fetch this one - skip it this round
}
keep.Add(safe);
order.Add(secs > 0 ? safe + "|" + secs : safe);
}
try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { }
// prune cache images no longer referenced
foreach (string f in Directory.GetFiles(cacheDir))
{
string n = Path.GetFileName(f);
if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue;
if (!keep.Contains(n)) { try { File.Delete(f); } catch { } }
}
}
}
// ------------------------------------------------------------- playlist
// Builds ordered file list. order.txt wins (strict sequence, one name per
// line, optional "name|seconds" per-slide duration). Else sort by name.
internal sealed class Slide
{
public string Path;
public int Seconds; // 0 = use default interval
}
internal static class Playlist
{
private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
public static List<Slide> Build(string folder, bool shuffle)
{
List<Slide> list = new List<Slide>();
try
{
if (!Directory.Exists(folder)) return list;
string orderFile = Path.Combine(folder, "order.txt");
if (File.Exists(orderFile))
{
// strict sequence from order.txt
foreach (string raw in File.ReadAllLines(orderFile))
{
string line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
int secs = 0;
string name = line;
int bar = line.IndexOf('|');
if (bar > 0)
{
name = line.Substring(0, bar).Trim();
int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n;
}
string full = Path.Combine(folder, name);
if (IsImage(full) && File.Exists(full))
list.Add(new Slide { Path = full, Seconds = secs });
}
return list; // order.txt is authoritative - do not append extras
}
// no order.txt - all images, sorted by filename
List<string> files = new List<string>();
foreach (string f in Directory.GetFiles(folder))
if (IsImage(f)) files.Add(f);
files.Sort(StringComparer.OrdinalIgnoreCase);
if (shuffle) Shuf(files);
foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 });
}
catch { /* share unreachable - return what we have (maybe empty) */ }
return list;
}
private static bool IsImage(string path)
{
string e = Path.GetExtension(path).ToLowerInvariant();
foreach (string x in Exts) if (x == e) return true;
return false;
}
// deterministic-enough shuffle; screensaver so exact randomness irrelevant
private static void Shuf(List<string> l)
{
Random r = new Random();
for (int i = l.Count - 1; i > 0; i--)
{
int j = r.Next(i + 1);
string t = l[i]; l[i] = l[j]; l[j] = t;
}
}
}
// ------------------------------------------------------------- saver form
internal sealed class SaverForm : Form
{
private readonly Config cfg; // null on non-primary screens (black only)
private readonly Timer timer;
private List<Slide> slides = new List<Slide>();
private int idx = -1;
private Image current;
private Point lastMouse = Point.Empty;
private bool mouseSeen = false;
private DateTime lastScan = DateTime.MinValue;
public SaverForm(Screen screen, Config c)
{
cfg = c;
FormBorderStyle = FormBorderStyle.None;
Bounds = screen.Bounds;
StartPosition = FormStartPosition.Manual;
BackColor = Color.Black;
TopMost = true;
ShowInTaskbar = false;
DoubleBuffered = true;
Cursor.Hide();
KeyPreview = true;
// Left/Right step through the slides by hand; ANY other key still
// wakes the machine, which is what a screensaver must do. Without
// that exception an operator tapping an arrow to get back to work
// would be stuck watching slides.
KeyDown += (s, e) =>
{
if (e.KeyCode == Keys.Left) { e.Handled = true; Step(-1); return; }
if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1); return; }
Quit();
};
MouseDown += (s, e) => Quit();
MouseMove += OnMove;
if (cfg != null)
{
cfg.SourceFolder(true); // HTTP mode: initial sync + set CacheDir
Rescan();
timer = new Timer();
timer.Interval = 1000; // tick every second; advance when slide's time is up
timer.Tick += OnTick;
timer.Start();
Advance(); // show first immediately
}
}
private int slideElapsed = 0;
private DateTime lastSync = DateTime.Now; // ctor already did the first sync
private void OnTick(object sender, EventArgs e)
{
// HTTP mode: re-pull from shopdb every 60s so manager edits propagate.
if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60)
{
lastSync = DateTime.Now;
try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { }
lastScan = DateTime.MinValue; // force the rescan below
}
// periodic rescan so edits appear without restarting the saver
if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan();
slideElapsed++;
int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0)
? slides[idx].Seconds : cfg.IntervalSeconds;
if (slideElapsed >= want) Advance();
}
private void Rescan()
{
lastScan = DateTime.Now;
List<Slide> fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle);
slides = fresh;
if (idx >= slides.Count) idx = -1;
}
private void Advance()
{
Step(1);
}
// delta of +1 is the timer advancing, -1 is the operator going back.
// Resets the dwell timer either way: stepping by hand and then having it
// move again a moment later, because the tick was nearly up, reads as
// the screensaver ignoring the keypress.
private void Step(int delta)
{
slideElapsed = 0;
if (slides.Count == 0) { SetImage(null); return; }
if (idx < 0) idx = (delta < 0) ? 0 : -1; // first Step lands on slide 0
idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count;
try
{
// load without locking the file on the share
Image img;
using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
img = Image.FromStream(fs);
SetImage(img);
}
catch { SetImage(null); }
}
private void SetImage(Image img)
{
Image old = current;
current = img;
if (old != null) old.Dispose();
Invalidate();
}
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.Clear(Color.Black);
if (current == null) return;
e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
Rectangle r = FitZoom(current.Size, ClientSize);
e.Graphics.DrawImage(current, r);
}
// test hook - same math as OnPaint, exposed for the /render self-check.
public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); }
// scale image to fit while preserving aspect (letterbox)
private static Rectangle FitZoom(Size img, Size box)
{
if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height);
double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height);
int w = (int)(img.Width * s);
int h = (int)(img.Height * s);
return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h);
}
private void OnMove(object sender, MouseEventArgs e)
{
// ignore the first synthetic move; require real movement to exit
if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; }
if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit();
}
private void Quit()
{
try { Cursor.Show(); } catch { }
Application.Exit();
}
}
}

View File

@@ -0,0 +1,19 @@
# EventSaver config. Lives next to EventSaver.scr.
# Change these without recompiling. Screensaver re-reads on each launch.
# HTTP mode (recommended): pull slides from shopdb over HTTP, cache locally.
# No file share needed. Point at the shopdb slides feed (/api/slides/feed) for this
# surface. FIX THE BASE URL if the shopdb path differs on the live box.
url=https://shopdb.example.net/api/slides/feed?surface=shopfloor
# Folder mode (fallback): used only if url is blank. SMB/local path.
# folder=\\fileserver.example.net\share\tv\shopfloor
# Seconds per image (default when a slide has no per-slide time).
interval=10
# 1 = random order, 0 = ordered. Ignored when order.txt / API order is present.
shuffle=0
# Crossfade length in ms (0 = hard cut). Reserved - hard cut in v1.
fadems=600

View File

@@ -6,6 +6,7 @@
v-if="machine && hasPosition"
:left="machine.mapx"
:top="machine.mapy"
:levelid="machine.levelid"
:machineName="machine.machinenumber || machine.name || ''"
>
<span class="mmc-chip" :title="hoverTitle">

View File

@@ -169,21 +169,44 @@ fi
# whether all of them travel with their level, because reading them by eye is
# how the twentieth gets missed.
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \
| grep -v '/tests\?/' || true)
# PER OCCURRENCE, not per file. The file-level form passed a module that emitted
# 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
# the toner report and the enforcement report, both feeding a hover preview that
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
# the window is wide enough for a comment between them, and no wider.
MISSING_LEVEL=""
for candidate in $POSITION_FILES; do
if ! grep -q "'levelid'" "$candidate"; then
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n'
while IFS= read -r hit; do
[ -z "$hit" ] && continue
file=${hit%%:*}
line=${hit#*:}; line=${line%%:*}
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
fi
done
done <<EOF
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
EOF
if [ -n "$MISSING_LEVEL" ]; then
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level"
echo " cannot be rendered on the right drawing:"
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
echo " level cannot be drawn on the right floor plan (ADR-017):"
echo "$MISSING_LEVEL" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1))
fi
# The same rule for the hover preview: binding coordinates into
# LocationMapTooltip without a level makes it report "no level" for every asset,
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
TOOLTIP_MISSING=""
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
grep -q ':left=' "$candidate" || continue
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
done
if [ -n "$TOOLTIP_MISSING" ]; then
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
echo " preview cannot know which drawing to use (ADR-017):"
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
VIOLATIONS=$((VIOLATIONS + 1))
fi
# ENFORCING. It was report-only while the backlog was worked off, and the hit
# count then did not move for weeks - a rule that only prints is read as no rule.
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.

View File

@@ -100,6 +100,22 @@ class Harness:
self.ids = IdMap(idmap_path or default)
self.source = Source()
self.errors = []
self._defaultlevelid = None
@property
def defaultlevelid(self):
"""The level imported map positions belong to (ADR-017).
The legacy schema predates levels: it has ONE floor plan, so every
mapleft/maptop it carries is a coordinate on this site's default level.
Importing them without a level produces markers the map refuses to draw
- it will not guess a drawing for coordinates that do not name one.
"""
if self._defaultlevelid is None:
from shopdb.core.models import MapLevel
level = MapLevel.default_level()
self._defaultlevelid = level.levelid if level else None
return self._defaultlevelid
def _silence_sql_logging(self):
import logging

View File

@@ -51,6 +51,19 @@ def _upsert(h, path, payload, unique_field, idfield, list_path=None):
return None
def _put(h, path, payload):
"""PUT through the harness client. The harness wraps post/get only, so the
reconcile-style assignment route goes direct with the same import-mode
headers. Returns (status, data)."""
headers = {'Authorization': f'Bearer {h.secret}', 'X-Import-Mode': 'true'}
resp = h.client.put(path, json=payload, headers=headers)
body = resp.get_json() or {}
data = body.get('data', body)
if resp.status_code >= 400:
h.errors.append((path, resp.status_code, payload, data))
return resp.status_code, data
# --- classic machinetype routing (per the resolved import decisions) ---------
# measuringtools are the physical instruments (the CMM/gauge machine types). A
# PC that DRIVES one (pctype CMM/Genspect/Keyence/Wax) is still a computer, not a
@@ -287,6 +300,7 @@ def stage_assets(h):
'serialnumber': (m['serialnumber'] or '').strip() or None,
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid,
'notes': m['machinenotes'],
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
@@ -345,13 +359,29 @@ def stage_printers(h):
'iscsf': _truthy_bit(p['iscsf']),
'installpath': p['installpath'], 'pin': p['printerpin'],
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
'levelid': h.defaultlevelid,
'modelnumberid': h.ids.get('model', p['modelid']),
'locationid': h.ids.get('location', p['machineid']),
}
status, _ = h.post('/api/printers', payload)
status, data = h.post('/api/printers', payload)
assetid = None
if status in (200, 201):
assetid = _id_of(data, 'assetid')
made += 1
return {'printers': made}
elif status == 409:
# Re-run: PRN-<printerid> is already an asset. Resolve it anyway -
# without this branch a resumed run crosswalks nothing and the
# defaultprinters stage silently links nothing.
_, rows = h.get(f"/api/printers?assetnumber={payload['assetnumber']}&per_page=5")
items = rows.get('items', rows) if isinstance(rows, dict) else rows
for row in (items or []):
if str(row.get('assetnumber', '')).strip().lower() == \
payload['assetnumber'].lower():
assetid = _id_of(row, 'assetid')
break
if assetid:
h.ids.put('printer', p['printerid'], assetid)
return {'printers': made, 'crosswalked': h.ids.count('printer')}
# classic pctype -> the measuring instrument that PC drives
@@ -386,7 +416,8 @@ def stage_metrology(h):
'name': (f"{pcname} {toolname}").strip() or toolname,
'measuringtooltypeid': typeid,
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
'mapx': m['mapleft'], 'mapy': m['maptop']})
'mapx': m['mapleft'], 'mapy': m['maptop'],
'levelid': h.defaultlevelid})
if status not in (200, 201):
continue
tool_assetid = _id_of(data, 'assetid')
@@ -673,6 +704,77 @@ def stage_relationships(h):
return {'relationships': made, 'dropped_unresolved': dropped}
def stage_defaultprinters(h):
"""classic machines.printerid -> a printer assignment on the imported asset.
Classic records exactly one default printer per machine row. It goes through
the assignment reconcile route rather than raw relationship posts: the
usesprinter + defaultprinter pairing, printer-type validation, the
default-must-be-in-the-set rule and the soft-delete/reactivate semantics all
live there, and reconciling converges on a re-run instead of duplicating.
Runs after assets and printers - it resolves both ends through their
crosswalks and drops the row when either end did not import. Needs the
usesprinter/defaultprinter relationship types seeded (flask seed
reference-data): the route writes nothing without them and every row fails.
"""
# printerid=0 means no printer recorded, not a dangling FK - excluded so it
# never lands in the drop count as something someone has to explain.
rows = h.source.rows('shopdb_src',
'SELECT machineid, printerid FROM machines '
'WHERE isactive=1 AND printerid IS NOT NULL AND printerid>0')
# Legacy names for the drop report. Most machines point at a retired
# placeholder printer, and a bare "dropped 560" reads like data loss.
printernames = {row['printerid']: (row['printerwindowsname'] or '').strip()
for row in h.source.rows(
'shopdb_src',
'SELECT printerid, printerwindowsname FROM printers')}
counts = {'source_rows': len(rows), 'linked': 0, 'already': 0,
'dropped_no_asset': 0, 'dropped_printer_not_imported': 0,
'failed': 0}
dropped_printers = {}
for r in rows:
assetid = h.ids.get('asset', r['machineid'])
if not assetid:
# Machine became a Location, was the 9999 placeholder, a duplicate
# machinenumber, or a skipped type.
counts['dropped_no_asset'] += 1
continue
printerassetid = h.ids.get('printer', r['printerid'])
if not printerassetid:
counts['dropped_printer_not_imported'] += 1
label = '{0} {1}'.format(r['printerid'],
printernames.get(r['printerid'], '?'))
dropped_printers[label] = dropped_printers.get(label, 0) + 1
continue
# Read first: the PUT reconciles the WHOLE set, so a blind write would
# unassign anything else already on this asset. Matching state is left
# alone so a second run is a no-op, not a rewrite.
_, current = h.get(f'/api/printers/assignments/for-asset/{assetid}')
current = current if isinstance(current, dict) else {}
assigned = list(current.get('printerassetids') or [])
if (current.get('defaultprinterassetid') == printerassetid
and printerassetid in assigned):
counts['already'] += 1
continue
if printerassetid not in assigned:
assigned.append(printerassetid)
status, _ = _put(h, f'/api/printers/assignments/for-asset/{assetid}',
{'printerassetids': assigned,
'defaultprinterassetid': printerassetid})
if status in (200, 201):
counts['linked'] += 1
else:
counts['failed'] += 1
if dropped_printers:
counts['dropped_printers'] = dict(
sorted(dropped_printers.items(), key=lambda item: -item[1])[:10])
return counts
def stage_subnets(h):
"""Subnets + VLANs. Classic cidr is the suffix only; full CIDR =
INET_NTOA(ipstart)+suffix. VLANs are lookup-or-create by number; duplicate
@@ -789,6 +891,7 @@ STAGES = {
'notifications': stage_notifications,
'knowledgebase': stage_knowledgebase,
'relationships': stage_relationships,
'defaultprinters': stage_defaultprinters,
'subnets': stage_subnets,
'usb': stage_usb,
'verify': stage_verify,
@@ -801,7 +904,7 @@ def main():
'--stages',
default='reference,employees,catalog,assets,locations,printers,'
'metrology,communications,applications,warranties,notifications,'
'knowledgebase,relationships,subnets,usb,verify',
'knowledgebase,relationships,defaultprinters,subnets,usb,verify',
help='comma list of stages to run')
args = parser.parse_args()

View File

@@ -63,7 +63,7 @@ __contract_version__ = '0.20.0'
# plugin-contract version above are distinct series with independent
# bump rules. Not part of the shopdb.api contract surface, so it is
# not re-exported there.
__version__ = '0.11.2'
__version__ = '0.11.3'
def create_app(config_name: str = None) -> Flask:
@@ -89,6 +89,13 @@ def create_app(config_name: str = None) -> Flask:
app.config.from_object(config_class)
# The product and contract versions, in config so a PLUGIN can read them
# through current_app instead of importing core (ADR-002 forbids that, and
# the contract test enforces it). Core code may still use the module
# constants directly.
app.config['VERSION'] = __version__
app.config['CONTRACT_VERSION'] = __contract_version__
# Load instance config if exists
app.config.from_pyfile('config.py', silent=True)

View File

@@ -516,12 +516,43 @@ def seed_reference_data():
for at in adr_types:
if not _lookup_binary(at['relationshiptype']):
db.session.add(RelationshipType(**at))
# Printer assignment edges. usesprinter = "this printer is installed here",
# defaultprinter = which of them is the default. Assignment lives on the
# MACHINE asset and reaches whichever PC controls it through the controls
# rail seeded below, so a reimaged PC gets its printers back with no
# restore step. Created before the rails: _seed_propagation no-ops in
# silence when either type is missing.
printer_types = [
{'relationshiptype': 'usesprinter',
'description': 'Asset to a printer installed on it (ADR-001)',
'isdirectional': True},
{'relationshiptype': 'defaultprinter',
'description': 'PC to its default printer (installer preselect, ADR-001)',
'isdirectional': True,
# One default per asset. The unique constraint is (source, target,
# type), so two DIFFERENT defaults are two valid rows and the resolver
# then takes the oldest - the new default silently loses. Core's
# create path replaces instead, driven by this flag.
'issingular': True},
]
for pt in printer_types:
existing = _lookup_binary(pt['relationshiptype'])
if not existing:
db.session.add(RelationshipType(**pt))
elif pt.get('issingular') and not existing.issingular:
# A site seeded before the flag existed keeps its row and gains the
# rule; without this the upgrade leaves the old silent behaviour.
existing.issingular = True
db.session.flush()
# Seed `controls` propagation rails as M:N rows. controls -> partof
# (declared; directional rail, not consumed yet) and controls -> Dualpath
# (consumed; a dual-bay pair shares one controller so both bays carry
# controls). Idempotent, resolved by name, skipped if a type is missing.
# Seed propagation rails as M:N rows. controls -> partof (declared;
# directional rail, not consumed yet) and controls -> Dualpath (consumed;
# a dual-bay pair shares one controller so both bays carry controls).
# The two printer rails are READ-TIME only: the create-time fan-out skips
# directional through-types, and controls is directional, so a PC's own
# rows keep beating what it inherits from the machine it controls.
# Idempotent, resolved by name, skipped if a type is missing.
from shopdb.core.models.relationship import RelationshipTypePropagation
def _seed_propagation(sourcename, throughname):
@@ -541,15 +572,8 @@ def seed_reference_data():
_seed_propagation('controls', 'partof')
_seed_propagation('controls', 'Dualpath')
# Default-printer link: a PC asset -> its default printer asset. Read by the
# printer-installer endpoint (parity with classic apipcdefaultprinter.asp).
# Attribute-style edge, not a position rail, so no propagation.
if not _lookup_binary('defaultprinter'):
db.session.add(RelationshipType(
relationshiptype='defaultprinter',
description='PC to its default printer (installer preselect, ADR-001)'
))
_seed_propagation('usesprinter', 'controls')
_seed_propagation('defaultprinter', 'controls')
db.session.commit()
click.echo(click.style("Reference data seeded.", fg='green'))

View File

@@ -792,6 +792,27 @@ def create_asset_relationship():
http_code=409
)
# A SINGULAR type allows one active row per source, and setting a new one
# REPLACES rather than refusing: "make this the default printer" means
# exactly that, and a card that answered 409 would leave the user to find
# and delete the old row first.
#
# Without this the schema is happy to hold two defaults - the unique
# constraint is (source, target, type), so two different targets are two
# valid rows - and the resolver takes the OLDEST, so the new default
# silently loses.
reltype = db.session.get(RelationshipType, type_id)
replaced = 0
if reltype is not None and getattr(reltype, 'issingular', False):
others = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == source_id,
AssetRelationship.relationshiptypeid == type_id,
AssetRelationship.isactive == True,
AssetRelationship.targetassetid != target_id).all()
for row in others:
row.isactive = False
replaced += 1
# And it cannot relate BOTH WAYS on a directional type. Only one direction
# can be true - a PC drives a machine, never the reverse - but the check
# above is keyed on (source, target, type), so the inverse used to insert

View File

@@ -24,6 +24,11 @@ class RelationshipType(BaseModel):
# True: edge has a source->target meaning (controls, partof, Backup For).
# False: symmetric link (Dualpath, connectedto, USB...) shown on the card
# once per peer with no direction, both stored direction rows collapsed.
# At most one ACTIVE relationship of this type per source asset. A PC has
# one default printer; the (source, target, type) unique constraint cannot
# express that, because two different targets are two different rows.
issingular = db.Column(db.Boolean, nullable=False, default=False)
isdirectional = db.Column(
db.Boolean,
default=True,

View File

@@ -66,7 +66,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'printedparts': ('printeditems', 'printeditemtransactions',
'printeditemfiles'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
'printersupplyalerts'),
'printersupplyalerts', 'printerobservedqueues'),
'slides': ('tvslides',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
'warranty': ('warranties', 'warrantyassets'),

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

View File

@@ -53,8 +53,11 @@ EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
# printers adds the printersupplyalerts crossing-state table on top of its
# anchor, then the exact INF driver name Add-PrinterDriver needs, a vendor on
# the driver, and finally printerobservedqueues - what a bay reported it HAS,
# kept apart from what it was assigned.
EXPECTED_HEAD_REVISION['printers'] = 'printers0005observedqueues'
# 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'

View File

@@ -0,0 +1,122 @@
"""The collector reporter, generated with a site's own values.
A site used to receive a script with the reference site's server and VLANs in
it, which it had to find and edit. The server now stamps its own values into the
parameter defaults, so the file downloads ready to deploy.
What must stay true:
- ONLY the defaults are substituted. The copy in the repo stays runnable, so
there is never a second version to drift from the first.
- The collector key is NEVER in the file. It lands on every shop-floor PC, and a
token spread across hundreds of bays cannot be rotated quietly.
- Everything stamped is still overridable, because a bay may need to differ from
its site.
"""
import pytest
from shopdb.core.models import Setting
SCRIPT = '/api/computers/client-script'
def _set(db, key, value):
row = Setting.query.filter_by(key=key).first()
if row is None:
row = Setting(key=key, value=value, valuetype='string', category='computers')
db.session.add(row)
else:
row.value = value
db.session.commit()
def test_an_anonymous_caller_gets_nothing(client):
"""It states a site's URL and internal ranges - configuration, not a
handout."""
assert client.get(SCRIPT).status_code in (401, 422)
def test_it_serves_the_reporter_as_a_download(client, auth_headers):
resp = client.get(SCRIPT, headers=auth_headers)
assert resp.status_code == 200, resp.get_data(as_text=True)[:200]
assert 'attachment' in resp.headers['Content-Disposition']
assert 'Report-AssetToShopDB.ps1' in resp.headers['Content-Disposition']
body = resp.get_data(as_text=True)
assert 'param(' in body
assert 'api/collector/computers' in body
def test_it_publishes_a_hash_of_what_it_served(client, auth_headers):
"""So a deployment can verify what it fetched, like the installer does."""
import hashlib
resp = client.get(SCRIPT, headers=auth_headers)
digest = hashlib.sha256(resp.get_data()).hexdigest()
assert resp.headers['X-Script-Sha256'] == digest
def test_it_stamps_the_sites_own_url(client, db, auth_headers):
_set(db, 'site_base_url', 'https://shopdb.example.net')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$ApiUrl = 'https://shopdb.example.net/api/collector/computers'" in body
assert 'for https://shopdb.example.net' in body
def test_a_site_with_no_url_configured_still_gets_a_usable_script(client, db,
auth_headers):
"""Blank site_base_url falls back to the origin the admin is talking to,
which is by definition a reachable address for this server."""
_set(db, 'site_base_url', '')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$ApiUrl = ''" not in body
assert 'api/collector/computers' in body
def test_it_stamps_the_sites_ranges(client, db, auth_headers):
"""The replacement for the two VLANs that used to be source code."""
_set(db, 'computers_routableranges', '10.20.0.0/23,10.21.4.0/26')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$AllowedRanges = '10.20.0.0/23,10.21.4.0/26'" in body
def test_no_ranges_configured_leaves_the_default_route_fallback(client, db,
auth_headers):
"""An unconfigured site must not be given someone else's addressing: the
script falls back to the NIC carrying the default route."""
_set(db, 'computers_routableranges', '')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert "[string]$AllowedRanges = ''" in body
assert 'default route' in body
def test_the_collector_key_is_never_in_the_file(client, db, auth_headers):
"""The one thing that must not be stamped in. A token in a file on every bay
is a token nobody can rotate quietly."""
_set(db, 'site_base_url', 'https://shopdb.example.net')
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert 'shopdb_pat_' not in body
assert "$ApiKey = ''" in body or "[string]$ApiKey = ''" in body
# It says where the key comes from instead.
assert 'CollectorKey' in body
def test_the_body_is_not_rewritten_only_the_defaults(client, auth_headers):
"""The repo copy stays runnable. If generation started editing the body,
the file on disk and the file a site runs would diverge."""
from plugins.computers.api.routes import _client_script_path
with open(_client_script_path(), 'r', encoding='utf-8') as handle:
source = handle.read()
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
# Every line of the source survives except the two parameter defaults.
changed = [line for line in source.splitlines()
if line.strip() and line not in body.splitlines()]
assert all('$ApiUrl' in line or '$AllowedRanges' in line for line in changed), changed
def test_it_names_the_version_that_generated_it(client, auth_headers):
"""So a script found on a bay can be traced to the server that made it."""
from shopdb import __version__
body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True)
assert __version__ in body

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

View File

@@ -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

View File

@@ -0,0 +1,836 @@
"""What a bay ACTUALLY has, and how it compares to what it was assigned.
ShopDB has always known what a host SHOULD have (usesprinter/defaultprinter rows
on the machine, read through /api/printers/for-host). This is the other half: a
PC reports the queues it really carries to POST /api/collector/printers, and
/api/printers/observed/for-asset/<id> puts the two sides next to each other.
Three settled rules are what these tests exist to defend:
Observed and assigned stay apart. A collector report never writes an
assignment row. The moment a drifted bay's own state is allowed to become
what that bay is told to install, enforcement means nothing and every
configuration error becomes permanent the next time the PC checks in.
A report REPLACES that host's rows. This is current state, not history: the
latest report is the whole truth for the host, so "what does this bay have"
stays a filter and never becomes a question about time.
An unmatched queue is UNKNOWN, never a guess. A wrong match seeds a wrong
assignment, and a wrong assignment is worse than no assignment because the
client then installs it on every cycle.
Two surfaces are exercised:
POST /api/collector/printers what the host reports it has
GET /api/printers/observed/for-asset/<assetid> observed against assigned
Seeding has its own route, POST /api/printers/assignments/seed-from-observed/<id>,
because a rollout adopts many machines at once and doing that through the editor
would be one round trip per bay. It is still not a second WRITE path: it calls the
same _reconcile_edges the editor's PUT does, so both are validated identically and
an assignment can only be written one way.
What makes it safe is that it is explicit. Nothing calls it on a schedule, and a
queue that resolves to no known printer is refused rather than guessed into an
assignment.
"""
import json
import pytest
from shopdb.core.models import (
Asset,
AssetRelationship,
AssetType,
Communication,
CommunicationType,
Model,
RelationshipType,
Vendor,
)
from plugins.printers.models import Printer, PrinterDriver, PrinterObservedQueue
COLLECT_URL = '/api/collector/printers'
OBSERVED_URL = '/api/printers/observed/for-asset/%d'
ASSIGN_URL = '/api/printers/assignments/for-asset/%d'
HOST_URL = '/api/printers/for-host/%s'
KEY = 'testcollectorkey'
BAY_HOST = 'BAYPC01'
SECOND_BAY_HOST = 'BAYPC02'
OFFICE_HOST = 'OFFICEPC01'
# Addresses only, no site meaning: the port address is the match key under test.
ADDRESS_A = '10.20.0.11'
ADDRESS_B = '10.20.0.12'
ADDRESS_C = '10.20.0.13'
ADDRESS_NOBODY = '10.20.0.99'
DRIVER_NAME = 'HP Universal Printing PS'
@pytest.fixture
def collector_key(app):
"""Set the shared collector key. A site may scope a printers-only key
instead (COLLECTOR_API_KEY_PRINTERS); the shared key is the documented
fallback and is what the reporter script falls back to as well."""
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def scene(db):
"""Two bay PCs controlling one machine, an office PC controlling nothing,
and three printers each reachable at its own address.
No assignments and no observations: every test builds the pair it needs, so
a classification can never be an accident of the fixture.
"""
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',
isdirectional=True)
ip_comtype = CommunicationType(comtype='IP')
db.session.add_all([pc_type, machine_type, printer_type, uses_type,
default_type, controls_type, ip_comtype])
db.session.flush()
vendor = Vendor(vendor='HP')
db.session.add(vendor)
db.session.flush()
model = Model(modelnumber='LaserJet M602', vendorid=vendor.vendorid)
db.session.add(model)
db.session.flush()
# Model-bound so the driver ShopDB would install is unambiguous: driver
# drift is only meaningful against a driver the assigned side actually names.
db.session.add(PrinterDriver(name='HP Universal Print Driver',
drivername=DRIVER_NAME,
location=r'\\server\share\hp',
vendorid=vendor.vendorid,
modelnumberid=model.modelnumberid,
isactive=True))
baypc = Asset(assetnumber='1001', name='Bay PC',
assettypeid=pc_type.assettypeid, isactive=True)
secondbaypc = Asset(assetnumber='1002', name='Second Bay PC',
assettypeid=pc_type.assettypeid, isactive=True)
officepc = Asset(assetnumber='1003', 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([baypc, secondbaypc, officepc, machine])
db.session.flush()
printers = {}
for suffix, name, address in (('A', 'Bay label printer', ADDRESS_A),
('B', 'Bay laser printer', ADDRESS_B),
('C', 'Office laser printer', ADDRESS_C)):
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,
hostname='printer-%s' % suffix.lower(),
vendorid=vendor.vendorid,
modelnumberid=model.modelnumberid,
isnetwork=True)
db.session.add(printer)
db.session.add(Communication(assetid=asset.assetid,
comtypeid=ip_comtype.comtypeid,
ipaddress=address, isprimary=True))
printers[suffix] = {'asset': asset, 'printer': printer,
'address': address}
db.session.add_all([
Computer(assetid=baypc.assetid, hostname=BAY_HOST),
Computer(assetid=secondbaypc.assetid, hostname=SECOND_BAY_HOST),
Computer(assetid=officepc.assetid, hostname=OFFICE_HOST),
])
db.session.commit()
return {
'baypc': baypc,
'secondbaypc': secondbaypc,
'officepc': officepc,
'machine': machine,
'printers': printers,
'uses_type': uses_type,
'default_type': default_type,
'controls_type': controls_type,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _queue(name, address=None, drivername=DRIVER_NAME, isdefault=False,
portname=None):
"""One entry of the reported queues array, spelled as the client sends it."""
return {
'queuename': name,
'drivername': drivername,
'portname': portname or ('IP_%s' % address if address else 'LPT1'),
'portaddress': address,
'isdefault': isdefault,
}
def _report(client, hostname, queues):
return client.post(COLLECT_URL, json={'hostname': hostname,
'queues': queues},
headers={'X-API-Key': KEY})
def _stored(hostname):
"""Observed rows held for a host, read straight from the table.
Read here rather than through the comparison endpoint because replacement is
a property of the STORE: a read that filtered by newest timestamp would hide
an append-only table growing behind it.
"""
return PrinterObservedQueue.query.filter(
PrinterObservedQueue.hostname.ilike(hostname)).all()
def _queuenames(hostname):
return {row.queuename for row in _stored(hostname)}
def _assignment_rows(scene):
"""Every active usesprinter/defaultprinter row in the database.
Not scoped to one asset on purpose: a collector report must not create an
assignment ANYWHERE, including on an asset the test never named.
"""
typeids = [scene['uses_type'].relationshiptypeid,
scene['default_type'].relationshiptypeid]
rows = AssetRelationship.query.filter(
AssetRelationship.relationshiptypeid.in_(typeids),
AssetRelationship.isactive == True).all() # noqa: E712
return {(row.sourceassetid, row.targetassetid, row.relationshiptypeid)
for row in rows}
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):
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, pc):
_relate(db, pc, scene['machine'], scene['controls_type'])
def _assetid(scene, suffix):
return scene['printers'][suffix]['asset'].assetid
def _hostblocks(response):
"""{hostname (lowercased): block} out of a comparison payload.
Normalized in one place because a machine answers with one block per
controlling PC while a PC has only itself, and the endpoint may reasonably
return the single case unwrapped. The semantics under test are the same
either way; which of the two shapes it is, is not.
"""
payload = response.get_json()['data']
blocks = payload.get('hosts')
if blocks is None:
blocks = [payload]
return {(block.get('hostname') or '').lower(): block for block in blocks}
def _oneblock(response, hostname):
blocks = _hostblocks(response)
assert hostname.lower() in blocks, \
'no block for %s in %s' % (hostname, sorted(blocks))
return blocks[hostname.lower()]
def _classified(block):
"""{classification: {identity}} for one host block.
Identity is the printer assetid when the row resolved to a printer, and the
queue name when it did not - which is exactly the distinction the UNKNOWN
rule is about. `missing` rows describe an assigned printer that was never
observed, so they may arrive in the queue list or in a list of their own.
"""
rows = list(block.get('queues') or [])
rows.extend(block.get('missing') or [])
result = {}
for row in rows:
assetid = row.get('printerassetid')
identity = assetid if assetid is not None else row.get('queuename')
result.setdefault(row.get('classification', 'missing'), set()).add(identity)
return result
def _seedcandidate(block):
seed = block.get('seedcandidate')
assert seed is not None, 'block carries no seedcandidate: %s' % sorted(block)
return seed
def _skippedtext(seed):
"""Everything the seed candidate says it left out, as one lowercase blob.
The shape of the skip report is not what matters; that an operator can see
WHICH queues were not seeded is. A seed that silently drops the queues it
could not match looks identical to a bay that has nothing else installed.
"""
skipped = (seed.get('skippedqueuenames') if 'skippedqueuenames' in seed
else seed.get('skipped'))
assert skipped is not None, \
'seedcandidate reports nothing about what it skipped: %s' % sorted(seed)
return json.dumps(skipped).lower()
# ---------------------------------------------------------------------------
# Collection
# ---------------------------------------------------------------------------
def test_a_report_stores_the_hosts_queues(client, db, collector_key, scene):
"""The queues a bay reports land verbatim, port address included.
Port address is the primary match key and the only unambiguous one. If it is
dropped or rewritten on the way in, every later comparison falls back to
matching on a queue name - a naming convention - and a renamed queue starts
reading as a different printer.
"""
response = _report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
_queue('PRINTER-B', ADDRESS_B),
])
assert response.status_code == 200, response.get_json()
rows = {row.queuename: row for row in _stored(BAY_HOST)}
assert set(rows) == {'PRINTER-A', 'PRINTER-B'}
assert rows['PRINTER-A'].portaddress == ADDRESS_A
assert rows['PRINTER-A'].drivername == DRIVER_NAME
assert rows['PRINTER-A'].isdefault is True
assert rows['PRINTER-B'].isdefault is False
def test_a_second_report_replaces_the_first(client, db, collector_key, scene):
"""The latest report is the whole truth for that host.
Accumulating instead would grow a row per queue per GE-Enforce cycle forever
and, worse, answer "what does this bay have" with every queue it has ever
had - so a printer removed from a bay would look installed for the rest of
the site's life.
"""
first = _report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A),
_queue('PRINTER-B', ADDRESS_B),
])
assert first.status_code == 200, first.get_json()
second = _report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
assert second.status_code == 200, second.get_json()
assert _queuenames(BAY_HOST) == {'PRINTER-A'}
def test_a_report_replaces_only_the_reporting_host(client, db, collector_key, scene):
"""One bay's report must not touch another bay's rows.
Replacement keyed on anything wider than the hostname turns every cycle into
a race: whichever PC reported last would be the only one ShopDB believes has
any printers at all.
"""
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
_report(client, SECOND_BAY_HOST, [_queue('PRINTER-B', ADDRESS_B)])
assert _queuenames(BAY_HOST) == {'PRINTER-A'}
assert _queuenames(SECOND_BAY_HOST) == {'PRINTER-B'}
def test_an_empty_queue_list_clears_the_host(client, db, collector_key, scene):
"""A host that genuinely has no printers reports that, and it takes effect.
This is the counterpart of the rule below: [] is a real observation and must
wipe the previous set, or a printer removed from a bay stays visible in
ShopDB forever.
"""
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
cleared = _report(client, BAY_HOST, [])
assert cleared.status_code == 200, cleared.get_json()
assert _stored(BAY_HOST) == []
def test_a_report_with_no_queues_key_is_rejected_and_changes_nothing(
client, db, collector_key, scene):
"""Absent is not empty, and the difference is the whole safety margin.
A client whose enumeration failed must send nothing. If a malformed report
with no queues key were treated as "this host has none", one client bug
would erase the observed state of the fleet host by host, quietly, at
collector cadence.
"""
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
response = client.post(COLLECT_URL, json={'hostname': BAY_HOST},
headers={'X-API-Key': KEY})
assert response.status_code == 400
assert _queuenames(BAY_HOST) == {'PRINTER-A'}
def test_an_unknown_hostname_warns_instead_of_failing(client, db, collector_key,
scene):
"""A bay ShopDB has no PC record for still gets to report.
Reporting before enrollment is normal on a fresh build, and the rows are
keyed by hostname so they resolve the moment the record appears. Failing the
call instead would make the client log an error on every cycle on every
unenrolled bay, and real failures would drown in it.
"""
response = _report(client, 'NOSUCHHOST', [_queue('PRINTER-A', ADDRESS_A)])
assert response.status_code == 200, response.get_json()
data = response.get_json()['data']
assert data['warnings'], 'an unresolvable hostname reported no warning'
assert any('nosuchhost' in warning.lower() for warning in data['warnings'])
assert _queuenames('NOSUCHHOST') == {'PRINTER-A'}
def test_a_report_never_changes_an_assignment(client, db, collector_key, scene):
"""The separation this whole design rests on.
The bay is assigned printer A and reports B and C instead - the exact drift
the feature exists to show. Not one assignment row may move. If observed
state could write the assigned side, a misconfigured bay would rewrite its
own orders on its next check-in, drift would self-heal into permanence, and
/api/printers/for-host would stop meaning "what this host should have".
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
before = _assignment_rows(scene)
response = _report(client, BAY_HOST, [
_queue('PRINTER-B', ADDRESS_B, isdefault=True),
_queue('PRINTER-C', ADDRESS_C),
])
assert response.status_code == 200, response.get_json()
assert _assignment_rows(scene) == before
# And the host is still told to install exactly what it was told before.
resolved = client.get(HOST_URL % BAY_HOST)
assert resolved.status_code == 200
assigned = resolved.get_json()['data']['printers']
assert [row['assetid'] for row in assigned] == [_assetid(scene, 'A')]
# ---------------------------------------------------------------------------
# Comparison
# ---------------------------------------------------------------------------
def test_comparison_requires_authentication(client, db, scene):
"""Observed state is internal detail, not a machine-readable public feed.
The collector endpoint has its own key auth for unattended clients; this
read is for people, so it goes through the normal login. Left open, a bay's
installed-software-adjacent inventory would be readable by anyone who can
reach the API.
"""
response = client.get(OBSERVED_URL % scene['baypc'].assetid)
assert response.status_code == 401
def test_comparison_classifies_matching_missing_and_extra(
client, db, collector_key, scene, auth_headers):
"""The three plain answers, in one bay.
A is assigned and observed (matching), B is assigned and absent (missing),
C is observed and never assigned (extra). Collapsing any of these into the
others is what makes a comparison view worthless: missing is a bay that
never converged, extra is a printer somebody added by hand, and reading one
as the other sends a technician to the wrong problem.
"""
_assign(db, scene, scene['machine'], ['A', 'B'], default='A')
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
_queue('PRINTER-C', ADDRESS_C),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
classified = _classified(_oneblock(response, BAY_HOST))
assert classified.get('matching') == {_assetid(scene, 'A')}
assert classified.get('missing') == {_assetid(scene, 'B')}
assert classified.get('extra') == {_assetid(scene, 'C')}
def test_a_queue_pointing_at_the_wrong_address_is_drifted(
client, db, collector_key, scene, auth_headers):
"""Right printer, wrong port: drifted, not matching.
The queue carries the assigned printer's name but prints to an address that
is not that printer's. Called matching, the bay reads as converged while its
jobs come out somewhere else - the failure that is invisible from the server
and obvious to whoever is standing at the machine.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_NOBODY, isdefault=True),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
classified = _classified(_oneblock(response, BAY_HOST))
assert classified.get('drifted') == {_assetid(scene, 'A')}
assert not classified.get('matching')
def test_a_queue_on_the_wrong_driver_is_drifted(
client, db, collector_key, scene, auth_headers):
"""Right printer, right port, driver nobody assigned: still drifted.
Drift is the whole reason Set-ShopdbPrinters repairs queues instead of only
creating them. A queue left on a driver the register does not name is the
case that prints, badly - wrong tray, wrong duplex, wrong paper - so it must
not read as converged.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, drivername='Some Other Driver',
isdefault=True),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
classified = _classified(_oneblock(response, BAY_HOST))
assert classified.get('drifted') == {_assetid(scene, 'A')}
assert not classified.get('matching')
def test_port_address_beats_a_colliding_queue_name(
client, db, collector_key, scene, auth_headers):
"""When the two match keys disagree, the address wins.
A queue name is a convention a technician typed; an address identifies a
device. A bay that named its queue after one printer while pointing it at
another is precisely the mistake this view exists to surface, and matching
on the name would report the mistake as agreement.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [_queue('PRINTER-B', ADDRESS_A, isdefault=True)])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
block = _oneblock(response, BAY_HOST)
resolved = {row.get('queuename'): row.get('printerassetid')
for row in (block.get('queues') or [])}
assert resolved.get('PRINTER-B') == _assetid(scene, 'A')
def test_a_queue_matching_no_printer_is_unknown_not_guessed(
client, db, collector_key, scene, auth_headers):
"""No match is reported as no match.
Nothing in ShopDB carries this name or this address. A fuzzy fallback that
reached for the nearest printer would put a wrong assetid in front of a
reviewer, and that reviewer's next click writes it into an assignment the
client then installs on every cycle. Unknown costs one conversation; a wrong
match costs a bay.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
_queue('Reception Copier', ADDRESS_NOBODY),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
block = _oneblock(response, BAY_HOST)
classified = _classified(block)
assert classified.get('unknown') == {'Reception Copier'}
# Unknown is not a quiet flavour of extra: extra means "resolved to a
# printer nobody assigned", which is a different conversation.
assert 'Reception Copier' not in classified.get('extra', set())
unmatched = next(row for row in block['queues']
if row.get('queuename') == 'Reception Copier')
assert unmatched.get('printerassetid') is None
def test_a_machine_answers_per_controlling_host(
client, db, collector_key, scene, auth_headers):
"""The assignment lives on the machine; the observations live on the PCs.
A dualpath pair or a part marker legitimately puts two PCs on one machine.
Merging their queues into one list would hide WHICH bay drifted, and the
only actionable thing about drift is which box to walk to.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
_controls(db, scene, scene['secondbaypc'])
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A, isdefault=True)])
_report(client, SECOND_BAY_HOST, [_queue('PRINTER-C', ADDRESS_C)])
response = client.get(OBSERVED_URL % scene['machine'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
blocks = _hostblocks(response)
assert {BAY_HOST.lower(), SECOND_BAY_HOST.lower()} <= set(blocks)
converged = _classified(blocks[BAY_HOST.lower()])
drifted = _classified(blocks[SECOND_BAY_HOST.lower()])
assert converged.get('matching') == {_assetid(scene, 'A')}
assert drifted.get('missing') == {_assetid(scene, 'A')}
assert drifted.get('extra') == {_assetid(scene, 'C')}
def test_comparison_reports_when_the_host_last_reported(
client, db, collector_key, scene, auth_headers):
"""A block with no timestamp cannot be trusted.
Observed state is only ever as good as its age: a bay that stopped reporting
six months ago and a bay that reported this morning produce identical
comparisons, and only the timestamp tells a reviewer which one is worth
acting on.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A, isdefault=True)])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
block = _oneblock(response, BAY_HOST)
# Server-stamped at ingest, so a bay with a wrong clock cannot report itself
# fresh. Either spelling of the key is the ingest stamp.
stamp = block.get('reportedat') or block.get('observedat')
assert stamp, 'no report timestamp on the host block: %s' % sorted(block)
def test_a_host_that_has_never_reported_is_empty_not_an_error(
client, db, scene, auth_headers):
"""Silence is a legitimate answer.
Most PCs will not have reported yet the day this ships. A 404 or a 500 here
would break the asset page for every one of them, and the page is where the
assignment is edited.
"""
_assign(db, scene, scene['machine'], ['A'], default='A')
_controls(db, scene, scene['baypc'])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
classified = _classified(_oneblock(response, BAY_HOST))
# Everything assigned is missing, and nothing was observed.
assert classified.get('missing') == {_assetid(scene, 'A')}
assert not classified.get('matching')
assert not classified.get('extra')
assert not classified.get('unknown')
# ---------------------------------------------------------------------------
# Seeding an assignment from what was observed
# ---------------------------------------------------------------------------
def test_seedcandidate_offers_matched_queues_and_names_what_it_skipped(
client, db, collector_key, scene, auth_headers):
"""The seed is a proposal made of matches only, and it says what it left out.
Unknown queues are never seeded - that is the never-guess rule reaching the
write path. But dropping them silently is its own failure: the reviewer sees
a short list, assumes the bay only has those, and the real queue goes
unrecorded with nothing anywhere saying it was skipped.
"""
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
_queue('Reception Copier', ADDRESS_NOBODY),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
seed = _seedcandidate(_oneblock(response, BAY_HOST))
assert list(seed['printerassetids']) == [_assetid(scene, 'A')]
assert 'reception copier' in _skippedtext(seed)
def test_seedcandidate_leaves_the_default_unset_when_it_cannot_be_matched(
client, db, collector_key, scene, auth_headers):
"""An unmatched default seeds no default at all.
The alternative is picking one of the matched queues so the field is not
blank, which would change a user's default printer on the strength of a
guess. No default is a state the client already handles quietly; a wrong one
is a support call.
"""
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A),
_queue('Reception Copier', ADDRESS_NOBODY, isdefault=True),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
seed = _seedcandidate(_oneblock(response, BAY_HOST))
assert list(seed['printerassetids']) == [_assetid(scene, 'A')]
assert seed['defaultprinterassetid'] is None
def test_seedcandidate_carries_the_default_when_it_matched(
client, db, collector_key, scene, auth_headers):
"""A matched default is offered, so the common case is one click.
If the default were never proposed, every seeded bay would come back later
for a second edit, and the half-seeded assignments in between are exactly
the state that makes the register untrustworthy.
"""
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
_queue('PRINTER-B', ADDRESS_B),
])
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
seed = _seedcandidate(_oneblock(response, BAY_HOST))
assert set(seed['printerassetids']) == {_assetid(scene, 'A'),
_assetid(scene, 'B')}
assert seed['defaultprinterassetid'] == _assetid(scene, 'A')
def test_reading_the_seedcandidate_writes_no_assignment(
client, db, collector_key, scene, auth_headers):
"""Offering is not applying.
A candidate that wrote itself on read would make every visit to an asset
page adopt whatever that bay happened to have - the observed side quietly
becoming the assigned side, which is the one thing this design forbids.
"""
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A, isdefault=True)])
before = _assignment_rows(scene)
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert response.status_code == 200, response.get_json()
assert _assignment_rows(scene) == before
stored = client.get(ASSIGN_URL % scene['machine'].assetid,
headers=auth_headers)
assert stored.get_json()['data']['printerassetids'] == []
def test_seeding_writes_the_assignment_only_when_a_person_saves_it(
client, db, collector_key, scene, auth_headers):
"""The seed is saved through the one existing write path, by hand.
Routing it through PUT /api/printers/assignments/for-asset keeps a single
place where an assignment is written, so the reconcile rules - the
default-must-be-in-the-set check, the soft delete, the printer-type
validation - cannot be bypassed by a seed that grew its own endpoint.
"""
_controls(db, scene, scene['baypc'])
_report(client, BAY_HOST, [
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
_queue('PRINTER-B', ADDRESS_B),
_queue('Reception Copier', ADDRESS_NOBODY),
])
observed = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
assert observed.status_code == 200, observed.get_json()
seed = _seedcandidate(_oneblock(observed, BAY_HOST))
# Seeded onto the MACHINE, which is where an assignment belongs: seeding the
# PC would create own rows that permanently shadow the bay's and quietly
# defeat reimage inheritance.
saved = client.put(ASSIGN_URL % scene['machine'].assetid,
headers=auth_headers,
json={'printerassetids': list(seed['printerassetids']),
'defaultprinterassetid': seed['defaultprinterassetid']})
assert saved.status_code == 200, saved.get_json()
resolved = client.get(HOST_URL % BAY_HOST)
installed = resolved.get_json()['data']['printers']
assert {row['assetid'] for row in installed} == {_assetid(scene, 'A'),
_assetid(scene, 'B')}
default = [row['assetid'] for row in installed if row['isdefault']]
assert default == [_assetid(scene, 'A')]
# The queue that matched nothing is still not an assignment, and the bay
# still reports it - drift stays visible instead of being adopted.
after = client.get(OBSERVED_URL % scene['baypc'].assetid,
headers=auth_headers)
classified = _classified(_oneblock(after, BAY_HOST))
assert classified.get('unknown') == {'Reception Copier'}
def test_a_host_that_changes_spelling_does_not_double_its_queues(client, db,
scene,
collector_key):
"""The replace must cover every spelling of one host.
A PC enrolled short can later report its FQDN, or the other way round. The
READ path already treats those as the same machine, so a delete matching
only the exact string left the other spelling's rows behind, and the bay
appeared to have every queue twice - which reads as drift that is not there,
and would be adopted as a duplicate assignment.
"""
_report(client, 'OBSPC01', [_queue('CSF01-HP', address='10.0.0.5')])
_report(client, 'obspc01.example.net', [_queue('CSF01-HP', address='10.0.0.5')])
# Counted across BOTH spellings, because the second report is stored under
# the name it sent. What must be true is that one physical host holds one
# row set, whichever spelling it last used.
held = PrinterObservedQueue.query.filter(
PrinterObservedQueue.hostname.ilike('obspc01%')).all()
assert [row.queuename for row in held] == ['CSF01-HP'], (
'the same queue was stored twice under two spellings of one host')