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

@@ -22,6 +22,24 @@ ADR-007 and ADR-002.
### Added ### 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_<type>_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 - Single-label sheet-position printing. The single asset-label page
(`/print/asset-label/:assettype/:id`) gained an Output control that toggles (`/print/asset-label/:assettype/:id`) gained an Output control that toggles
between the standalone label (unchanged default) and placing that one label at between the standalone label (unchanged default) and placing that one label at

View File

@@ -209,10 +209,38 @@ a column.
| `modelnumber` | string | `Computer.modelnumberid`, scoped to the vendor when known. Model row auto-created if missing. | | `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). | | `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. | | `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`. 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. 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) ### pc-type mapping (configurable per site)
`pctype` (e.g. `gea-shopfloor-cmm`) maps to a shopdb Computer Type through `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 {} 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. --- # --- Build payload. Field names MUST match get_collector_schema exactly. ---
$payload = @{ hostname = $hostname } $payload = @{ hostname = $hostname }
if ($defaultPrinter) { $payload['defaultprinter'] = $defaultPrinter }
if ($printerIds.Count) { $payload['printers'] = $printerIds }
if ($machineNumber) { $payload['machinenumber'] = $machineNumber } if ($machineNumber) { $payload['machinenumber'] = $machineNumber }
if ($pcType) { $payload['pctype'] = $pcType } if ($pcType) { $payload['pctype'] = $pcType }
if ($pcSubType) { $payload['pcsubtype'] = $pcSubType } if ($pcSubType) { $payload['pcsubtype'] = $pcSubType }

View File

@@ -319,6 +319,15 @@ One boolean key per search domain, keyed `search_<type>_enabled` (default
`true`). Toggles whether a domain appears in global search results. The set is `true`). Toggles whether a domain appears in global search results. The set is
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`. 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_<type>_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 ## See also

View File

