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:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
50
migrations/versions/7d24_customfield_searchable.py
Normal file
50
migrations/versions/7d24_customfield_searchable.py
Normal 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')
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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