Warranty search finds a machine number; the collector can no longer set a printer assignment

This commit is contained in:
2026-08-24 11:52:48 -04:00
parent f109dd1349
commit 0398c55fb5
6 changed files with 263 additions and 272 deletions

View File

@@ -105,6 +105,13 @@ ADR-007 and ADR-002.
the wrong code. The page index now travels with the page. the wrong code. The page index now travels with the page.
- The back label's dotted preview outline outspecified the print reset and drew - The back label's dotted preview outline outspecified the print reset and drew
a dashed rule down the edge of every second card. a dashed rule down the edge of every second card.
- **The warranty list could not be searched by machine number.** The number was
in the payload and rendered in the table as a chip, but the search box looked
only at vendor, service level, service tag and asset number. That made the
identifier the feature was built around - a shopfloor PC is warranted as a PC
but found by the machine it drives - the one thing the box could not find.
Machine number and machine name are now searched too, and the placeholder
names them.
- **`flask plugin upgrade-all` failed a plugin whose tables already existed** - **`flask plugin upgrade-all` failed a plugin whose tables already existed**
("table securesendsecrets already exists" on the tools plugin). Every ("table securesendsecrets already exists" on the tools plugin). Every
post-cutover baseline emitted a plain `op.create_table`, which is correct only post-cutover baseline emitted a plain `op.create_table`, which is correct only
@@ -132,6 +139,20 @@ ADR-007 and ADR-002.
### Changed ### Changed
- **The computers collector no longer accepts `defaultprinter` or `printers`,
and can no longer write a printer assignment.** Those keys used to become
`defaultprinter`/`connectedto` relationship rows straight from a report, which
contradicts the rule the whole printer feature rests on: a collector report is
OBSERVED state and never ASSIGNED state, or a bay that installed the wrong
printer makes itself right simply by reporting it. It was not a dormant code
path - the collector schema advertised both keys and the example client in
`docs/COLLECTOR-INTEGRATION.md` set `defaultprinter`, while another page of
the same document promised nothing on that path writes an assignment row.
**A client still sending them keeps working**: the keys are ignored, not
rejected, and the response carries a warning naming them and pointing at
`POST /api/collector/printers` for reporting queues and
`seed-from-observed` for adopting them. Silently dropping them would have left
an integrator believing assignments were still being written.
- **Tech Tools no longer asks people to sign in for tools that need no - **Tech Tools no longer asks people to sign in for tools that need no
account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind
`requiresAuth`. The label generator runs entirely in the browser - no API, no `requiresAuth`. The label generator runs entirely in the browser - no API, no

View File

