Add collector PC->printer links and searchable custom fields
All checks were successful
CI / backend (push) Successful in 1m24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

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:
cproudlock
2026-07-12 14:01:20 -04:00
parent 9daf1578a7
commit 275224822e
12 changed files with 664 additions and 6 deletions

View File

@@ -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'])

View File

@@ -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:

View File

@@ -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_<type>_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))

View File

@@ -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),
}