@@ -32,6 +32,7 @@
<th>Type</th> <th>Type</th>
<th>On Detail</th> <th>On Detail</th>
<th>On Form</th> <th>On Form</th>
<th>Search</th>
<th>Order</th> <th>Order</th>
<th>Active</th> <th>Active</th>
<th>Actions</th> <th>Actions</th>
@@ -47,6 +48,7 @@
</td> </td>
<td>{{ f.showondetail ? 'yes' : '-' }}</td> <td>{{ f.showondetail ? 'yes' : '-' }}</td>
<td>{{ f.showonform ? 'yes' : '-' }}</td> <td>{{ f.showonform ? 'yes' : '-' }}</td>
<td>{{ f.searchable ? 'yes' : '-' }}</td>
<td>{{ f.sortorder }}</td> <td>{{ f.sortorder }}</td>
<td> <td>
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span> <span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
@@ -57,7 +59,7 @@
</td> </td>
</tr> </tr>
<tr v-if="visibleFields.length === 0"> <tr v-if="visibleFields.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td> <td colspan="9" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -92,6 +94,10 @@
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label> <label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label> <label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
</div> </div>
<div class="form-row">
<label class="checkbox-label"><input type="checkbox" v-model="form.searchable" /> Searchable</label>
<span class="hint">Include this field's values in global search.</span>
</div>
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>Sort Order</label> <label>Sort Order</label>
@@ -131,7 +137,7 @@ const error = ref('')
const form = ref(blankForm()) const form = ref(blankForm())
function 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) { function typeLabel(t) {
@@ -174,6 +180,7 @@ function openModal(item = null) {
options: (item.options || []).join('\n'), options: (item.options || []).join('\n'),
showondetail: item.showondetail !== false, showondetail: item.showondetail !== false,
showonform: item.showonform !== false, showonform: item.showonform !== false,
searchable: item.searchable === true,
sortorder: item.sortorder || 0, sortorder: item.sortorder || 0,
isactive: item.isactive !== false, isactive: item.isactive !== false,
} }

View File

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

View File

@@ -16,6 +16,12 @@ 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'
class ComputersPlugin(BasePlugin): class ComputersPlugin(BasePlugin):
""" """
@@ -92,6 +98,26 @@ 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
# 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, computerid=comp.computerid, appid=app.appid,
installedversion=version)) installedversion=version))
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
db.session.commit() db.session.commit()
return { return {
'action': action, 'action': action,
'assetid': comp.assetid, 'assetid': comp.assetid,
'identityvalue': hostname, 'identityvalue': hostname,
'warnings': warnings, '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: def on_install(self, app: Flask) -> None:
"""Called when plugin is installed.""" """Called when plugin is installed."""
with app.app_context(): with app.app_context():

View File

@@ -244,13 +244,18 @@ def generic_collect(pluginname):
) )
db.session.commit() db.session.commit()
return success_response({ response = {
'status': 'ok', 'status': 'ok',
'action': action, 'action': action,
'assetid': outcome.get('assetid'), 'assetid': outcome.get('assetid'),
'identityvalue': outcome.get('identityvalue', identityvalue), 'identityvalue': outcome.get('identityvalue', identityvalue),
'warnings': outcome.get('warnings', []), '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']) @collector_bp.route('/_schemas', methods=['GET'])

View File

@@ -87,6 +87,7 @@ def create_field():
existing.options = _normalize_options(data.get('options')) existing.options = _normalize_options(data.get('options'))
existing.showondetail = bool(data.get('showondetail', True)) existing.showondetail = bool(data.get('showondetail', True))
existing.showonform = bool(data.get('showonform', True)) existing.showonform = bool(data.get('showonform', True))
existing.searchable = bool(data.get('searchable', False))
existing.sortorder = data.get('sortorder', 0) existing.sortorder = data.get('sortorder', 0)
db.session.commit() db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing field') return success_response(existing.to_dict(), message='Reactivated existing field')
@@ -101,6 +102,7 @@ def create_field():
options=_normalize_options(data.get('options')), options=_normalize_options(data.get('options')),
showondetail=bool(data.get('showondetail', True)), showondetail=bool(data.get('showondetail', True)),
showonform=bool(data.get('showonform', True)), showonform=bool(data.get('showonform', True)),
searchable=bool(data.get('searchable', False)),
sortorder=data.get('sortorder', 0), sortorder=data.get('sortorder', 0),
) )
db.session.add(field) db.session.add(field)
@@ -124,7 +126,7 @@ def update_field(fieldid):
field.datatype = data['datatype'] field.datatype = data['datatype']
if 'options' in data: if 'options' in data:
field.options = _normalize_options(data['options']) field.options = _normalize_options(data['options'])
for key in ('showondetail', 'showonform', 'isactive'): for key in ('showondetail', 'showonform', 'isactive', 'searchable'):
if key in data: if key in data:
setattr(field, key, bool(data[key])) setattr(field, key, bool(data[key]))
if 'sortorder' in data: if 'sortorder' in data:

View File

@@ -12,7 +12,8 @@ from sqlalchemy.orm import joinedload
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import ( from shopdb.core.models import (
Application, Setting, 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.core.api.settings import get_cached_settings
from shopdb.utils.responses import success_response from shopdb.utils.responses import success_response
@@ -343,6 +344,41 @@ def _search_measuringtools(query, search_term):
return results 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): def _search_by_ip(query, search_term):
"""Search Communications table for IP address matches.""" """Search Communications table for IP address matches."""
results = [] results = []
@@ -799,6 +835,7 @@ def global_search():
results.extend(_search_employees(query, search_term)) results.extend(_search_employees(query, search_term))
results.extend(_search_assets(query, search_term)) results.extend(_search_assets(query, search_term))
results.extend(_search_measuringtools(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_notifications(query, search_term))
results.extend(_search_hostnames(query, search_term)) results.extend(_search_hostnames(query, search_term))
results.extend(_search_vendor_model_type(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') showonform = db.Column(db.Boolean, nullable=False, server_default='1')
sortorder = db.Column(db.Integer, nullable=False, server_default='0') sortorder = db.Column(db.Integer, nullable=False, server_default='0')
isactive = db.Column(db.Boolean, nullable=False, server_default='1') 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__ = ( __table_args__ = (
db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'), db.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'),
@@ -57,6 +59,7 @@ class CustomField(db.Model):
'showonform': bool(self.showonform), 'showonform': bool(self.showonform),
'sortorder': self.sortorder, 'sortorder': self.sortorder,
'isactive': bool(self.isactive), 'isactive': bool(self.isactive),
'searchable': bool(self.searchable),
} }

View 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

View 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') == []