@@ -16,16 +16,14 @@ from .api import computers_bp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Marker stamped on PC->printer links this collector creates. The stale-link
# archive only touches rows carrying this label, so manually-created printer
# relationships are never removed by a collector push. Stored in the
# assetrelationships.label column (no origin column exists; see BUILD notes).
PRINTER_LINK_ORIGIN = 'collector:printers'
# Marker stamped on PC->measuringtool "controls" links this collector creates # Marker stamped on PC->measuringtool "controls" links this collector creates
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link # for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Only rows carrying
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived # this label are archived by a collector push, so hand-made tool links survive.
# by a collector push, so hand-made tool links survive. # Stored in assetrelationships.label (no origin column exists; see BUILD notes).
#
# There is deliberately no printer equivalent any more. PRINTER_LINK_ORIGIN went
# with _sync_printer_links: printer ASSIGNMENT is never collected, so there are
# no collector-owned printer rows left to label or archive.
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool' MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
# Marker stamped on the PC->machine "controls" link built from the reported # Marker stamped on the PC->machine "controls" link built from the reported
@@ -139,26 +137,10 @@ class ComputersPlugin(BasePlugin):
'type': 'array', 'type': 'array',
'items': {'name': 'string', 'version': 'string'}, 'items': {'name': 'string', 'version': 'string'},
}, },
# Printer identifiers reported by the GE-Enforce side, which # RETIRED: 'defaultprinter' and 'printers' used to live here and
# runs Get-CimInstance Win32_Printer and marks the default with # wrote PC->printer assignment rows from a report. That is the
# the Default flag. An identifier is the printer's windows name, # one thing this path must never do - see _RETIRED_PRINTER_KEYS.
# share name, hostname, or port/IP; the collector resolves it # A client still sending them is warned, not rejected.
# flexibly to a printer asset. Both optional. Presence of either
# key drives the PC->printer relationship sync (and stale-link
# archive); absence leaves existing printer links untouched.
'defaultprinter': {
'type': 'string',
'description': ('Default printer identifier (Win32_Printer '
'with Default=true): windows name, share '
'name, hostname, or port IP.'),
},
'printers': {
'type': 'array',
'items': {'type': 'string'},
'description': ('All installed network printer identifiers '
'(Win32_Printer): windows name / share / '
'hostname / IP. Unresolved -> warning.'),
},
'accessprotocols': { 'accessprotocols': {
'type': 'array', 'type': 'array',
'items': {'type': 'string'}, 'items': {'type': 'string'},
@@ -423,8 +405,9 @@ class ComputersPlugin(BasePlugin):
else: else:
machinelinks = [] machinelinks = []
# Printer relationship sync (only when the payload carried printer data). # Printer assignment is NOT collected. See _warn_retired_printer_keys.
printerlinks = self._sync_printer_links(comp.asset, payload, warnings) self._warn_retired_printer_keys(payload, warnings)
printerlinks = []
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace) # Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
# get an attached MeasuringTool asset auto-created and linked. # get an attached MeasuringTool asset auto-created and linked.
@@ -518,134 +501,38 @@ class ComputersPlugin(BasePlugin):
AccessProtocol.protocolid.in_(wanted)).all()] if wanted else [] AccessProtocol.protocolid.in_(wanted)).all()] if wanted else []
return sorted(names) return sorted(names)
def _sync_printer_links(self, pcasset, payload, warnings): def _warn_retired_printer_keys(self, payload, warnings):
"""Idempotently sync PC->printer relationships from collector printer data. """Refuse to turn a report into an assignment, out loud.
Default printer -> 'defaultprinter' (directional). Other reported RETIRED 2026-08-24. This used to be _sync_printer_links, which wrote
printers -> 'connectedto' (symmetric). Resolves each identifier to a 'defaultprinter' and 'connectedto' rows straight from a collector
printer asset by windows name / share / hostname / asset number / name payload. That contradicted the rule the whole printer feature rests on:
or a communications IP. Unresolved identifiers add a warning and never ASSIGNED and OBSERVED never mix. A bay that installed the wrong printer
fail the push. would have made itself right simply by reporting it, and enforcement
would stop meaning anything.
Stale-link archive: on each push, collector-tagged links (label == Worse than dormant, it was ADVERTISED: the collector schema listed both
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported keys and COLLECTOR-INTEGRATION.md's example client set defaultprinter,
desired set are set inactive. Only tagged rows are touched, so manual while the same document promised on another page that nothing on this
links survive. Runs only when the payload carried a printer key path writes an assignment row. The document contradicted itself and the
('defaultprinter' or 'printers'); a PC that reports without printer data code sided with the wrong half.
keeps its existing links. Returns the desired-link list (created + kept).
Observed queues have their own path (POST /api/collector/printers) and
become assignments only when a person asks, through
seed-from-observed after reviewing the comparison.
WARNS RATHER THAN REJECTS. Unknown keys are not validated here, so a
client written against the old docs keeps pushing successfully; only its
printer keys stop doing anything. Silently ignoring them would leave an
integrator believing assignments were being written.
""" """
from shopdb.api import ( sent = [key for key in ('defaultprinter', 'printers') if key in payload]
AssetRelationship, RelationshipType, Asset, Communication) if sent:
warnings.append(
has_default = 'defaultprinter' in payload '%s ignored: a collector report never sets printer assignment. '
has_list = 'printers' in payload 'Report queues to POST /api/collector/printers, then adopt them '
if not has_default and not has_list: 'with POST /api/printers/assignments/seed-from-observed.'
return [] % ' and '.join(sent))
try:
from plugins.printers.models import Printer
except ImportError:
warnings.append('printers plugin unavailable; printer links skipped')
return []
dp_type = RelationshipType.query.filter_by(
relationshiptype='defaultprinter').first()
ct_type = RelationshipType.query.filter_by(
relationshiptype='connectedto').first()
if not dp_type or not ct_type:
warnings.append('printer relationship types missing; '
'run flask seed reference-data')
return []
def resolve(identifier):
# first match wins: printer text identity, then a printer IP.
ident = (identifier or '').strip()
if not ident:
return None
printer = (
Printer.query.join(Asset, Asset.assetid == Printer.assetid)
.filter(Asset.isactive == True)
.filter(db.or_(
Printer.windowsname.ilike(ident),
Printer.hostname.ilike(ident),
Printer.sharename.ilike(ident),
Asset.assetnumber.ilike(ident),
Asset.name.ilike(ident),
)).first())
if printer:
return printer.asset
comm = (
db.session.query(Communication)
.join(Printer, Printer.assetid == Communication.assetid)
.filter(Communication.ipaddress == ident)
.first())
if comm:
return db.session.get(Asset, comm.assetid)
return None
pcid = pcasset.assetid
desired = set() # (targetassetid, relationshiptypeid) to keep
printerlinks = []
default_id = None
default_ident = (payload.get('defaultprinter') or '').strip()
if default_ident:
target = resolve(default_ident)
if target:
default_id = target.assetid
desired.add((default_id, dp_type.relationshiptypeid))
self._sync_one(pcid, default_id, dp_type)
printerlinks.append({'assetid': default_id,
'relationshiptype': 'defaultprinter'})
else:
warnings.append(f'unresolved default printer: {default_ident}')
for ident in payload.get('printers') or []:
target = resolve(ident)
if not target:
warnings.append(f'unresolved printer: {ident}')
continue
if target.assetid == default_id:
continue # already the default link
desired.add((target.assetid, ct_type.relationshiptypeid))
self._sync_one(pcid, target.assetid, ct_type)
printerlinks.append({'assetid': target.assetid,
'relationshiptype': 'connectedto'})
# Archive collector-tagged links no longer reported (manual links, with
# a NULL/other label, are never matched here).
collector_rows = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcid,
AssetRelationship.relationshiptypeid.in_(
[dp_type.relationshiptypeid, ct_type.relationshiptypeid]),
AssetRelationship.isactive == True,
AssetRelationship.label == PRINTER_LINK_ORIGIN,
).all()
for rel in collector_rows:
if (rel.targetassetid, rel.relationshiptypeid) not in desired:
rel.isactive = False
return printerlinks
def _sync_one(self, pcid, printerid, reltype):
"""Reactivate or create one collector PC->printer link (idempotent)."""
from shopdb.api import AssetRelationship
existing = AssetRelationship.query.filter_by(
sourceassetid=pcid, targetassetid=printerid,
relationshiptypeid=reltype.relationshiptypeid).first()
if existing:
# do not re-stamp label: a pre-existing manual row stays manual.
if not existing.isactive:
existing.isactive = True
return existing
rel = AssetRelationship(
sourceassetid=pcid, targetassetid=printerid,
relationshiptypeid=reltype.relationshiptypeid,
label=PRINTER_LINK_ORIGIN)
db.session.add(rel)
return rel
# -- measuring-tool sync -----------------------------------------------
def _sync_machine_link(self, comp, machinenumber, warnings): def _sync_machine_link(self, comp, machinenumber, warnings):
"""Link a PC to the machine it drives, from the reported machine number. """Link a PC to the machine it drives, from the reported machine number.

