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 back label's dotted preview outline outspecified the print reset and drew
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**
("table securesendsecrets already exists" on the tools plugin). Every
post-cutover baseline emitted a plain `op.create_table`, which is correct only
@@ -132,6 +139,20 @@ ADR-007 and ADR-002.
### 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
account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind
`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__)
# 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
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
# by a collector push, so hand-made tool links survive.
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Only rows carrying
# this label are archived 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'
# Marker stamped on the PC->machine "controls" link built from the reported
@@ -139,26 +137,10 @@ class ComputersPlugin(BasePlugin):
'type': 'array',
'items': {'name': 'string', 'version': 'string'},
},
# Printer identifiers reported by the GE-Enforce side, which
# runs Get-CimInstance Win32_Printer and marks the default with
# the Default flag. An identifier is the printer's windows name,
# share name, hostname, or port/IP; the collector resolves it
# 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.'),
},
# RETIRED: 'defaultprinter' and 'printers' used to live here and
# wrote PC->printer assignment rows from a report. That is the
# one thing this path must never do - see _RETIRED_PRINTER_KEYS.
# A client still sending them is warned, not rejected.
'accessprotocols': {
'type': 'array',
'items': {'type': 'string'},
@@ -423,8 +405,9 @@ class ComputersPlugin(BasePlugin):
else:
machinelinks = []
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
# Printer assignment is NOT collected. See _warn_retired_printer_keys.
self._warn_retired_printer_keys(payload, warnings)
printerlinks = []
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
# get an attached MeasuringTool asset auto-created and linked.
@@ -518,134 +501,38 @@ class ComputersPlugin(BasePlugin):
AccessProtocol.protocolid.in_(wanted)).all()] if wanted else []
return sorted(names)
def _sync_printer_links(self, pcasset, payload, warnings):
"""Idempotently sync PC->printer relationships from collector printer data.
def _warn_retired_printer_keys(self, payload, warnings):
"""Refuse to turn a report into an assignment, out loud.
Default printer -> 'defaultprinter' (directional). Other reported
printers -> 'connectedto' (symmetric). Resolves each identifier to a
printer asset by windows name / share / hostname / asset number / name
or a communications IP. Unresolved identifiers add a warning and never
fail the push.
RETIRED 2026-08-24. This used to be _sync_printer_links, which wrote
'defaultprinter' and 'connectedto' rows straight from a collector
payload. That contradicted the rule the whole printer feature rests on:
ASSIGNED and OBSERVED never mix. A bay that installed the wrong printer
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 ==
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported
desired set are set inactive. Only tagged rows are touched, so manual
links survive. Runs only when the payload carried a printer key
('defaultprinter' or 'printers'); a PC that reports without printer data
keeps its existing links. Returns the desired-link list (created + kept).
Worse than dormant, it was ADVERTISED: the collector schema listed both
keys and COLLECTOR-INTEGRATION.md's example client set defaultprinter,
while the same document promised on another page that nothing on this
path writes an assignment row. The document contradicted itself and the
code sided with the wrong half.
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 (
AssetRelationship, RelationshipType, Asset, Communication)
has_default = 'defaultprinter' in payload
has_list = 'printers' in payload
if not has_default and not has_list:
return []
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 -----------------------------------------------
sent = [key for key in ('defaultprinter', 'printers') if key in payload]
if sent:
warnings.append(
'%s ignored: a collector report never sets printer assignment. '
'Report queues to POST /api/collector/printers, then adopt them '
'with POST /api/printers/assignments/seed-from-observed.'
% ' and '.join(sent))
def _sync_machine_link(self, comp, machinenumber, warnings):
"""Link a PC to the machine it drives, from the reported machine number.

View File

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

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
rows linking the PC asset to the resolved printer assets ('defaultprinter' for
the default, 'connectedto' for the rest). Stale collector links archive on
re-report; manual links survive; unresolved identifiers warn without failing.
This file used to pin the opposite: `_sync_printer_links` turned a payload's
`defaultprinter`/`printers` into AssetRelationship rows. That contradicted the
rule the printer feature rests on - ASSIGNED and OBSERVED never mix - because a
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
from shopdb.core.models import (
@@ -81,119 +89,75 @@ def _active_links(db, pcid):
sourceassetid=pcid, isactive=True).all()
def test_creates_default_and_connected_links(client, db, collector_key, scene):
"""Default -> defaultprinter, others -> connectedto, resolved by three id forms."""
resp = _post(client, {
'hostname': 'WJPC-P1',
'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',
def test_printer_keys_create_no_relationships(client, db, collector_key, scene):
"""The reported case, inverted: sending them must change nothing."""
response = _post(client, {
'hostname': 'BAY-3105-PC',
'defaultprinter': 'HP-Materials',
'printers': ['does-not-exist'],
'printers': ['HP-Materials', 'prn-front.wjs.local', '10.1.2.3'],
})
assert resp.status_code == 200
data = resp.get_json()['data']
assert any('unresolved printer: does-not-exist' in w
for w in data['warnings'])
pc = _pc_asset(db)
assert data['printerlinkcount'] == 1
assert len(_active_links(db, pc.assetid)) == 1
assert response.status_code in (200, 201), response.get_json()
pcid = _pc_asset(db).assetid
assert _active_links(db, pcid) == [], (
'a collector report wrote printer relationships')
def test_idempotent_no_duplicates(client, db, collector_key, scene):
"""Re-report the same set: no new rows, still one link per printer."""
payload = {'hostname': 'WJPC-P3', 'defaultprinter': 'HP-Materials',
'printers': ['Zebra-Ship']}
assert _post(client, payload).status_code == 200
assert _post(client, payload).status_code == 200
def test_the_push_still_succeeds_and_says_why(client, db, collector_key, scene):
"""Warn, do not reject.
pc = _pc_asset(db)
all_rows = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).all()
assert len(all_rows) == 2 # one default + one connected, no dupes
assert all(r.isactive for r in all_rows)
Unknown keys are not validated, so a client written against the old docs
keeps pushing successfully - only its printer keys stop doing anything.
Silently dropping them would leave an integrator believing assignments were
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):
"""A printer that stops being reported loses its collector link."""
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials',
'printers': ['Zebra-Ship']})
pc = _pc_asset(db)
assert len(_active_links(db, pc.assetid)) == 2
# 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_a_payload_without_printer_keys_warns_about_nothing(
client, db, collector_key, scene):
"""The normal case must stay quiet - this is every push in the fleet."""
response = _post(client, {'hostname': 'BAY-3105-PC'})
assert response.status_code in (200, 201)
warnings = ' '.join(response.get_json()['data']['warnings']).lower()
assert 'printer' not in warnings
def test_default_change_archives_old_default(client, db, collector_key, scene):
"""Changing the default printer archives the previous defaultprinter link."""
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'HP-Materials'})
pc = _pc_asset(db)
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'prn-front.wjs.local'})
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).
def test_a_manual_assignment_survives_a_report_that_names_printers(
client, db, collector_key, scene):
"""The row a person made is the one that matters, and it stays untouched."""
_post(client, {'hostname': 'BAY-3105-PC'})
pcasset = _pc_asset(db)
manual = AssetRelationship(
sourceassetid=pc.assetid, targetassetid=scene['b'].assetid,
relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand')
sourceassetid=pcasset.assetid, targetassetid=scene['a'].assetid,
relationshiptypeid=scene['dp_type'].relationshiptypeid)
db.session.add(manual)
db.session.commit()
manual_id = manual.relationshipid
# Re-report dropping Zebra-Ship and never mentioning printer B.
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials',
'printers': []})
_post(client, {
'hostname': 'BAY-3105-PC',
'defaultprinter': 'prn-front.wjs.local', # a DIFFERENT printer
'printers': ['prn-front.wjs.local'],
})
surviving = db.session.get(AssetRelationship, manual_id)
assert surviving.isactive is True # manual link untouched
# Zebra (collector) archived.
zebra = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
assert zebra.isactive is False
links = _active_links(db, pcasset.assetid)
assert len(links) == 1
assert links[0].targetassetid == scene['a'].assetid, (
'the report retargeted a human-made default printer')
def test_no_printer_keys_leaves_links_untouched(client, db, collector_key, scene):
"""A report with no printer keys does not archive existing links."""
_post(client, {'hostname': 'WJPC-P7', 'defaultprinter': 'HP-Materials'})
pc = _pc_asset(db)
assert len(_active_links(db, pc.assetid)) == 1
def test_the_retired_keys_are_gone_from_the_collector_schema(app):
"""The schema advertised them, which is how integrators found them."""
from plugins.computers.plugin import ComputersPlugin
# A plain update (no defaultprinter, no printers key) must not archive.
_post(client, {'hostname': 'WJPC-P7', 'currentuser': 'alice'})
assert len(_active_links(db, pc.assetid)) == 1
fields = ComputersPlugin().get_collector_schema()['fields']
assert 'defaultprinter' not in fields
assert 'printers' not in fields