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.
This commit is contained in:
@@ -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.",
|
"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'"
|
"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": "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": "GET",
|
"method": "GET",
|
||||||
"path": "/api/printers/<printer_id>",
|
"path": "/api/printers/<printer_id>",
|
||||||
|
|||||||
@@ -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}": {
|
"/api/printers/{printer_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -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.
|
Author: planning session 2026-08-18.
|
||||||
|
|
||||||
## 1. What this is
|
## 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
|
fine for someone choosing a printer, and wrong for a bay whose printers are a
|
||||||
property of the bay.
|
property of the bay.
|
||||||
|
|
||||||
This proposal makes the assignment data: edit a PC in ShopDB, tick the printers
|
This proposal makes the assignment data. The printers belong to the MACHINE, not
|
||||||
that belong on it, mark one default. The PC converges on its next GE-Enforce
|
to the box currently driving it: tick the printers that belong on the machine,
|
||||||
cycle - installing what is missing and setting the default - and keeps
|
mark one default, and the assignment reaches whichever PC controls that machine.
|
||||||
converging, so a reimaged bay comes back with its printers and a bay that drifts
|
The PC converges on its next GE-Enforce cycle - installing what is missing and
|
||||||
is corrected.
|
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
|
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.
|
unmanaged or office PC picking a printer that nobody assigned.
|
||||||
|
|
||||||
## 2. Why it is worth doing
|
## 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.
|
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
|
- **Drift is corrected, not just detected.** A queue deleted by a user comes
|
||||||
back.
|
back.
|
||||||
- **It removes the walk-up from the common case.** The installer's map remains
|
- **It removes the walk-up from the common case.** The installer's map remains
|
||||||
@@ -37,6 +46,8 @@ a project.
|
|||||||
| piece | state |
|
| piece | state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
|
| 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` |
|
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
|
||||||
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
|
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
|
||||||
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
|
| 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 |
|
| 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` |
|
| 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/<hostname>
|
GET /api/printers/for-host/<hostname>
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the printers assigned to that PC and which is default, each with what a
|
The desired printer set for one PC, resolved per section 4.3, each entry with
|
||||||
client needs to install it: queue name, host or IP, port, driver name, driver
|
what a client needs to install it: queue name, host or IP, port, driver name,
|
||||||
location.
|
driver location, and which one is default.
|
||||||
|
|
||||||
Resolved by hostname, not machine number: the collector already upserts PCs by
|
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/<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
|
**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
|
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
|
script that asks the API what THIS host gets - the mirror image of
|
||||||
`Report-AssetToShopDB.ps1`.
|
`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.
|
A printer multi-select plus a default dropdown whose options are only the
|
||||||
`HP Universal Printing PCL 6`. `Add-PrinterDriver` needs it verbatim, and a
|
currently selected printers, clearing itself when its printer is deselected.
|
||||||
mismatch is the usual failure. Deriving it by parsing the INF on hundreds of
|
Saved through the reconcile endpoint against the PC's asset.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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).
|
The machine-side picker is out of scope for now, which means the machine's
|
||||||
`AssetRelationships.vue` already edits relationships; this is a narrowed case of
|
assignment is editable only through the generic relationships card. That is the
|
||||||
it.
|
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
|
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
|
||||||
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
|
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
|
Nothing here needs the manifest to know when a printer changes, because the
|
||||||
desired state is fetched, not declared.
|
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
|
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.
|
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,
|
3. **Failure is silent and safe**: unreachable API means change nothing, log,
|
||||||
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
|
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.
|
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
|
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
|
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
|
(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
|
- **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.
|
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
|
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
|
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
|
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
|
payload is restored. Each needs `drivername` copied verbatim from its INF.
|
||||||
to a driver.
|
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
|
**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
|
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"
|
does not, and why "the assignment script schedules a task that installs drivers"
|
||||||
is the wrong shape.
|
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.
|
- The printer installer keeps working, for walk-up and self-service.
|
||||||
- Nothing about how printers are modelled, mapped or reported.
|
- 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
|
- 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.
|
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.
|
||||||
|
|||||||
@@ -245,6 +245,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Printer assignment. Written as plain asset relationships, so this
|
||||||
|
section is absent at a site without the printers plugin. -->
|
||||||
|
<template v-if="printersEnabled">
|
||||||
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printers</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="printersearch">Assigned Printers</label>
|
||||||
|
<input
|
||||||
|
id="printersearch"
|
||||||
|
v-model="printerSearch"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Filter printers..."
|
||||||
|
/>
|
||||||
|
<div class="printer-list">
|
||||||
|
<label
|
||||||
|
v-for="printerAsset in filteredPrinters"
|
||||||
|
:key="printerAsset.assetid"
|
||||||
|
class="printer-item"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isPrinterAssigned(printerAsset.assetid)"
|
||||||
|
@change="togglePrinter(printerAsset.assetid, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ printerLabel(printerAsset) }}</span>
|
||||||
|
<span v-if="printerAsset.printer?.modelname" class="printer-meta">
|
||||||
|
{{ printerAsset.printer.modelname }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span v-if="!filteredPrinters.length" class="muted">No printers match.</span>
|
||||||
|
</div>
|
||||||
|
<small class="form-hint">
|
||||||
|
{{ assignedPrinters.length }} assigned. Printers ticked here belong to this PC
|
||||||
|
and take the place of any assigned to the machine it controls.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="defaultprinterassetid">Default Printer</label>
|
||||||
|
<select
|
||||||
|
id="defaultprinterassetid"
|
||||||
|
v-model="defaultPrinterAssetId"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<option :value="null">No default</option>
|
||||||
|
<option
|
||||||
|
v-for="printerAsset in assignedPrinters"
|
||||||
|
:key="printerAsset.assetid"
|
||||||
|
:value="printerAsset.assetid"
|
||||||
|
>
|
||||||
|
{{ printerLabel(printerAsset) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-hint">
|
||||||
|
Optional, and only ever one of the printers assigned above.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -317,7 +377,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
from '@/composables/mapConfig'
|
from '@/composables/mapConfig'
|
||||||
@@ -325,8 +386,11 @@ import Modal from '@/components/Modal.vue'
|
|||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||||
|
import { useToast } from '@/composables/toast'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
const { isEnabled } = useIdentifierFlags()
|
const { isEnabled } = useIdentifierFlags()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -412,6 +476,191 @@ const models = ref([])
|
|||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
const operatingsystems = ref([])
|
const operatingsystems = ref([])
|
||||||
|
|
||||||
|
// Printer assignment. The rows are ordinary asset relationships - usesprinter
|
||||||
|
// for "installed here", defaultprinter for which of them wins - so the picker
|
||||||
|
// reads and writes them through the generic relationship endpoints.
|
||||||
|
const printersEnabled = ref(false)
|
||||||
|
const printers = ref([])
|
||||||
|
const printerSearch = ref('')
|
||||||
|
const assignedPrinterAssetIds = ref([])
|
||||||
|
const defaultPrinterAssetId = ref(null)
|
||||||
|
const printerRelationshipTypes = ref({ usesprinter: null, defaultprinter: null })
|
||||||
|
// The rows as loaded, so saving writes only what actually changed.
|
||||||
|
const existingPrinterRelationships = ref([])
|
||||||
|
|
||||||
|
function printerLabel(printerAsset) {
|
||||||
|
const name = printerAsset.name && printerAsset.name.toUpperCase() !== 'NONE'
|
||||||
|
? printerAsset.name
|
||||||
|
: null
|
||||||
|
return name || printerAsset.printer?.hostname || printerAsset.assetnumber
|
||||||
|
|| `Asset ${printerAsset.assetid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPrinters = computed(() =>
|
||||||
|
[...printers.value].sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||||
|
|
||||||
|
const filteredPrinters = computed(() => {
|
||||||
|
const term = printerSearch.value.trim().toLowerCase()
|
||||||
|
if (!term) return sortedPrinters.value
|
||||||
|
return sortedPrinters.value.filter(printerAsset => {
|
||||||
|
const haystack = [
|
||||||
|
printerLabel(printerAsset),
|
||||||
|
printerAsset.assetnumber || '',
|
||||||
|
printerAsset.printer?.modelname || ''
|
||||||
|
].join(' ').toLowerCase()
|
||||||
|
return haystack.includes(term)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drives the default dropdown, so the default can only ever be one of the
|
||||||
|
// assigned printers. A printer filtered out of the list above is still here.
|
||||||
|
const assignedPrinters = computed(() =>
|
||||||
|
assignedPrinterAssetIds.value
|
||||||
|
.map(assetid => printers.value.find(printerAsset => printerAsset.assetid === assetid))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||||
|
|
||||||
|
function isPrinterAssigned(assetid) {
|
||||||
|
return assignedPrinterAssetIds.value.includes(assetid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrinter(assetid, on) {
|
||||||
|
if (on) {
|
||||||
|
if (!isPrinterAssigned(assetid)) {
|
||||||
|
assignedPrinterAssetIds.value = [...assignedPrinterAssetIds.value, assetid]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assignedPrinterAssetIds.value = assignedPrinterAssetIds.value.filter(id => id !== assetid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassigning the printer that is currently default clears the default rather
|
||||||
|
// than leaving one pointing at a printer this PC no longer has.
|
||||||
|
watch(assignedPrinterAssetIds, (assetids) => {
|
||||||
|
if (defaultPrinterAssetId.value && !assetids.includes(defaultPrinterAssetId.value)) {
|
||||||
|
defaultPrinterAssetId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Printer list + the two relationship type ids. Both types are seed data
|
||||||
|
// (flask seed reference-data); without them there is nothing to write, so the
|
||||||
|
// section stays hidden rather than offering a control that cannot save.
|
||||||
|
async function loadPrinterOptions() {
|
||||||
|
try {
|
||||||
|
await loadEnabledPlugins()
|
||||||
|
if (!isPluginEnabled('printers')) return
|
||||||
|
const [printerRows, typeResponse] = await Promise.all([
|
||||||
|
printersApi.listAll(),
|
||||||
|
relationshipTypesApi.list()
|
||||||
|
])
|
||||||
|
const types = typeResponse.data.data || []
|
||||||
|
const typeIdFor = (name) =>
|
||||||
|
types.find(t => t.relationshiptype === name)?.relationshiptypeid || null
|
||||||
|
printerRelationshipTypes.value = {
|
||||||
|
usesprinter: typeIdFor('usesprinter'),
|
||||||
|
defaultprinter: typeIdFor('defaultprinter')
|
||||||
|
}
|
||||||
|
printers.value = printerRows || []
|
||||||
|
printersEnabled.value = !!(printerRelationshipTypes.value.usesprinter
|
||||||
|
&& printerRelationshipTypes.value.defaultprinter)
|
||||||
|
} catch (printerError) {
|
||||||
|
console.error('Error loading printers:', printerError)
|
||||||
|
printersEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrinterAssignments(assetid) {
|
||||||
|
if (!printersEnabled.value || !assetid) return
|
||||||
|
try {
|
||||||
|
const response = await assetsApi.getRelationships(assetid)
|
||||||
|
const types = printerRelationshipTypes.value
|
||||||
|
existingPrinterRelationships.value = (response.data.data?.outgoing || []).filter(
|
||||||
|
rel => rel.relationshiptypeid === types.usesprinter
|
||||||
|
|| rel.relationshiptypeid === types.defaultprinter
|
||||||
|
)
|
||||||
|
assignedPrinterAssetIds.value = existingPrinterRelationships.value
|
||||||
|
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||||
|
.map(rel => rel.targetassetid)
|
||||||
|
const currentDefault = existingPrinterRelationships.value
|
||||||
|
.find(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||||
|
defaultPrinterAssetId.value = currentDefault ? currentDefault.targetassetid : null
|
||||||
|
// Defaults set before this form existed have no usesprinter row. Show that
|
||||||
|
// printer as assigned: a default missing from the list reads as data loss,
|
||||||
|
// and saving then writes the row that was never there.
|
||||||
|
if (defaultPrinterAssetId.value
|
||||||
|
&& !assignedPrinterAssetIds.value.includes(defaultPrinterAssetId.value)) {
|
||||||
|
assignedPrinterAssetIds.value = [
|
||||||
|
...assignedPrinterAssetIds.value, defaultPrinterAssetId.value
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// /printers lists active printers only, so a retired one that is still
|
||||||
|
// assigned would be missing from every control on this form - unable to be
|
||||||
|
// unticked, and blank in the default box. The relationship carries the
|
||||||
|
// asset, so add it to the list it fell out of.
|
||||||
|
for (const rel of existingPrinterRelationships.value) {
|
||||||
|
const target = rel.targetasset
|
||||||
|
if (target && !printers.value.some(known => known.assetid === target.assetid)) {
|
||||||
|
printers.value = [...printers.value, target]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (printerError) {
|
||||||
|
console.error('Error loading printer assignment:', printerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the PC's own printer rows against the picker. Row at a time
|
||||||
|
// through the generic relationship endpoints - there is no single assignment
|
||||||
|
// endpoint yet - so the order matters: the outgoing default goes before the
|
||||||
|
// incoming one lands, because the unique key is (source, target, type) and
|
||||||
|
// would let two different defaults sit side by side. Re-creating a row that
|
||||||
|
// was removed earlier is safe; the create path reactivates the soft-deleted
|
||||||
|
// one instead of inserting a duplicate.
|
||||||
|
async function savePrinterAssignments(assetid) {
|
||||||
|
const types = printerRelationshipTypes.value
|
||||||
|
const assigned = assignedPrinterAssetIds.value
|
||||||
|
const wanteddefault = defaultPrinterAssetId.value
|
||||||
|
|
||||||
|
const assignedRows = existingPrinterRelationships.value
|
||||||
|
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||||
|
const defaultRows = existingPrinterRelationships.value
|
||||||
|
.filter(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Removing an assignment removes the row and nothing else. It never
|
||||||
|
// uninstalls a queue anywhere.
|
||||||
|
for (const rel of assignedRows) {
|
||||||
|
if (!assigned.includes(rel.targetassetid)) {
|
||||||
|
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const printerassetid of assigned) {
|
||||||
|
if (!assignedRows.some(rel => rel.targetassetid === printerassetid)) {
|
||||||
|
await assetsApi.createRelationship({
|
||||||
|
sourceassetid: assetid,
|
||||||
|
targetassetid: printerassetid,
|
||||||
|
relationshiptypeid: types.usesprinter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const rel of defaultRows) {
|
||||||
|
if (rel.targetassetid !== wanteddefault) {
|
||||||
|
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wanteddefault && !defaultRows.some(rel => rel.targetassetid === wanteddefault)) {
|
||||||
|
await assetsApi.createRelationship({
|
||||||
|
sourceassetid: assetid,
|
||||||
|
targetassetid: wanteddefault,
|
||||||
|
relationshiptypeid: types.defaultprinter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Part of the reconcile may have landed, so what the form believes is
|
||||||
|
// stored has to come from the server before anyone saves again.
|
||||||
|
await loadPrinterAssignments(assetid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||||
watch(() => form.value.serialnumber, (serial) => {
|
watch(() => form.value.serialnumber, (serial) => {
|
||||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||||
@@ -438,7 +687,9 @@ onMounted(async () => {
|
|||||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
locationsApi.list({ perpage: 100 }),
|
locationsApi.list({ perpage: 100 }),
|
||||||
operatingsystemsApi.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.
|
||||||
|
loadPrinterOptions()
|
||||||
])
|
])
|
||||||
|
|
||||||
pcTypes.value = ptRes.data.data || []
|
pcTypes.value = ptRes.data.data || []
|
||||||
@@ -482,6 +733,8 @@ onMounted(async () => {
|
|||||||
levelid: pc.levelid ?? null,
|
levelid: pc.levelid ?? null,
|
||||||
ipaddress: primaryComm?.ipaddress || ''
|
ipaddress: primaryComm?.ipaddress || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadPrinterAssignments(currentAssetId.value)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading data:', err)
|
console.error('Error loading data:', err)
|
||||||
@@ -570,6 +823,19 @@ async function savePC() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 a printer
|
||||||
|
// assignment that quietly did not happen is the bug this feature exists to
|
||||||
|
// stop, so it has to be said out loud.
|
||||||
|
if (assetId && printersEnabled.value) {
|
||||||
|
try {
|
||||||
|
await savePrinterAssignments(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')
|
router.push('/pcs')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving PC:', err)
|
console.error('Error saving PC:', err)
|
||||||
@@ -603,6 +869,30 @@ async function savePC() {
|
|||||||
color: var(--text-light);
|
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 {
|
.map-location-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -638,6 +638,464 @@ def pc_default_printer():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Printer assignment resolution (which printers belong on a PC)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# The assignment edges. usesprinter says a printer is installed here;
|
||||||
|
# defaultprinter says which of them Windows should default to.
|
||||||
|
_USES_PRINTER = 'usesprinter'
|
||||||
|
_DEFAULT_PRINTER = 'defaultprinter'
|
||||||
|
_CONTROLS = 'controls'
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_typeids(*names):
|
||||||
|
"""{name: [relationshiptypeid, ...]} for the named relationship types.
|
||||||
|
|
||||||
|
A list per name, not an id: MySQL's default collation is case-insensitive,
|
||||||
|
so a legacy 'Controls' row lives happily beside 'controls' and a walk that
|
||||||
|
picked one of them would silently miss half the data. Names absent from the
|
||||||
|
table map to an empty list, which resolves to no printers rather than an
|
||||||
|
error - an un-seeded database is a deployment step missed, not a bad request.
|
||||||
|
"""
|
||||||
|
wanted = {name.lower(): [] for name in names}
|
||||||
|
rows = RelationshipType.query.filter(
|
||||||
|
RelationshipType.relationshiptype.in_(names)).all()
|
||||||
|
for row in rows:
|
||||||
|
key = (row.relationshiptype or '').lower()
|
||||||
|
if key in wanted:
|
||||||
|
wanted[key].append(row.relationshiptypeid)
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
def _outgoing_rows(assetid, typeids):
|
||||||
|
"""Active outgoing relationships of the given types, oldest first."""
|
||||||
|
if not typeids:
|
||||||
|
return []
|
||||||
|
return (AssetRelationship.query
|
||||||
|
.filter(AssetRelationship.sourceassetid == assetid,
|
||||||
|
AssetRelationship.relationshiptypeid.in_(typeids),
|
||||||
|
AssetRelationship.isactive == True)
|
||||||
|
.order_by(AssetRelationship.relationshipid)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
|
||||||
|
def _own_assignment(assetid, typeids):
|
||||||
|
"""One asset's OWN assignment: (ordered printer assetids, default assetid).
|
||||||
|
|
||||||
|
On an asset with NO usesprinter rows, a defaultprinter row is the whole
|
||||||
|
assignment. Those rows predate this feature - the installer preselect and
|
||||||
|
the collector both write them - and ignoring them would take printers away
|
||||||
|
from every PC recorded before assignment existed. Once an asset has
|
||||||
|
usesprinter rows it is managed, and a default outside that set is stale
|
||||||
|
rather than legacy, so it is dropped by _assignment_result.
|
||||||
|
|
||||||
|
Two active defaults cannot be prevented by the schema - the unique
|
||||||
|
constraint is (source, target, type) - so the oldest row wins and the rest
|
||||||
|
are ignored, which at least makes the answer the same on every read.
|
||||||
|
"""
|
||||||
|
printerassetids = []
|
||||||
|
for rel in _outgoing_rows(assetid, typeids[_USES_PRINTER]):
|
||||||
|
if rel.targetassetid not in printerassetids:
|
||||||
|
printerassetids.append(rel.targetassetid)
|
||||||
|
ismanaged = bool(printerassetids)
|
||||||
|
|
||||||
|
defaultassetid = None
|
||||||
|
for rel in _outgoing_rows(assetid, typeids[_DEFAULT_PRINTER]):
|
||||||
|
if not ismanaged and rel.targetassetid not in printerassetids:
|
||||||
|
printerassetids.append(rel.targetassetid)
|
||||||
|
if defaultassetid is None:
|
||||||
|
defaultassetid = rel.targetassetid
|
||||||
|
|
||||||
|
return printerassetids, defaultassetid
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_asset_printers(asset):
|
||||||
|
"""Which printers an asset gets, and which one is default.
|
||||||
|
|
||||||
|
Own rows first; only when the asset has none does the walk follow its
|
||||||
|
outgoing controls edges one hop and take the assignment of whatever it
|
||||||
|
controls.
|
||||||
|
|
||||||
|
THE INHERITANCE IS THE FEATURE. Printers are a property of the bay, not of
|
||||||
|
the box sat next to it: the machine holds the assignment, and whichever PC
|
||||||
|
controls that machine picks it up. So a PC that is reimaged, or swapped for
|
||||||
|
a different chassis entirely, resolves the same printers on its next cycle
|
||||||
|
with nothing backed up and nothing restored. A PC that controls no machine -
|
||||||
|
an office PC - has only its own rows, which is the same code path with an
|
||||||
|
empty walk.
|
||||||
|
|
||||||
|
A PC's own rows SHADOW what it would inherit rather than adding to it, so a
|
||||||
|
one-off printer on a bay PC is expressed by assigning that PC everything it
|
||||||
|
should have, not by hoping two sets merge.
|
||||||
|
|
||||||
|
Returns {'assignments': [{'assetid', 'isdefault', 'inheritedfromassetid'}],
|
||||||
|
'source': 'self' | 'inherited' | 'none'}.
|
||||||
|
"""
|
||||||
|
assetid = getattr(asset, 'assetid', None)
|
||||||
|
if assetid is None:
|
||||||
|
return {'assignments': [], 'source': 'none'}
|
||||||
|
|
||||||
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
||||||
|
|
||||||
|
printerassetids, defaultassetid = _own_assignment(assetid, typeids)
|
||||||
|
if printerassetids:
|
||||||
|
return _assignment_result(printerassetids, defaultassetid, None)
|
||||||
|
|
||||||
|
# Nothing of its own: take the bay's. Outgoing controls only (PC -> machine,
|
||||||
|
# the direction `flask relationships fix-controls-direction` enforces).
|
||||||
|
inherited = []
|
||||||
|
defaults = []
|
||||||
|
suppliers = {}
|
||||||
|
for rel in _outgoing_rows(assetid, typeids[_CONTROLS]):
|
||||||
|
machine = rel.targetasset
|
||||||
|
if machine is None or not getattr(machine, 'isactive', True):
|
||||||
|
continue
|
||||||
|
machineprinters, machinedefault = _own_assignment(machine.assetid, typeids)
|
||||||
|
for printerassetid in machineprinters:
|
||||||
|
if printerassetid not in inherited:
|
||||||
|
inherited.append(printerassetid)
|
||||||
|
suppliers[printerassetid] = machine.assetid
|
||||||
|
if machinedefault is not None and machinedefault not in defaults:
|
||||||
|
defaults.append(machinedefault)
|
||||||
|
|
||||||
|
if not inherited:
|
||||||
|
return {'assignments': [], 'source': 'none'}
|
||||||
|
|
||||||
|
# A PC controlling several machines (or both bays of a dualpath pair) can
|
||||||
|
# inherit two different defaults. Union the printers, but refuse to guess a
|
||||||
|
# default: no default is a state the client already handles, a coin toss is
|
||||||
|
# not.
|
||||||
|
if len(defaults) > 1:
|
||||||
|
logger.warning(
|
||||||
|
'Asset %s inherits %d conflicting default printers; leaving default unset',
|
||||||
|
assetid, len(defaults))
|
||||||
|
inheriteddefault = None
|
||||||
|
else:
|
||||||
|
inheriteddefault = defaults[0] if defaults else None
|
||||||
|
|
||||||
|
return _assignment_result(inherited, inheriteddefault, suppliers)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_result(printerassetids, defaultassetid, suppliers):
|
||||||
|
"""Shape the resolver's answer. suppliers is None for an asset's own rows."""
|
||||||
|
# Settled rule: the default must be one of the assigned printers. A dangling
|
||||||
|
# default happens when a printer is unassigned through the generic
|
||||||
|
# relationships card, which knows nothing about this pairing.
|
||||||
|
if defaultassetid not in printerassetids:
|
||||||
|
defaultassetid = None
|
||||||
|
return {
|
||||||
|
'assignments': [{
|
||||||
|
'assetid': printerassetid,
|
||||||
|
'isdefault': printerassetid == defaultassetid,
|
||||||
|
'inheritedfromassetid': (suppliers or {}).get(printerassetid),
|
||||||
|
} for printerassetid in printerassetids],
|
||||||
|
'source': 'inherited' if suppliers is not None else 'self',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _printer_driver(printer, universaldrivers):
|
||||||
|
"""Driver record to install this printer with, or None.
|
||||||
|
|
||||||
|
The printer's own model link first. Failing that, a driver with no model at
|
||||||
|
all whose name carries the printer's vendor: HP and Xerox universal drivers
|
||||||
|
cover the overwhelming majority of a floor, and per-model rows for each
|
||||||
|
queue are a table nobody keeps true. printerdrivers cannot name a vendor of
|
||||||
|
its own yet, so the vendor word in the driver's name is what there is.
|
||||||
|
"""
|
||||||
|
if printer.modelnumberid:
|
||||||
|
driver = (PrinterDriver.query
|
||||||
|
.filter_by(modelnumberid=printer.modelnumberid, isactive=True)
|
||||||
|
.order_by(PrinterDriver.name).first())
|
||||||
|
if driver:
|
||||||
|
return driver
|
||||||
|
|
||||||
|
vendor = _printer_vendor(printer).lower()
|
||||||
|
if not vendor:
|
||||||
|
return None
|
||||||
|
for driver in universaldrivers:
|
||||||
|
if vendor in (driver.name or '').lower():
|
||||||
|
return driver
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _computer_by_hostname(hostname):
|
||||||
|
"""Active computer asset matching a reported hostname, or None.
|
||||||
|
|
||||||
|
Case-folded on both sides: COMPUTERNAME arrives uppercase, MySQL forgives
|
||||||
|
that and SQLite does not, so an uncompared case would work in production and
|
||||||
|
fail in the tests (or the other way round on a binary collation).
|
||||||
|
|
||||||
|
A short name also matches a stored FQDN, and an FQDN matches a stored short
|
||||||
|
name, because which of the two a site records is a matter of how its PCs
|
||||||
|
were enrolled and the client only ever knows its own COMPUTERNAME.
|
||||||
|
"""
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
|
||||||
|
name = (hostname or '').strip().lower()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = db.session.query(Computer, Asset).join(
|
||||||
|
Asset, Asset.assetid == Computer.assetid).filter(Asset.isactive == True)
|
||||||
|
|
||||||
|
row = query.filter(db.func.lower(Computer.hostname) == name).first()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
|
||||||
|
shortname = name.split('.')[0]
|
||||||
|
if shortname != name:
|
||||||
|
row = query.filter(db.func.lower(Computer.hostname) == shortname).first()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
# Prefix match only for a plain hostname: LIKE wildcards in a path segment
|
||||||
|
# would otherwise let '%' pull back somebody else's printers.
|
||||||
|
if not re.match(r'^[a-z0-9-]+$', shortname):
|
||||||
|
return None
|
||||||
|
return query.filter(
|
||||||
|
db.func.lower(Computer.hostname).like(shortname + '.%')).first()
|
||||||
|
|
||||||
|
|
||||||
|
@printers_asset_bp.route('/for-host/<hostname>', methods=['GET'])
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def printers_for_host(hostname: str):
|
||||||
|
"""Printers assigned to a PC, by hostname, with what it takes to install one.
|
||||||
|
|
||||||
|
The endpoint the convergence client asks on every cycle: give me the state
|
||||||
|
this host should be in. Resolution is own rows, else the assignment of the
|
||||||
|
machine this PC controls (see resolve_asset_printers) - which is why a
|
||||||
|
reimaged bay reinstalls its own printers.
|
||||||
|
|
||||||
|
Resolved by hostname rather than machine number because the collector
|
||||||
|
upserts PCs by hostname and an office PC has no machine number at all.
|
||||||
|
|
||||||
|
404 when the host is unknown. A known host with nothing assigned is an
|
||||||
|
empty list and a null default, not an error: that is the client's no-op.
|
||||||
|
|
||||||
|
Each printer carries queuename (what to call the queue), hostname/ipaddress
|
||||||
|
(where to point the port), port (null means the client's own default raw
|
||||||
|
port), drivername (verbatim from the INF, what Add-PrinterDriver matches on)
|
||||||
|
and driverlocation (where the package lives).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
row = _computer_by_hostname(hostname)
|
||||||
|
except ImportError:
|
||||||
|
# No computers plugin, no way to resolve a hostname to an asset.
|
||||||
|
row = None
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'No computer found with hostname {hostname}',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
computer, asset = row
|
||||||
|
resolved = resolve_asset_printers(asset)
|
||||||
|
assignments = resolved['assignments']
|
||||||
|
|
||||||
|
printers = []
|
||||||
|
if assignments:
|
||||||
|
assetids = [item['assetid'] for item in assignments]
|
||||||
|
rows = (db.session.query(Printer)
|
||||||
|
.join(Asset, Asset.assetid == Printer.assetid)
|
||||||
|
.filter(Printer.assetid.in_(assetids))
|
||||||
|
.filter(Asset.isactive == True)
|
||||||
|
.all())
|
||||||
|
byassetid = {printer.assetid: printer for printer in rows}
|
||||||
|
|
||||||
|
# Fetched once: the universal-driver fallback would otherwise re-read
|
||||||
|
# the same handful of rows per printer.
|
||||||
|
universaldrivers = (PrinterDriver.query
|
||||||
|
.filter(PrinterDriver.modelnumberid.is_(None),
|
||||||
|
PrinterDriver.isactive == True)
|
||||||
|
.order_by(PrinterDriver.name).all())
|
||||||
|
|
||||||
|
for item in assignments:
|
||||||
|
printer = byassetid.get(item['assetid'])
|
||||||
|
if not printer:
|
||||||
|
# Assigned asset is retired, or is not a printer at all.
|
||||||
|
continue
|
||||||
|
printerasset = printer.asset
|
||||||
|
primary = Communication.query.filter_by(
|
||||||
|
assetid=printer.assetid, isprimary=True).first() \
|
||||||
|
or Communication.query.filter_by(assetid=printer.assetid).first()
|
||||||
|
driver = _printer_driver(printer, universaldrivers)
|
||||||
|
printers.append({
|
||||||
|
'printerid': printer.printerid,
|
||||||
|
'assetid': printer.assetid,
|
||||||
|
'queuename': _install_name(printer, printerasset),
|
||||||
|
'windowsname': printer.windowsname,
|
||||||
|
'sharename': printer.sharename,
|
||||||
|
'hostname': printer.hostname,
|
||||||
|
'ipaddress': primary.ipaddress if primary else None,
|
||||||
|
'port': primary.port if primary else None,
|
||||||
|
'driverid': driver.driverid if driver else None,
|
||||||
|
'drivername': driver.drivername if driver else None,
|
||||||
|
'driverlocation': driver.location if driver else None,
|
||||||
|
'installpath': printer.installpath,
|
||||||
|
'isdefault': item['isdefault'],
|
||||||
|
'inheritedfromassetid': item['inheritedfromassetid'],
|
||||||
|
})
|
||||||
|
|
||||||
|
default = next((p for p in printers if p['isdefault']), None)
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'hostname': computer.hostname,
|
||||||
|
'assetid': asset.assetid,
|
||||||
|
'assetnumber': asset.assetnumber,
|
||||||
|
# Where the assignment came from, so a technician reading a client log
|
||||||
|
# can tell a bay's printers from the PC's own overrides.
|
||||||
|
'source': resolved['source'],
|
||||||
|
'defaultprinterid': default['printerid'] if default else None,
|
||||||
|
'printers': printers,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('printers.edit')
|
||||||
|
def set_asset_printer_assignment(asset_id: int):
|
||||||
|
"""Reconcile one asset's whole printer assignment in a single call.
|
||||||
|
|
||||||
|
Body: {"printerassetids": [...], "defaultprinterassetid": N or null}.
|
||||||
|
|
||||||
|
The WHOLE set, not a delta, because the caller knows the intended end state
|
||||||
|
and 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.
|
||||||
|
|
||||||
|
Written against the MACHINE for a bay - that is the point of the feature, so
|
||||||
|
a reimaged PC inherits it - but an asset is an asset here, and writing to a
|
||||||
|
PC deliberately shadows its machine (see resolve_asset_printers).
|
||||||
|
|
||||||
|
Rows that go away are SOFT-deleted and rows that come back are REACTIVATED
|
||||||
|
rather than inserted: the unique constraint (source, target, type) spans
|
||||||
|
inactive rows, so a blind insert after an unassign raises IntegrityError on
|
||||||
|
MySQL while passing on SQLite.
|
||||||
|
|
||||||
|
Removal here uninstalls nothing. It changes what the bay is told to have;
|
||||||
|
the client never deletes a queue.
|
||||||
|
"""
|
||||||
|
asset = db.session.get(Asset, asset_id)
|
||||||
|
if not asset or not asset.isactive:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if data is None:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||||
|
|
||||||
|
raw = data.get('printerassetids')
|
||||||
|
if raw is None or not isinstance(raw, list):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'printerassetids must be a list of asset ids')
|
||||||
|
|
||||||
|
# Ordered, de-duplicated: the same printer twice is one assignment, and the
|
||||||
|
# order is the order the client is told to install them in.
|
||||||
|
wanted = []
|
||||||
|
for value in raw:
|
||||||
|
try:
|
||||||
|
assetid = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'printerassetids must be integers')
|
||||||
|
if assetid not in wanted:
|
||||||
|
wanted.append(assetid)
|
||||||
|
|
||||||
|
defaultid = data.get('defaultprinterassetid')
|
||||||
|
if defaultid is not None:
|
||||||
|
try:
|
||||||
|
defaultid = int(defaultid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'defaultprinterassetid must be an asset id or null')
|
||||||
|
# Checked BEFORE any write, so a rejected request changes nothing. A
|
||||||
|
# default outside the set tells the client to default to a queue it was
|
||||||
|
# never told to install: it fails, and nothing in ShopDB says why.
|
||||||
|
if defaultid not in wanted:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'defaultprinterassetid must be one of printerassetids')
|
||||||
|
|
||||||
|
# Every target must exist and be a printer. Assigning a machine to a machine
|
||||||
|
# is a typo that would otherwise sit in the data until a bay tried it.
|
||||||
|
if wanted:
|
||||||
|
found = {row.assetid: row for row in
|
||||||
|
Asset.query.filter(Asset.assetid.in_(wanted)).all()}
|
||||||
|
missing = [assetid for assetid in wanted if assetid not in found]
|
||||||
|
if missing:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.NOT_FOUND,
|
||||||
|
'Unknown printer asset(s): {0}'.format(
|
||||||
|
', '.join(str(assetid) for assetid in missing)),
|
||||||
|
http_code=404)
|
||||||
|
notprinters = [assetid for assetid, row in found.items()
|
||||||
|
if not (row.assettype and row.assettype.assettype == 'printer')]
|
||||||
|
if notprinters:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Not printer assets: {0}'.format(
|
||||||
|
', '.join(str(assetid) for assetid in sorted(notprinters))))
|
||||||
|
|
||||||
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
||||||
|
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
||||||
|
# Seed data, not a migration. An un-seeded database cannot hold an
|
||||||
|
# assignment, and saying so beats writing rows nothing can read.
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
'Relationship types are not seeded - run: flask seed reference-data',
|
||||||
|
http_code=500)
|
||||||
|
|
||||||
|
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
||||||
|
typeids[_USES_PRINTER], wanted)
|
||||||
|
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
||||||
|
typeids[_DEFAULT_PRINTER],
|
||||||
|
[defaultid] if defaultid is not None else [])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
||||||
|
return success_response({
|
||||||
|
'assetid': asset_id,
|
||||||
|
'printerassetids': printerassetids,
|
||||||
|
'defaultprinterassetid': defaultassetid,
|
||||||
|
}, message='Printer assignment updated')
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
|
||||||
|
"""Make the active edges of one type be exactly `wantedtargets`.
|
||||||
|
|
||||||
|
Reads across every case-variant type id (a legacy 'DefaultPrinter' row is
|
||||||
|
the same edge) but writes new rows with one, so the table converges on a
|
||||||
|
single spelling instead of accumulating both.
|
||||||
|
"""
|
||||||
|
existing = {}
|
||||||
|
rows = (AssetRelationship.query
|
||||||
|
.filter(AssetRelationship.sourceassetid == sourceassetid,
|
||||||
|
AssetRelationship.relationshiptypeid.in_(readtypeids))
|
||||||
|
.order_by(AssetRelationship.relationshipid)
|
||||||
|
.all())
|
||||||
|
for row in rows:
|
||||||
|
existing.setdefault(row.targetassetid, []).append(row)
|
||||||
|
|
||||||
|
for targetassetid, rowlist in existing.items():
|
||||||
|
if targetassetid in wantedtargets:
|
||||||
|
# Keep the oldest, retire any duplicate: two active rows for one
|
||||||
|
# edge is how an asset ends up with two defaults.
|
||||||
|
keep = rowlist[0]
|
||||||
|
keep.isactive = True
|
||||||
|
for extra in rowlist[1:]:
|
||||||
|
extra.isactive = False
|
||||||
|
else:
|
||||||
|
for row in rowlist:
|
||||||
|
row.isactive = False
|
||||||
|
|
||||||
|
for targetassetid in wantedtargets:
|
||||||
|
if targetassetid not in existing:
|
||||||
|
db.session.add(AssetRelationship(
|
||||||
|
sourceassetid=sourceassetid,
|
||||||
|
targetassetid=targetassetid,
|
||||||
|
relationshiptypeid=writetypeid,
|
||||||
|
isactive=True))
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def get_printer(printer_id: int):
|
def get_printer(printer_id: int):
|
||||||
|
|||||||
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
43
plugins/printers/migrations/versions/0003_drivername.py
Normal 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')
|
||||||
@@ -11,6 +11,9 @@ class PrinterDriver(db.Model):
|
|||||||
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
||||||
location = db.Column(db.String(500), nullable=False)
|
location = db.Column(db.String(500), nullable=False)
|
||||||
description = db.Column(db.Text)
|
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 specific printer model
|
# Optional: attach a driver to a specific printer model
|
||||||
modelnumberid = db.Column(
|
modelnumberid = db.Column(
|
||||||
db.Integer,
|
db.Integer,
|
||||||
@@ -27,6 +30,7 @@ class PrinterDriver(db.Model):
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'location': self.location,
|
'location': self.location,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
|
'drivername': self.drivername,
|
||||||
'modelnumberid': self.modelnumberid,
|
'modelnumberid': self.modelnumberid,
|
||||||
'modelname': self.model.modelnumber if self.model else None,
|
'modelname': self.model.modelnumber if self.model else None,
|
||||||
'isactive': bool(self.isactive),
|
'isactive': bool(self.isactive),
|
||||||
|
|||||||
@@ -516,12 +516,33 @@ def seed_reference_data():
|
|||||||
for at in adr_types:
|
for at in adr_types:
|
||||||
if not _lookup_binary(at['relationshiptype']):
|
if not _lookup_binary(at['relationshiptype']):
|
||||||
db.session.add(RelationshipType(**at))
|
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},
|
||||||
|
]
|
||||||
|
for pt in printer_types:
|
||||||
|
if not _lookup_binary(pt['relationshiptype']):
|
||||||
|
db.session.add(RelationshipType(**pt))
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# Seed `controls` propagation rails as M:N rows. controls -> partof
|
# Seed propagation rails as M:N rows. controls -> partof (declared;
|
||||||
# (declared; directional rail, not consumed yet) and controls -> Dualpath
|
# directional rail, not consumed yet) and controls -> Dualpath (consumed;
|
||||||
# (consumed; a dual-bay pair shares one controller so both bays carry
|
# a dual-bay pair shares one controller so both bays carry controls).
|
||||||
# controls). Idempotent, resolved by name, skipped if a type is missing.
|
# 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
|
from shopdb.core.models.relationship import RelationshipTypePropagation
|
||||||
|
|
||||||
def _seed_propagation(sourcename, throughname):
|
def _seed_propagation(sourcename, throughname):
|
||||||
@@ -541,15 +562,8 @@ def seed_reference_data():
|
|||||||
|
|
||||||
_seed_propagation('controls', 'partof')
|
_seed_propagation('controls', 'partof')
|
||||||
_seed_propagation('controls', 'Dualpath')
|
_seed_propagation('controls', 'Dualpath')
|
||||||
|
_seed_propagation('usesprinter', 'controls')
|
||||||
# Default-printer link: a PC asset -> its default printer asset. Read by the
|
_seed_propagation('defaultprinter', 'controls')
|
||||||
# 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)'
|
|
||||||
))
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(click.style("Reference data seeded.", fg='green'))
|
click.echo(click.style("Reference data seeded.", fg='green'))
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
|
|||||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||||
# baseline.
|
# baseline.
|
||||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
||||||
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
|
# printers adds the printersupplyalerts crossing-state table on top of its
|
||||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
|
# anchor, then the exact INF driver name Add-PrinterDriver needs.
|
||||||
|
EXPECTED_HEAD_REVISION['printers'] = 'printers0003drivername'
|
||||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
# 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.
|
# rename revision on top, so its head is not the f-string default.
|
||||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||||
|
|||||||
342
tests/test_plugins/test_printer_assignment.py
Normal file
342
tests/test_plugins/test_printer_assignment.py
Normal 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
|
||||||
Reference in New Issue
Block a user