View File

@@ -24,7 +24,7 @@
<option value="unknown">Unknown</option> <option value="unknown">Unknown</option>
</select> </select>
</label> </label>
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset..." /> <input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset number, machine..." />
<span class="result-count">{{ filteredItems.length }} of {{ items.length }}</span> <span class="result-count">{{ filteredItems.length }} of {{ items.length }}</span>
</div> </div>
@@ -186,6 +186,7 @@ import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import MachineMapChip from '../components/MachineMapChip.vue' import MachineMapChip from '../components/MachineMapChip.vue'
import { matchesWarrantySearch } from './warrantySearch'
const toast = useToast() const toast = useToast()
const route = useRoute() const route = useRoute()
@@ -197,16 +198,11 @@ const search = ref('')
const page = ref(1) const page = ref(1)
const perPage = 25 const perPage = 25
// Client-side search across vendor / service level / tag / linked asset numbers. // Client-side search. The field list lives in warrantySearch.js, where it is
const filteredItems = computed(() => { // covered by tests - the machine number was rendered in this table for a long
const term = search.value.trim().toLowerCase() // time before anything let you search for it.
if (!term) return items.value const filteredItems = computed(() =>
return items.value.filter(w => items.value.filter(w => matchesWarrantySearch(w, search.value)))
(w.vendor || '').toLowerCase().includes(term) ||
(w.servicelevel || '').toLowerCase().includes(term) ||
(w.servicetag || '').toLowerCase().includes(term) ||
(w.assets || []).some(a => (a.assetnumber || '').toLowerCase().includes(term)))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / perPage))) const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / perPage)))
const paginatedItems = computed(() => filteredItems.value.slice((page.value - 1) * perPage, page.value * perPage)) const paginatedItems = computed(() => filteredItems.value.slice((page.value - 1) * perPage, page.value * perPage))

