Add collector PC->printer links and searchable custom fields
Collector: the computers collector schema gains defaultprinter and printers; apply_collector_payload resolves each reported identifier to a printer asset (windowsname/hostname/sharename/assetnumber/IP, first-hit case-insensitive) and idempotently syncs relationships - defaultprinter (directional) for the default, connectedto for the rest. Collector-created rows are tagged so a re-report archives dropped links while manual relationships are never touched; unresolved identifiers warn instead of failing. Both PC and printer detail pages show the links via the shared relationships card (no frontend change). GE-Enforce Win32_Printer collection snippet documented. Searchable custom fields: a per-field searchable flag (migration 7d24); global search matches custom-field values on flagged active fields and routes each hit to the asset detail page, reusing the existing (type,id) dedupe and search_<type>_enabled domain filter. Searchable toggle on the Custom Fields settings page. 822 tests pass; both verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
199
tests/test_core/test_collector_printers.py
Normal file
199
tests/test_core/test_collector_printers.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Tests for the computers collector PC->printer relationship sync.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import (
|
||||
Asset, AssetType, AssetRelationship, RelationshipType, CommunicationType,
|
||||
Communication)
|
||||
from plugins.printers.models import Printer
|
||||
|
||||
KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
"""Computer + printer asset types, the two relationship types, and three
|
||||
printer assets identified by windowsname / hostname / IP."""
|
||||
pc_type = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
pr_type = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers')
|
||||
dp_type = RelationshipType(relationshiptype='defaultprinter',
|
||||
description='PC to default printer',
|
||||
isdirectional=True)
|
||||
ct_type = RelationshipType(relationshiptype='connectedto',
|
||||
description='Network link', isdirectional=False)
|
||||
ip_comtype = CommunicationType(comtype='IP')
|
||||
db.session.add_all([pc_type, pr_type, dp_type, ct_type, ip_comtype])
|
||||
db.session.flush()
|
||||
|
||||
# Printer A: matched by windowsname. Printer B: by hostname. Printer C: by IP.
|
||||
a_asset = Asset(assetnumber='PRN-A', name='Materials HP',
|
||||
assettypeid=pr_type.assettypeid, isactive=True)
|
||||
b_asset = Asset(assetnumber='PRN-B', name='Front Office',
|
||||
assettypeid=pr_type.assettypeid, isactive=True)
|
||||
c_asset = Asset(assetnumber='PRN-C', name='Shipping Label',
|
||||
assettypeid=pr_type.assettypeid, isactive=True)
|
||||
db.session.add_all([a_asset, b_asset, c_asset])
|
||||
db.session.flush()
|
||||
|
||||
db.session.add_all([
|
||||
Printer(assetid=a_asset.assetid, windowsname='HP-Materials'),
|
||||
Printer(assetid=b_asset.assetid, hostname='prn-front.wjs.local'),
|
||||
Printer(assetid=c_asset.assetid, windowsname='Zebra-Ship'),
|
||||
])
|
||||
db.session.add(Communication(assetid=c_asset.assetid,
|
||||
comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress='10.1.2.3', isprimary=True))
|
||||
db.session.commit()
|
||||
|
||||
return {'pc_type': pc_type, 'dp_type': dp_type, 'ct_type': ct_type,
|
||||
'a': a_asset, 'b': b_asset, 'c': c_asset}
|
||||
|
||||
|
||||
def _post(client, payload):
|
||||
return client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
|
||||
|
||||
def _pc_asset(db):
|
||||
from plugins.computers.models import Computer
|
||||
comp = Computer.query.first()
|
||||
return comp.asset
|
||||
|
||||
|
||||
def _active_links(db, pcid):
|
||||
return AssetRelationship.query.filter_by(
|
||||
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',
|
||||
'defaultprinter': 'HP-Materials',
|
||||
'printers': ['does-not-exist'],
|
||||
})
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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_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).
|
||||
manual = AssetRelationship(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['b'].assetid,
|
||||
relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand')
|
||||
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': []})
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
114
tests/test_core/test_search_customfields.py
Normal file
114
tests/test_core/test_search_customfields.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Global search matches custom-field VALUES for fields flagged searchable.
|
||||
|
||||
Pins: a searchable custom field's value routes to its asset; a non-searchable
|
||||
field is NOT matched; disabling the asset's search domain excludes the hit; an
|
||||
asset matched by BOTH its assetnumber and a custom field appears once; an
|
||||
inactive field is excluded.
|
||||
|
||||
Search by a distinctive value string so only the custom-field domain can match
|
||||
(the value is not any built-in identifier).
|
||||
"""
|
||||
|
||||
from shopdb.core.api.settings import invalidate_settings_cache
|
||||
|
||||
|
||||
def _seed_computer(client, db, auth_headers, assetnumber):
|
||||
"""Create a computer asset and return its assetid."""
|
||||
from shopdb.core.models import AssetType, Asset
|
||||
if not AssetType.query.filter_by(assettype='computer').first():
|
||||
db.session.add(AssetType(assettype='computer', pluginname='computer',
|
||||
tablename='computer', description='c'))
|
||||
db.session.commit()
|
||||
resp = client.post('/api/computers',
|
||||
json={'assetnumber': assetnumber},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
asset = Asset.query.filter_by(assetnumber=assetnumber).first()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _seed_field(db, label, searchable=True, isactive=True):
|
||||
"""Create a computer-scoped custom field and return its fieldid."""
|
||||
from shopdb.core.models import AssetType, CustomField
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
field = CustomField(assettypeid=assettype.assettypeid,
|
||||
fieldkey=label.replace(' ', '').lower(),
|
||||
label=label, datatype='text',
|
||||
searchable=searchable, isactive=isactive)
|
||||
db.session.add(field)
|
||||
db.session.commit()
|
||||
return field.fieldid
|
||||
|
||||
|
||||
def _set_value(db, fieldid, assetid, value):
|
||||
from shopdb.core.models import CustomFieldValue
|
||||
db.session.add(CustomFieldValue(fieldid=fieldid, assetid=assetid, value=value))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _hits(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data']['results']
|
||||
|
||||
|
||||
def test_searchable_field_value_matches_and_routes(client, db, auth_headers):
|
||||
"""A searchable custom-field value matches and routes to the asset page."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF01')
|
||||
fieldid = _seed_field(db, 'Tool Crib Number', searchable=True)
|
||||
_set_value(db, fieldid, assetid, 'cribval01')
|
||||
|
||||
hits = [r for r in _hits(client, auth_headers, 'cribval01')
|
||||
if r['type'] == 'computer']
|
||||
assert len(hits) == 1
|
||||
assert hits[0]['url'].startswith('/pcs/')
|
||||
assert hits[0]['subtitle'] == 'Tool Crib Number'
|
||||
|
||||
|
||||
def test_non_searchable_field_value_does_not_match(client, db, auth_headers):
|
||||
"""A value on a NON-searchable field is not matched by global search."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF02')
|
||||
fieldid = _seed_field(db, 'Internal Note', searchable=False)
|
||||
_set_value(db, fieldid, assetid, 'cribval02')
|
||||
|
||||
assert _hits(client, auth_headers, 'cribval02') == []
|
||||
|
||||
|
||||
def test_disabled_domain_excludes_customfield_hit(client, db, auth_headers):
|
||||
"""search_computer_enabled=false hides a custom-field hit on a computer."""
|
||||
from shopdb.core.models import Setting
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF03')
|
||||
fieldid = _seed_field(db, 'Bench Tag', searchable=True)
|
||||
_set_value(db, fieldid, assetid, 'cribval03')
|
||||
|
||||
# Sanity: enabled by default.
|
||||
assert [r for r in _hits(client, auth_headers, 'cribval03')
|
||||
if r['type'] == 'computer']
|
||||
|
||||
Setting.set('search_computer_enabled', False, valuetype='boolean',
|
||||
category='search')
|
||||
invalidate_settings_cache()
|
||||
assert [r for r in _hits(client, auth_headers, 'cribval03')
|
||||
if r['type'] == 'computer'] == []
|
||||
|
||||
|
||||
def test_dedupe_assetnumber_and_customfield(client, db, auth_headers):
|
||||
"""An asset matched by both assetnumber and a custom field appears once."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'cribval04')
|
||||
fieldid = _seed_field(db, 'Crib Ref', searchable=True)
|
||||
_set_value(db, fieldid, assetid, 'cribval04')
|
||||
|
||||
hits = [r for r in _hits(client, auth_headers, 'cribval04')
|
||||
if r['type'] == 'computer']
|
||||
assert len(hits) == 1
|
||||
# The higher-relevance built-in assetnumber match wins the dedupe.
|
||||
assert hits[0]['relevance'] == 100
|
||||
|
||||
|
||||
def test_inactive_field_excluded(client, db, auth_headers):
|
||||
"""A value on an inactive (searchable) field is not matched."""
|
||||
assetid = _seed_computer(client, db, auth_headers, 'AST-CF05')
|
||||
fieldid = _seed_field(db, 'Retired Tag', searchable=True, isactive=False)
|
||||
_set_value(db, fieldid, assetid, 'cribval05')
|
||||
|
||||
assert _hits(client, auth_headers, 'cribval05') == []
|
||||
Reference in New Issue
Block a user