diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc7f33..db4f9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,24 @@ ADR-007 and ADR-002. ### Added +- Collector-driven PC -> printer relationships. The computers collector schema + gained optional `defaultprinter` (string) and `printers` (array of strings) + fields carrying Win32_Printer identifiers. On ingest each identifier resolves + to a printer asset (by windows name / share / hostname / asset number-name or + a communications IP) and the PC is linked to it: the default via a + `defaultprinter` relationship, the rest via `connectedto`. The links render in + the shared Relationships card on both the PC and printer detail pages. The + sync is idempotent and archives collector-created links to printers no longer + reported (tagged `assetrelationships.label = 'collector:printers'`, so + manually-created links are never touched); unresolved identifiers become + response warnings, never failures. The collector response carries + `printerlinkcount` and a `printerlinks` list. See docs/COLLECTOR-INTEGRATION.md. +- Searchable custom fields. Each custom-field definition gained a `searchable` + flag (Settings > Custom Fields). When on, that field's stored values are + matched by global search and a hit routes to the owning asset's detail page. + The asset's `search__enabled` domain toggle still applies, and matches + dedupe against built-in-field asset hits so an asset appears once. Inactive or + non-searchable fields are never matched. - Single-label sheet-position printing. The single asset-label page (`/print/asset-label/:assettype/:id`) gained an Output control that toggles between the standalone label (unchanged default) and placing that one label at diff --git a/docs/COLLECTOR-INTEGRATION.md b/docs/COLLECTOR-INTEGRATION.md index 118de1d..81f406e 100644 --- a/docs/COLLECTOR-INTEGRATION.md +++ b/docs/COLLECTOR-INTEGRATION.md @@ -209,10 +209,38 @@ a column. | `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. | | `osname` | string | `Computer.osid`. Controlled vocab: looked up in `operatingsystems`, NOT auto-created. Unknown value -> warning (row still written, `osid` left unset). | | `installedsoftware` | array of `{name, version}` | `ComputerInstalledApp` rows for applications shopdb already tracks. Unknown app name -> warning, skipped. | +| `defaultprinter` | string | The default printer's identifier (windows name / share / hostname / port IP). Resolved to a printer asset and linked PC -> printer as a `defaultprinter` relationship. Unresolved -> warning. | +| `printers` | array of strings | All installed network printer identifiers. Each resolves to a printer asset and is linked PC -> printer as a `connectedto` relationship (the default is skipped here since it already links as `defaultprinter`). Unresolved entries -> warning. | Schema source of truth: `get_collector_schema` in `plugins/computers/plugin.py`. If you change the payload, change it there and re-check this table. +### PC -> printer relationship sync + +When a payload carries `defaultprinter` and/or `printers`, the collector syncs +`AssetRelationship` rows so a PC page shows its printers and a printer page shows +the PCs that use it (both render in the shared Relationships card). + +- Resolution: each identifier is matched, first hit wins, against the printer's + `windowsname`, `hostname`, `sharename`, its asset number/name, then any active + printer communications IP. Case-insensitive except the IP (exact). An + identifier that resolves to nothing adds a warning and is skipped; it never + fails the whole push. +- Link types: the default printer links with `defaultprinter` (directional, PC + is the source); every other reported printer links with `connectedto` + (symmetric). A printer that is both default and in `printers` links only as + the default. +- Idempotent: re-reporting the same set creates no duplicate rows (an existing + matching row is reactivated if it was archived, otherwise left as is). +- Stale-link archive: on every push, collector-created links to printers no + longer reported are set inactive. Collector-created rows are tagged in + `assetrelationships.label = 'collector:printers'`; only tagged rows are ever + archived, so links you create by hand in the UI are never touched. A payload + that omits BOTH printer keys leaves all existing printer links untouched + (report an empty `printers: []` to clear the auto links instead). +- Response: the collector response carries `printerlinkcount` and a + `printerlinks` list of `{assetid, relationshiptype}` for the links kept. + ### pc-type mapping (configurable per site) `pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through @@ -487,8 +515,32 @@ function Send-ShopdbCollectorReport { try { $pcSubType = (Get-Content -LiteralPath 'C:\Enrollment\pc-subtype.txt' -First 1 -ErrorAction Stop).Trim() } catch {} } + # --- Installed printers (Win32_Printer). The Default flag marks the one + # default printer. We report each printer's port name (an IP or a queue + # host for network printers) and fall back to the share/printer name, which + # the collector resolves flexibly against printer windowsname/hostname/IP. --- + $defaultPrinter = '' + $printerIds = @() + try { + $printers = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop + foreach ($p in $printers) { + if ($p.Local) { continue } # skip local-only (XPS/PDF/OneNote) + # Prefer the port name (IP or queue host); fall back to ShareName, + # then the printer Name. + $identity = $p.PortName + if (-not $identity) { $identity = $p.ShareName } + if (-not $identity) { $identity = $p.Name } + if (-not $identity) { continue } + $printerIds += $identity + if ($p.Default) { $defaultPrinter = $identity } + } + $printerIds = @($printerIds | Select-Object -Unique) + } catch { Write-CollectorLog "WARN printer read failed: $($_.Exception.Message)" } + # --- Build payload. Field names MUST match get_collector_schema exactly. --- $payload = @{ hostname = $hostname } + if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter } + if ($printerIds.Count) { $payload['printers'] = $printerIds } if ($machineNumber) { $payload['machinenumber'] = $machineNumber } if ($pcType) { $payload['pctype'] = $pcType } if ($pcSubType) { $payload['pcsubtype'] = $pcSubType } diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 33eca53..26bf14f 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -319,6 +319,15 @@ One boolean key per search domain, keyed `search__enabled` (default `true`). Toggles whether a domain appears in global search results. The set is generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`. +## Custom fields + +Site-defined extra attributes per asset type (Settings > Custom Fields, table +`customfields`). Each field has a `searchable` flag (default off). When on, the +field's stored values are matched by global search and a hit routes to the +owning asset's detail page. The asset's `search__enabled` domain toggle +still applies, so a custom-field hit on a computer only shows when the computer +search domain is enabled. Inactive or non-searchable fields are never matched. + --- ## See also diff --git a/frontend/src/views/settings/CustomFieldsList.vue b/frontend/src/views/settings/CustomFieldsList.vue index 39b9557..8bb6223 100644 --- a/frontend/src/views/settings/CustomFieldsList.vue +++ b/frontend/src/views/settings/CustomFieldsList.vue @@ -32,6 +32,7 @@ Type On Detail On Form + Search Order Active Actions @@ -47,6 +48,7 @@ {{ f.showondetail ? 'yes' : '-' }} {{ f.showonform ? 'yes' : '-' }} + {{ f.searchable ? 'yes' : '-' }} {{ f.sortorder }} {{ f.isactive ? 'yes' : 'no' }} @@ -57,7 +59,7 @@ - No custom fields for this asset type + No custom fields for this asset type @@ -92,6 +94,10 @@ +
+ + Include this field's values in global search. +
@@ -131,7 +137,7 @@ const error = ref('') const form = ref(blankForm()) function blankForm() { - return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, sortorder: 0, isactive: true } + return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, searchable: false, sortorder: 0, isactive: true } } function typeLabel(t) { @@ -174,6 +180,7 @@ function openModal(item = null) { options: (item.options || []).join('\n'), showondetail: item.showondetail !== false, showonform: item.showonform !== false, + searchable: item.searchable === true, sortorder: item.sortorder || 0, isactive: item.isactive !== false, } diff --git a/migrations/versions/7d24_customfield_searchable.py b/migrations/versions/7d24_customfield_searchable.py new file mode 100644 index 0000000..bb0b3fe --- /dev/null +++ b/migrations/versions/7d24_customfield_searchable.py @@ -0,0 +1,50 @@ +"""Custom field searchable flag (customfields.searchable) + +Adds a boolean customfields.searchable column (default false). When true, the +field's stored values are matched by global search and a hit routes to the +owning asset's detail page. + +Idempotent guard so it is safe on a partially-migrated box; real downgrade. + +Revision ID: 7d24_customfield_searchable +Revises: 7d23_user_mustchangepassword +Create Date: 2026-07-12 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d24_customfield_searchable' +down_revision = '7d23_user_mustchangepassword' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'customfields' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('customfields')} + if 'searchable' in columns: + return + + op.add_column( + 'customfields', + sa.Column('searchable', sa.Boolean(), + nullable=False, server_default=sa.false())) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'customfields' not in insp.get_table_names(): + return + columns = {c['name'] for c in insp.get_columns('customfields')} + if 'searchable' not in columns: + return + + op.drop_column('customfields', 'searchable') diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index e88d10f..b4c14bf 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -16,6 +16,12 @@ 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' + class ComputersPlugin(BasePlugin): """ @@ -92,6 +98,26 @@ 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.'), + }, }, } @@ -230,14 +256,150 @@ class ComputersPlugin(BasePlugin): computerid=comp.computerid, appid=app.appid, installedversion=version)) + # Printer relationship sync (only when the payload carried printer data). + printerlinks = self._sync_printer_links(comp.asset, payload, warnings) + db.session.commit() return { 'action': action, 'assetid': comp.assetid, 'identityvalue': hostname, 'warnings': warnings, + 'extra': { + 'printerlinks': printerlinks, + 'printerlinkcount': len(printerlinks), + }, } + # -- printer relationship sync ----------------------------------------- + + def _sync_printer_links(self, pcasset, payload, warnings): + """Idempotently sync PC->printer relationships from collector printer data. + + 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. + + 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). + """ + 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 + def on_install(self, app: Flask) -> None: """Called when plugin is installed.""" with app.app_context(): diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py index 4c7ac57..1b0bd65 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -244,13 +244,18 @@ def generic_collect(pluginname): ) db.session.commit() - return success_response({ + response = { 'status': 'ok', 'action': action, 'assetid': outcome.get('assetid'), 'identityvalue': outcome.get('identityvalue', identityvalue), 'warnings': outcome.get('warnings', []), - }, message=f'{pluginname} collector {action}') + } + # Generic passthrough: a plugin may surface extra result fields (e.g. the + # computers plugin returns printerlinks). Known keys win over extras. + for key, value in (outcome.get('extra') or {}).items(): + response.setdefault(key, value) + return success_response(response, message=f'{pluginname} collector {action}') @collector_bp.route('/_schemas', methods=['GET']) diff --git a/shopdb/core/api/customfields.py b/shopdb/core/api/customfields.py index fd40940..3032bf0 100644 --- a/shopdb/core/api/customfields.py +++ b/shopdb/core/api/customfields.py @@ -87,6 +87,7 @@ def create_field(): existing.options = _normalize_options(data.get('options')) existing.showondetail = bool(data.get('showondetail', True)) existing.showonform = bool(data.get('showonform', True)) + existing.searchable = bool(data.get('searchable', False)) existing.sortorder = data.get('sortorder', 0) db.session.commit() return success_response(existing.to_dict(), message='Reactivated existing field') @@ -101,6 +102,7 @@ def create_field(): options=_normalize_options(data.get('options')), showondetail=bool(data.get('showondetail', True)), showonform=bool(data.get('showonform', True)), + searchable=bool(data.get('searchable', False)), sortorder=data.get('sortorder', 0), ) db.session.add(field) @@ -124,7 +126,7 @@ def update_field(fieldid): field.datatype = data['datatype'] if 'options' in data: field.options = _normalize_options(data['options']) - for key in ('showondetail', 'showonform', 'isactive'): + for key in ('showondetail', 'showonform', 'isactive', 'searchable'): if key in data: setattr(field, key, bool(data[key])) if 'sortorder' in data: diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py index f504948..bc8aa8b 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -12,7 +12,8 @@ from sqlalchemy.orm import joinedload from shopdb.extensions import db from shopdb.core.models import ( Application, Setting, - Asset, AssetType, Communication, Vendor, Model + Asset, AssetType, Communication, Vendor, Model, + CustomField, CustomFieldValue ) from shopdb.core.api.settings import get_cached_settings from shopdb.utils.responses import success_response @@ -343,6 +344,41 @@ def _search_measuringtools(query, search_term): return results +def _search_customfields(query, search_term): + """Search custom-field VALUES for fields flagged searchable. + + Joins CustomFieldValue -> CustomField (searchable + active) -> Asset (active) + and emits a normal asset result routed to that asset's detail page. Results + are deduped against built-in-field asset matches by the shared (type, id) key + in global_search, and the asset's search__enabled domain toggle is + applied by the same end-of-request domain filter. The matched field label is + put in the subtitle so a value hit reads sensibly. + """ + results = [] + try: + rows = db.session.query(Asset, CustomField).join( + CustomFieldValue, CustomFieldValue.assetid == Asset.assetid + ).join( + CustomField, CustomField.fieldid == CustomFieldValue.fieldid + ).options( + joinedload(Asset.assettype), + joinedload(Asset.location), + ).filter( + Asset.isactive == True, + CustomField.searchable == True, + CustomField.isactive == True, + CustomFieldValue.value.ilike(search_term), + ).limit(15).all() + + for asset, field in rows: + result = _get_asset_result(asset, query, relevance=40) + result['subtitle'] = field.label + results.append(result) + except Exception as e: + logger.error(f"Custom field search failed: {e}") + return results + + def _search_by_ip(query, search_term): """Search Communications table for IP address matches.""" results = [] @@ -799,6 +835,7 @@ def global_search(): results.extend(_search_employees(query, search_term)) results.extend(_search_assets(query, search_term)) results.extend(_search_measuringtools(query, search_term)) + results.extend(_search_customfields(query, search_term)) results.extend(_search_notifications(query, search_term)) results.extend(_search_hostnames(query, search_term)) results.extend(_search_vendor_model_type(query, search_term)) diff --git a/shopdb/core/models/customfield.py b/shopdb/core/models/customfield.py index 94c7475..afb39ac 100644 --- a/shopdb/core/models/customfield.py +++ b/shopdb/core/models/customfield.py @@ -33,6 +33,8 @@ class CustomField(db.Model): showonform = db.Column(db.Boolean, nullable=False, server_default='1') sortorder = db.Column(db.Integer, nullable=False, server_default='0') isactive = db.Column(db.Boolean, nullable=False, server_default='1') + # When true, this field's values are matched by global search. + searchable = db.Column(db.Boolean, nullable=False, server_default='0') __table_args__ = ( db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'), @@ -57,6 +59,7 @@ class CustomField(db.Model): 'showonform': bool(self.showonform), 'sortorder': self.sortorder, 'isactive': bool(self.isactive), + 'searchable': bool(self.searchable), } diff --git a/tests/test_core/test_collector_printers.py b/tests/test_core/test_collector_printers.py new file mode 100644 index 0000000..ac48bd9 --- /dev/null +++ b/tests/test_core/test_collector_printers.py @@ -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 diff --git a/tests/test_core/test_search_customfields.py b/tests/test_core/test_search_customfields.py new file mode 100644 index 0000000..24e3909 --- /dev/null +++ b/tests/test_core/test_search_customfields.py @@ -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') == []