View File

@@ -0,0 +1,44 @@
// What the warranty list's search box matches against.
//
// Pulled out of WarrantiesList.vue so the field list is somewhere a test can
// see it. It went wrong the way a field list usually does: silently, by a
// column being added to the table and not to the filter.
//
// THE MACHINE NUMBER IS THE POINT. _related_machine in the warranty API exists
// precisely because "a shopfloor PC is bought, warranted and replaced as a PC,
// but it is FOUND by the machine it drives - nobody walks the floor looking for
// an asset number". The payload carries it, the table renders it in a chip, and
// the filter did not look at it - so the one identifier the feature was built
// around was the one thing the search box could not find.
/**
* Does this warranty match the search term?
*
* @param {object} warranty - a row from GET /api/warranties
* @param {string} term - the raw search box contents
* @returns {boolean} true when the term is empty or any searched field contains it
*/
export function matchesWarrantySearch(warranty, term) {
const needle = (term || '').trim().toLowerCase()
if (!needle) return true
if (!warranty) return false
const has = (value) => String(value ?? '').toLowerCase().includes(needle)
if (has(warranty.vendor) || has(warranty.servicelevel) || has(warranty.servicetag)) {
return true
}
return (warranty.assets || []).some((asset) => {
if (has(asset?.assetnumber)) return true
// The related machine is optional: a warranty on something that drives no
// machine has none, and a machine that was never linked leaves it null.
const machine = asset?.machine
return !!machine && (has(machine.machinenumber) || has(machine.name))
})
}
/** The fields above, in the order the placeholder should name them. */
export const WARRANTY_SEARCH_FIELDS = [
'vendor', 'service level', 'tag', 'asset number', 'machine',
]

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import { matchesWarrantySearch } from './warrantySearch'
// Reported: the warranty page could not be searched by machine number.
//
// The number was in the payload (_related_machine puts it there) and rendered
// in the table as a chip, but the filter looked only at vendor, service level,
// service tag and asset number. So the identifier the feature was built around
// - a PC is warranted as a PC but FOUND by the machine it drives - was the one
// thing the box could not find.
function warranty(overrides = {}) {
return {
vendor: 'Dell',
servicelevel: 'ProSupport',
servicetag: 'ABC1234',
assets: [{
assetnumber: 'PC-00912',
name: 'Bay 3105 workstation',
machine: { machinenumber: '3016', name: 'Makino a61nx' },
}],
...overrides,
}
}
describe('matchesWarrantySearch', () => {
it('matches on the machine number', () => {
// The reported case.
expect(matchesWarrantySearch(warranty(), '3016')).toBe(true)
})
it('matches on the machine name', () => {
expect(matchesWarrantySearch(warranty(), 'makino')).toBe(true)
})
it('still matches the fields it always did', () => {
expect(matchesWarrantySearch(warranty(), 'dell')).toBe(true)
expect(matchesWarrantySearch(warranty(), 'prosupport')).toBe(true)
expect(matchesWarrantySearch(warranty(), 'abc1234')).toBe(true)
expect(matchesWarrantySearch(warranty(), 'pc-00912')).toBe(true)
})
it('is case insensitive and ignores surrounding space', () => {
expect(matchesWarrantySearch(warranty(), ' MAKINO ')).toBe(true)
})
it('does not match an unrelated term', () => {
expect(matchesWarrantySearch(warranty(), '9999')).toBe(false)
})
it('keeps every row when the term is empty', () => {
expect(matchesWarrantySearch(warranty(), '')).toBe(true)
expect(matchesWarrantySearch(warranty(), ' ')).toBe(true)
expect(matchesWarrantySearch(warranty(), undefined)).toBe(true)
})
it('handles a warranty whose asset drives no machine', () => {
// machine is null whenever the covered asset has no controls link - a
// printer, or a PC nobody linked yet. It must not throw.
const row = warranty({ assets: [{ assetnumber: 'PR-42', machine: null }] })
expect(matchesWarrantySearch(row, 'PR-42')).toBe(true)
expect(matchesWarrantySearch(row, '3016')).toBe(false)
})
it('handles a warranty with no linked assets at all', () => {
const row = warranty({ assets: [] })
expect(matchesWarrantySearch(row, 'dell')).toBe(true)
expect(matchesWarrantySearch(row, '3016')).toBe(false)
})
it('tolerates missing fields rather than matching everything', () => {
// String(undefined) is 'undefined', which would make a search for 'und'
// match every row with an empty field.
const row = { vendor: null, servicelevel: undefined, assets: null }
expect(matchesWarrantySearch(row, 'und')).toBe(false)
expect(matchesWarrantySearch(row, 'null')).toBe(false)
})
})

