diff --git a/docs/api-inventory.json b/docs/api-inventory.json index 679e2db..b38ee5c 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -2888,6 +2888,22 @@ "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/", + "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": "PUT", + "path": "/api/printers/assignments/for-asset/", + "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": "GET", "path": "/api/printers/", diff --git a/docs/openapi.json b/docs/openapi.json index 5f86613..fc9b273 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -16755,6 +16755,115 @@ } } }, + "/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/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)" + } + } + } + } + } + }, "/api/printers/{printer_id}": { "get": { "tags": [ diff --git a/docs/proposals/printer-assignment.md b/docs/proposals/printer-assignment.md index b6d466b..9295a02 100644 --- a/docs/proposals/printer-assignment.md +++ b/docs/proposals/printer-assignment.md @@ -1,6 +1,8 @@ -# Proposal: assign printers to a PC in ShopDB, let the PC install them +# Proposal: assign printers to a machine in ShopDB, let the PC install them -Status: PROPOSED. Not built. +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 @@ -10,20 +12,27 @@ 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: edit a PC in ShopDB, tick the printers -that belong on it, mark one default. 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. +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 bay 2107 have" is a +- **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. +- **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 @@ -37,6 +46,8 @@ 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/` | | Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site | @@ -46,20 +57,114 @@ a project. | 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. What has to be built +## 4. The data model -### 4.1 One endpoint +### 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/ ``` -Returns the printers assigned to that PC and which is default, each with what a -client needs to install it: queue name, host or IP, port, driver name, driver -location. +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. +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/ +``` + +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 @@ -67,22 +172,20 @@ 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`. -### 4.2 Two columns on `printerdrivers` +### 5.3 UI on the PC form -- `drivername` - the driver's exact name as the INF declares it, e.g. - `HP Universal Printing PCL 6`. `Add-PrinterDriver` needs it verbatim, 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. -- `installmethod` - `pnputil` or `dpinst`. See section 6: if Brother really is - absent from the fleet, everything is `pnputil` and this column can wait. +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. -### 4.3 UI on the PC form +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. -A printer picker writing `defaultprinter` (one) and an assignment list (many). -`AssetRelationships.vue` already edits relationships; this is a narrowed case of -it. +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. -### 4.4 One client script, in two contexts +### 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`. @@ -108,7 +211,32 @@ 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. -## 5. Decisions to take before writing code +## 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. @@ -119,7 +247,33 @@ desired state is fetched, not declared. 3. **Failure is silent and safe**: unreachable API means change nothing, log, exit 0 - the convention `Report-AssetToShopDB.ps1` already follows. -## 6. What the fleet data says, and the one prerequisite +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. @@ -130,7 +284,7 @@ The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1. 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. + 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. @@ -138,10 +292,10 @@ The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1. 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. Nothing in this proposal works until a printer can resolve -to a driver. +payload is restored. Each needs `drivername` copied verbatim from its INF. +Nothing in this proposal works until a printer can resolve to a driver. -## 7. Deployment constraint that shapes the design +## 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 @@ -152,10 +306,26 @@ 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. -## 8. What this does not change +## 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. +- 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. diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue index 408dfbd..b4593e1 100644 --- a/plugins/computers/frontend/views/PCForm.vue +++ b/plugins/computers/frontend/views/PCForm.vue @@ -245,6 +245,66 @@ + + +