Collect what bays actually have, separately from what they are told to have
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m22s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s

ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the
observed half makes a rollout a review instead of a typing exercise: the floor
reports itself in, you look, and you adopt.

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

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

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

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

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

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

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

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

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

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

Not covered by any of this: the driver-staging path, which needs a real vendor
package rather than the class drivers a VM ships with.
This commit is contained in:
cproudlock
2026-08-19 14:45:39 -04:00
parent 1a5a1cd43d
commit 2d09fa3201
15 changed files with 2903 additions and 30 deletions

View File

@@ -8,7 +8,7 @@ from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver, PrinterObservedQueue
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
from ..services import (
ZabbixService,
@@ -1136,6 +1136,657 @@ def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
relationshiptypeid=writetypeid,
isactive=True))
# =============================================================================
# Observed state (what a bay actually has)
# =============================================================================
# The other half of the loop. The assignment says what a host SHOULD have; the
# observed rows say what it reported having on its last cycle, and they are kept
# apart on purpose - the moment a drifted bay's observed state is treated as
# correct, enforcement stops meaning anything. Nothing here writes an assignment
# except the seed endpoint at the bottom, which a person has to ask for.
_OBSERVED_MATCHING = 'matching'
_OBSERVED_MISSING = 'missing'
_OBSERVED_EXTRA = 'extra'
_OBSERVED_DRIFTED = 'drifted'
_OBSERVED_UNKNOWN = 'unknown'
_OBSERVED_CLASSIFICATIONS = (_OBSERVED_MATCHING, _OBSERVED_MISSING,
_OBSERVED_EXTRA, _OBSERVED_DRIFTED,
_OBSERVED_UNKNOWN)
def _observed_rows(hostname):
"""The rows of a host's last report, or [].
Case-folded with the same short-name and FQDN fallbacks as
_computer_by_hostname: the reporter sends its own COMPUTERNAME, and whether
that is short or fully qualified is a matter of how the site enrolls PCs,
not of which host it is.
Ordered by queue name, not by insertion: a report replaces the previous one
in one pass, so row order carries no meaning, and a stable sort keeps two
reads of the same report - and the seed built from it - identical.
"""
name = (hostname or '').strip().lower()
if not name:
return []
query = PrinterObservedQueue.query.order_by(PrinterObservedQueue.queuename)
rows = query.filter(db.func.lower(PrinterObservedQueue.hostname) == name).all()
if rows:
return rows
shortname = name.split('.')[0]
if shortname != name:
rows = query.filter(
db.func.lower(PrinterObservedQueue.hostname) == shortname).all()
if rows:
return rows
# Prefix match only for a plain hostname, as in _computer_by_hostname: a
# LIKE wildcard arriving in the path segment would pull back another PC.
if not re.match(r'^[a-z0-9-]+$', shortname):
return []
return query.filter(
db.func.lower(PrinterObservedQueue.hostname).like(shortname + '.%')).all()
def _printer_match_index():
"""Lookup tables for turning an observed queue into a printer asset.
Built in two queries instead of one lookup per queue, and built in
printerid order so first-match is deterministic: two printers can share an
address (a multi-queue device, or a Communication row left behind by a
swap), and without a fixed order the same report would classify one way on
one read and another way on the next.
'addressof' is what an install would point the port at - hostname first, IP
second, the order Set-ShopdbPrinters uses - and 'addressesof' is every
address that IS this printer. Drift is judged against the set, not the
preferred one: a queue pointed at the printer's IP where the register names
its hostname reaches the same device, and calling that drift would light up
every correctly installed bay.
"""
rows = (db.session.query(Printer, Asset)
.join(Asset, Asset.assetid == Printer.assetid)
.filter(Asset.isactive == True)
.order_by(Printer.printerid)
.all())
communications = {}
assetids = [printer.assetid for printer, _asset in rows]
if assetids:
for communication in (Communication.query
.filter(Communication.assetid.in_(assetids))
.order_by(Communication.communicationid).all()):
communications.setdefault(communication.assetid, []).append(communication)
index = {'byaddress': {}, 'byqueuename': {}, 'printers': {},
'addressof': {}, 'addressesof': {}}
for printer, asset in rows:
index['printers'][printer.assetid] = (printer, asset)
own = communications.get(printer.assetid, [])
primary = next((item for item in own if item.isprimary), None)
if primary is None and own:
primary = own[0]
address = (printer.hostname or '').strip()
if not address and primary is not None:
address = (primary.ipaddress or '').strip()
index['addressof'][printer.assetid] = address or None
# Every address the printer answers on, not just the primary one: a
# second NIC still identifies the same device to a port that uses it.
addresses = set()
for value in [printer.hostname] + [item.ipaddress for item in own]:
key = (value or '').strip().lower()
if key:
addresses.add(key)
index['byaddress'].setdefault(key, printer.assetid)
index['addressesof'][printer.assetid] = addresses
for value in (printer.windowsname, printer.sharename,
_install_name(printer, asset),
asset.assetnumber, asset.name):
key = (value or '').strip().lower()
if key:
index['byqueuename'].setdefault(key, printer.assetid)
return index
def _match_observed_queue(row, index):
"""The printer assetid an observed queue is, or None.
PORT ADDRESS first. An IP or FQDN names one device and cannot be reused by
a differently-named queue on the next bay, so it is the only key worth
trusting. The Windows PORT NAME is looked up in the same address table
because a standard TCP/IP port created outside the client is named after the
host address itself - that is still an exact match on an address ShopDB
holds, not a guess at one.
Queue name second, and nothing after it. An unmatched queue is reported as
unknown: a wrong match seeds a wrong assignment, which is worse than no
assignment at all.
"""
for value in (row.portaddress, row.portname):
key = (value or '').strip().lower()
if key and key in index['byaddress']:
return index['byaddress'][key]
return index['byqueuename'].get((row.queuename or '').strip().lower())
def _assigned_expectations(assignment, index):
"""{printer assetid: what an install of it would look like}.
Keyed off the resolved assignment, so a PC that inherits its bay's printers
is compared against the bay's, which is what the client would have
installed. An assigned asset that is retired or is not a printer is left out
entirely: for-host skips it too, so a bay cannot be missing it.
"""
expected = {}
assignments = assignment.get('assignments') or []
if not assignments:
return expected
universaldrivers = (PrinterDriver.query
.filter(PrinterDriver.modelnumberid.is_(None),
PrinterDriver.isactive == True)
.order_by(PrinterDriver.name).all())
for item in assignments:
entry = index['printers'].get(item['assetid'])
if entry is None:
continue
printer, asset = entry
driver = _printer_driver(printer, universaldrivers)
expected[printer.assetid] = {
'printerid': printer.printerid,
'printerassetid': printer.assetid,
'printername': asset.name or asset.assetnumber,
'queuename': _install_name(printer, asset),
'portaddress': index['addressof'].get(printer.assetid),
'addresses': index['addressesof'].get(printer.assetid) or set(),
'drivername': driver.drivername if driver else None,
'isdefault': bool(item.get('isdefault')),
'inheritedfromassetid': item.get('inheritedfromassetid'),
}
return expected
def _observed_driftfields(row, want):
"""Which installed properties disagree with the assignment.
Port address and driver name only. A queue NAME that differs is reported -
expectedqueuename is in the payload - but is not drift: the port says it is
the same device, and a locally renamed queue still prints to it, so renaming
it back is a preference rather than a fault.
The address is judged against every address the printer answers on rather
than against the one an install would prefer, because hostname and IP are
the same device. Address drift is therefore a queue that carries the
printer's NAME while printing somewhere else - the failure that is invisible
from the server and obvious to whoever is standing at the machine.
A queue with no port address (a non-TCP port, or a reporting host too old to
read one) cannot be compared on address, so only its driver is judged.
Comparing against a blank would report every such queue as drifted and the
view would be noise.
"""
fields = []
observedaddress = (row.portaddress or '').strip().lower()
addresses = want.get('addresses') or set()
if observedaddress and addresses and observedaddress not in addresses:
fields.append('portaddress')
observeddriver = (row.drivername or '').strip().lower()
wanteddriver = (want['drivername'] or '').strip().lower()
if observeddriver and wanteddriver and observeddriver != wanteddriver:
fields.append('drivername')
return fields
def _observed_entry(row=None, want=None, classification=None, printer=None,
asset=None, driftfields=None):
"""One row of the comparison, observed side and assigned side in one shape.
Both sides in every entry so a reviewer never has to join two lists: a
missing printer has no observed half, an unknown queue has no assigned half,
and everything in between carries what it has and nulls for what it lacks.
"""
return {
'classification': classification,
'queuename': (row.queuename if row is not None
else (want or {}).get('queuename')),
'drivername': row.drivername if row is not None else None,
'portname': row.portname if row is not None else None,
'portaddress': row.portaddress if row is not None else None,
'isdefault': bool(row.isdefault) if row is not None else False,
'isshared': bool(row.isshared) if row is not None else False,
'printerid': (printer.printerid if printer is not None
else (want or {}).get('printerid')),
'printerassetid': (printer.assetid if printer is not None
else (want or {}).get('printerassetid')),
'printername': ((asset.name or asset.assetnumber) if asset is not None
else (want or {}).get('printername')),
'expectedqueuename': (want or {}).get('queuename'),
'expectedportaddress': (want or {}).get('portaddress'),
'expecteddrivername': (want or {}).get('drivername'),
'isassigneddefault': bool((want or {}).get('isdefault')),
'inheritedfromassetid': (want or {}).get('inheritedfromassetid'),
'driftfields': driftfields or [],
}
def _observed_comparison(rows, expected, index):
"""Every observed queue classified, then the assigned printers nobody saw.
matching - assigned, present, installed the way the assignment says.
drifted - assigned and present, but on another port or another driver.
extra - a printer ShopDB knows, installed here without being assigned.
missing - assigned, and the host did not report it.
unknown - a queue that matches no printer in ShopDB.
"""
queues = []
seen = set()
for row in rows:
printerassetid = _match_observed_queue(row, index)
if printerassetid is None:
queues.append(_observed_entry(row=row, classification=_OBSERVED_UNKNOWN))
continue
printer, asset = index['printers'][printerassetid]
want = expected.get(printerassetid)
if want is None:
queues.append(_observed_entry(row=row, classification=_OBSERVED_EXTRA,
printer=printer, asset=asset))
continue
seen.add(printerassetid)
driftfields = _observed_driftfields(row, want)
queues.append(_observed_entry(
row=row, want=want, printer=printer, asset=asset,
driftfields=driftfields,
classification=_OBSERVED_DRIFTED if driftfields else _OBSERVED_MATCHING))
for printerassetid, want in expected.items():
if printerassetid not in seen:
queues.append(_observed_entry(want=want, classification=_OBSERVED_MISSING))
return queues
def _seed_candidate(queues):
"""The assignment a seed would write, and the queues it would refuse.
Matched queues only, in the order _observed_rows returns them. The observed
default carries over only when it matched a printer: a default outside the
set is rejected by the assignment writer anyway, and pointing a bay at a
queue it was never told to install fails on the bay with nothing in ShopDB
saying why.
"""
printerassetids = []
skipped = []
defaultprinterassetid = None
for entry in queues:
if entry['classification'] == _OBSERVED_MISSING:
continue
if entry['classification'] == _OBSERVED_UNKNOWN:
skipped.append({
'queuename': entry['queuename'],
'drivername': entry['drivername'],
'portname': entry['portname'],
'portaddress': entry['portaddress'],
'isdefault': entry['isdefault'],
})
continue
if entry['printerassetid'] not in printerassetids:
printerassetids.append(entry['printerassetid'])
if entry['isdefault'] and defaultprinterassetid is None:
defaultprinterassetid = entry['printerassetid']
return {
'printerassetids': printerassetids,
'defaultprinterassetid': defaultprinterassetid,
'skipped': skipped,
}
def _observed_summary(queues):
counts = {name: 0 for name in _OBSERVED_CLASSIFICATIONS}
for entry in queues:
counts[entry['classification']] += 1
return counts
def _observed_host_block(hostname, asset, index):
"""One host's report, classified against what that host would install.
The assigned side is resolved from the REPORTING PC, not from whatever asset
a caller asked about: a PC's own rows shadow the machine's, so a bay with an
override is converged when it matches the override. `source` says which of
the two the comparison used.
"""
rows = _observed_rows(hostname)
assignment = (resolve_asset_printers(asset) if asset is not None
else {'assignments': [], 'source': 'none'})
queues = _observed_comparison(rows, _assigned_expectations(assignment, index),
index)
# One report is written in one pass, so every row carries the same stamp.
observedat = rows[0].observedat if rows else None
return {
'hostname': hostname,
'assetid': asset.assetid if asset is not None else None,
'assetnumber': asset.assetnumber if asset is not None else None,
'source': assignment['source'],
'observedat': observedat.isoformat() if observedat else None,
'queues': queues,
'summary': _observed_summary(queues),
'seedcandidate': _seed_candidate(queues),
}
@printers_asset_bp.route('/observed/<hostname>', methods=['GET'])
@jwt_required()
@require_permission('printers.view')
def observed_for_host(hostname: str):
"""What a host last reported, each queue judged against what it is assigned.
The mirror of for-host: that endpoint says what this bay SHOULD have, this
one says what it told us it DOES have, and puts the two side by side. Read
only - nothing here changes an assignment, however wrong the bay looks.
404 only when the hostname means nothing here: no report and no computer. A
known PC that has never reported is an empty queue list, and a report from a
host with no computer record still comes back - everything on it is extra or
unknown, which is exactly the answer a technician needs.
`seedcandidate` is a preview of what POST
/api/printers/assignments/seed-from-observed would write from this report.
"""
try:
found = _computer_by_hostname(hostname)
except ImportError:
# No computers plugin, so no hostname -> asset resolution and no
# assigned side. The report itself is still worth returning.
found = None
rows = _observed_rows(hostname)
if not rows and not found:
return error_response(
ErrorCodes.NOT_FOUND,
f'No printer report and no computer for hostname {hostname}',
http_code=404)
computer, asset = found if found else (None, None)
known = (computer.hostname if computer is not None
else (rows[0].hostname if rows else hostname))
return success_response(_observed_host_block(known, asset, _printer_match_index()))
@printers_asset_bp.route('/observed/for-asset/<int:asset_id>', methods=['GET'])
@jwt_required()
@require_permission('printers.view')
def observed_for_asset(asset_id: int):
"""The same comparison, reached from an asset page: one block per host.
Assigned state lives on the MACHINE and observed state is reported by the
PCs, so a machine answers with a block for each PC that controls it. Blocks
rather than one merged list because a dualpath pair or a part marker
legitimately puts two PCs on one machine, and the only actionable thing
about drift is which box to walk to.
An asset nothing reports for - a machine with no PC, or a PC with no
computer record - is an empty `hosts` list and a 200. This hangs off the
asset page, and on the day it ships most bays have not reported yet.
"""
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)
hosts = []
seen = set()
own = _own_hostname(asset)
if own:
hosts.append((own, asset))
seen.add(own.lower())
for hostname, pcasset in _controlling_computers(asset_id):
if hostname.lower() not in seen:
seen.add(hostname.lower())
hosts.append((hostname, pcasset))
index = _printer_match_index()
return success_response({
'assetid': asset_id,
'assetnumber': asset.assetnumber,
'hosts': [_observed_host_block(hostname, pcasset, index)
for hostname, pcasset in hosts],
})
def _controlling_computers(assetid):
"""(hostname, PC asset) for the active PCs that control this asset.
Observed state is reported by the PC and the assignment belongs to the
machine, so both the comparison and a seed onto a machine have to cross the
controls edge. Read incoming here (PC -> machine) because the machine is the
asset being asked about, which is the same edge resolve_asset_printers walks
outgoing. Oldest edge first, so two PCs on one machine list in a fixed
order.
"""
try:
from plugins.computers.models import Computer
except ImportError:
return []
typeids = _relationship_typeids(_CONTROLS)[_CONTROLS]
if not typeids:
return []
rows = (db.session.query(Computer.hostname, Asset)
.select_from(AssetRelationship)
.join(Computer, Computer.assetid == AssetRelationship.sourceassetid)
.join(Asset, Asset.assetid == Computer.assetid)
.filter(AssetRelationship.targetassetid == assetid,
AssetRelationship.relationshiptypeid.in_(typeids),
AssetRelationship.isactive == True,
Asset.isactive == True)
.order_by(AssetRelationship.relationshipid)
.all())
controllers = []
seen = set()
for hostname, pcasset in rows:
key = (hostname or '').strip().lower()
if not key or key in seen:
continue
seen.add(key)
controllers.append((hostname, pcasset))
return controllers
def _own_hostname(asset):
"""The asset's own hostname when it is a PC, else None."""
try:
from plugins.computers.models import Computer
except ImportError:
return None
computer = Computer.query.filter_by(assetid=asset.assetid).first()
if computer is None or not computer.hostname:
return None
return computer.hostname
def _seed_source_hostnames(asset):
"""Hosts whose report could seed this asset: its own, then its controllers.
Its own first because seeding a PC from a different PC's report is never
what was meant; the controllers because the normal target is the MACHINE,
which reports nothing itself.
"""
hostnames = []
own = _own_hostname(asset)
if own:
hostnames.append(own)
for hostname, _pcasset in _controlling_computers(asset.assetid):
if hostname.lower() not in [name.lower() for name in hostnames]:
hostnames.append(hostname)
return hostnames
@printers_asset_bp.route('/assignments/seed-from-observed/<int:asset_id>',
methods=['POST'])
@jwt_required()
@require_permission('printers.edit')
def seed_assignment_from_observed(asset_id: int):
"""Write what a PC observed as the assignment of the asset in the path.
THE ONE PATH FROM OBSERVED TO ASSIGNED, and a person has to ask for it. No
collector, no cycle and no background job reaches this route: a bay that
installed the wrong printer must never be able to make itself right by
reporting it. The reviewer reads the comparison (GET
/api/printers/observed/<hostname>, or /observed/for-asset/<id> from an asset
page), agrees with it, and posts here.
Normally posted against the MACHINE, so the assignment survives a reimage
and follows the bay (see resolve_asset_printers); posting it against the PC
works and is warned about, because the PC's own rows then shadow the
machine's for good.
Body, all optional:
{"hostname": "PC01", which report to seed from. Omitted, the asset's
own hostname or its single controlling PC.
"allowunmatched": false} proceed when some queue matches no printer.
Refuses rather than guesses:
409 when more than one controlling PC has reported - which bay is right is
not something this endpoint can know.
409 when any queue matches no printer, listing every one of them, unless
allowunmatched says to seed the rest anyway.
400 when nothing matched, because writing the empty set would silently
unassign the asset.
Nothing is written on any of those; the assignment is left exactly as found.
"""
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) or {}
hostname = (data.get('hostname') or '').strip()
allowunmatched = bool(data.get('allowunmatched'))
if hostname:
rows = _observed_rows(hostname)
if not rows:
return error_response(
ErrorCodes.NOT_FOUND,
f'No printer report from hostname {hostname}',
http_code=404)
else:
reporting = [name for name in _seed_source_hostnames(asset)
if _observed_rows(name)]
if not reporting:
return error_response(
ErrorCodes.NOT_FOUND,
'No host has reported printers for this asset - name one with '
'{"hostname": "..."}',
http_code=404)
if len(reporting) > 1:
return error_response(
ErrorCodes.CONFLICT,
'Several hosts report printers for this asset - name the one to '
'seed from',
details={'hostnames': reporting},
http_code=409)
hostname = reporting[0]
rows = _observed_rows(hostname)
index = _printer_match_index()
# Classified with no assigned side: seeding asks what each queue IS, not
# whether the asset already has it. The reconcile below is the whole set.
queues = _observed_comparison(rows, {}, index)
candidate = _seed_candidate(queues)
if candidate['skipped'] and not allowunmatched:
return error_response(
ErrorCodes.CONFLICT,
'{0} observed queue(s) match no printer in ShopDB. Add them as '
'printer assets, or repost with allowunmatched to seed the '
'rest.'.format(len(candidate['skipped'])),
details={
'hostname': hostname,
'skipped': candidate['skipped'],
'printerassetids': candidate['printerassetids'],
},
http_code=409)
if not candidate['printerassetids']:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Nothing to seed: no queue reported by {hostname} matches a printer '
'in ShopDB',
details={'hostname': hostname, 'skipped': candidate['skipped']})
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Relationship types are not seeded - run: flask seed reference-data',
http_code=500)
warnings = []
observeddefault = next((entry for entry in queues if entry['isdefault']), None)
if observeddefault is not None and candidate['defaultprinterassetid'] is None:
warnings.append(
'Default queue "{0}" matches no printer in ShopDB; no default '
'assigned'.format(observeddefault['queuename']))
elif observeddefault is None:
warnings.append(f'{hostname} reported no default printer; no default assigned')
if candidate['skipped']:
warnings.append('{0} unmatched queue(s) were not assigned'.format(
len(candidate['skipped'])))
if _outgoing_rows(asset_id, typeids[_CONTROLS]):
# Own rows shadow rather than merge, so seeding the PC of a bay quietly
# takes that bay off the machine's assignment for good.
warnings.append(
'This asset controls another asset: its own printers now shadow the '
'assignment of the machine it controls')
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
typeids[_USES_PRINTER], candidate['printerassetids'])
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
typeids[_DEFAULT_PRINTER],
[candidate['defaultprinterassetid']]
if candidate['defaultprinterassetid'] is not None else [])
db.session.commit()
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
logger.info('Seeded printer assignment for asset %s from %s: %d printer(s), '
'%d skipped', asset_id, hostname, len(printerassetids),
len(candidate['skipped']))
return success_response({
'assetid': asset_id,
'seededfromhostname': hostname,
'printerassetids': printerassetids,
'defaultprinterassetid': defaultassetid,
'skipped': candidate['skipped'],
'warnings': warnings,
}, message='Printer assignment seeded from observed state')
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
@jwt_required(optional=True)
def get_printer(printer_id: int):