View File

@@ -1,11 +1,19 @@
"""Tests for the computers collector PC->printer relationship sync. """The computers collector must NEVER write a printer assignment.
A collector payload carrying defaultprinter/printers creates AssetRelationship This file used to pin the opposite: `_sync_printer_links` turned a payload's
rows linking the PC asset to the resolved printer assets ('defaultprinter' for `defaultprinter`/`printers` into AssetRelationship rows. That contradicted the
the default, 'connectedto' for the rest). Stale collector links archive on rule the printer feature rests on - ASSIGNED and OBSERVED never mix - because a
re-report; manual links survive; unresolved identifiers warn without failing. bay that installed the wrong printer would have made itself right just by
reporting it.
It was not dormant. The collector schema advertised both keys and
COLLECTOR-INTEGRATION.md's example client set `defaultprinter`, while another
page of the same document promised nothing on this path writes an assignment
row. Retired 2026-08-24; these tests are the guard that it stays retired.
Observed queues have their own path (POST /api/collector/printers) and become
assignments only when a person asks, via seed-from-observed.
""" """
import pytest import pytest
from shopdb.core.models import ( from shopdb.core.models import (
@@ -81,119 +89,75 @@ def _active_links(db, pcid):
sourceassetid=pcid, isactive=True).all() sourceassetid=pcid, isactive=True).all()
def test_creates_default_and_connected_links(client, db, collector_key, scene): def test_printer_keys_create_no_relationships(client, db, collector_key, scene):
"""Default -> defaultprinter, others -> connectedto, resolved by three id forms.""" """The reported case, inverted: sending them must change nothing."""
resp = _post(client, { response = _post(client, {
'hostname': 'WJPC-P1', 'hostname': 'BAY-3105-PC',
'defaultprinter': 'HP-Materials', # windowsname
'printers': ['prn-front.wjs.local', '10.1.2.3'], # hostname, IP
})
assert resp.status_code == 200, resp.get_json()
data = resp.get_json()['data']
assert data['printerlinkcount'] == 3
assert not data['warnings']
pc = _pc_asset(db)
links = {(r.targetassetid, r.relationshiptype.relationshiptype)
for r in _active_links(db, pc.assetid)}
assert (scene['a'].assetid, 'defaultprinter') in links
assert (scene['b'].assetid, 'connectedto') in links
assert (scene['c'].assetid, 'connectedto') in links
def test_unresolved_identifier_warns_not_fails(client, db, collector_key, scene):
"""An unknown printer identifier is a warning, the rest still link."""
resp = _post(client, {
'hostname': 'WJPC-P2',
'defaultprinter': 'HP-Materials', 'defaultprinter': 'HP-Materials',
'printers': ['does-not-exist'], 'printers': ['HP-Materials', 'prn-front.wjs.local', '10.1.2.3'],
}) })
assert resp.status_code == 200 assert response.status_code in (200, 201), response.get_json()
data = resp.get_json()['data']
assert any('unresolved printer: does-not-exist' in w pcid = _pc_asset(db).assetid
for w in data['warnings']) assert _active_links(db, pcid) == [], (
pc = _pc_asset(db) 'a collector report wrote printer relationships')
assert data['printerlinkcount'] == 1
assert len(_active_links(db, pc.assetid)) == 1
def test_idempotent_no_duplicates(client, db, collector_key, scene): def test_the_push_still_succeeds_and_says_why(client, db, collector_key, scene):
"""Re-report the same set: no new rows, still one link per printer.""" """Warn, do not reject.
payload = {'hostname': 'WJPC-P3', 'defaultprinter': 'HP-Materials',
'printers': ['Zebra-Ship']}
assert _post(client, payload).status_code == 200
assert _post(client, payload).status_code == 200
pc = _pc_asset(db) Unknown keys are not validated, so a client written against the old docs
all_rows = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).all() keeps pushing successfully - only its printer keys stop doing anything.
assert len(all_rows) == 2 # one default + one connected, no dupes Silently dropping them would leave an integrator believing assignments were
assert all(r.isactive for r in all_rows) still being written, which is worse than the original bug.
"""
response = _post(client, {
'hostname': 'BAY-3105-PC',
'defaultprinter': 'HP-Materials',
})
assert response.status_code in (200, 201)
warnings = ' '.join(response.get_json()['data']['warnings']).lower()
assert 'defaultprinter' in warnings
assert 'seed-from-observed' in warnings, (
'the warning should say where assignment actually comes from')
def test_dropped_printer_is_archived(client, db, collector_key, scene): def test_a_payload_without_printer_keys_warns_about_nothing(
"""A printer that stops being reported loses its collector link.""" client, db, collector_key, scene):
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials', """The normal case must stay quiet - this is every push in the fleet."""
'printers': ['Zebra-Ship']}) response = _post(client, {'hostname': 'BAY-3105-PC'})
pc = _pc_asset(db) assert response.status_code in (200, 201)
assert len(_active_links(db, pc.assetid)) == 2 warnings = ' '.join(response.get_json()['data']['warnings']).lower()
assert 'printer' not in warnings
# Re-report without Zebra-Ship.
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials',
'printers': []})
active = _active_links(db, pc.assetid)
assert len(active) == 1
assert active[0].targetassetid == scene['a'].assetid
# the dropped row still exists but is inactive
zebra = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
assert zebra is not None and zebra.isactive is False
def test_default_change_archives_old_default(client, db, collector_key, scene): def test_a_manual_assignment_survives_a_report_that_names_printers(
"""Changing the default printer archives the previous defaultprinter link.""" client, db, collector_key, scene):
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'HP-Materials'}) """The row a person made is the one that matters, and it stays untouched."""
pc = _pc_asset(db) _post(client, {'hostname': 'BAY-3105-PC'})
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'prn-front.wjs.local'}) pcasset = _pc_asset(db)
active = _active_links(db, pc.assetid)
assert len(active) == 1
assert active[0].targetassetid == scene['b'].assetid
assert active[0].relationshiptype.relationshiptype == 'defaultprinter'
def test_manual_link_survives_collector_sync(client, db, collector_key, scene):
"""A manually-created link (no collector label) is never archived."""
# PC created first via a bare report.
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials',
'printers': ['Zebra-Ship']})
pc = _pc_asset(db)
# Manual connectedto link PC -> printer B (label stays NULL).
manual = AssetRelationship( manual = AssetRelationship(
sourceassetid=pc.assetid, targetassetid=scene['b'].assetid, sourceassetid=pcasset.assetid, targetassetid=scene['a'].assetid,
relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand') relationshiptypeid=scene['dp_type'].relationshiptypeid)
db.session.add(manual) db.session.add(manual)
db.session.commit() db.session.commit()
manual_id = manual.relationshipid
# Re-report dropping Zebra-Ship and never mentioning printer B. _post(client, {
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials', 'hostname': 'BAY-3105-PC',
'printers': []}) 'defaultprinter': 'prn-front.wjs.local', # a DIFFERENT printer
'printers': ['prn-front.wjs.local'],
})
surviving = db.session.get(AssetRelationship, manual_id) links = _active_links(db, pcasset.assetid)
assert surviving.isactive is True # manual link untouched assert len(links) == 1
# Zebra (collector) archived. assert links[0].targetassetid == scene['a'].assetid, (
zebra = AssetRelationship.query.filter_by( 'the report retargeted a human-made default printer')
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
assert zebra.isactive is False
def test_no_printer_keys_leaves_links_untouched(client, db, collector_key, scene): def test_the_retired_keys_are_gone_from_the_collector_schema(app):
"""A report with no printer keys does not archive existing links.""" """The schema advertised them, which is how integrators found them."""
_post(client, {'hostname': 'WJPC-P7', 'defaultprinter': 'HP-Materials'}) from plugins.computers.plugin import ComputersPlugin
pc = _pc_asset(db)
assert len(_active_links(db, pc.assetid)) == 1
# A plain update (no defaultprinter, no printers key) must not archive. fields = ComputersPlugin().get_collector_schema()['fields']
_post(client, {'hostname': 'WJPC-P7', 'currentuser': 'alice'}) assert 'defaultprinter' not in fields
assert len(_active_links(db, pc.assetid)) == 1 assert 'printers' not in fields