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:
@@ -16,6 +16,12 @@ from .api import computers_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Marker stamped on PC->printer links this collector creates. The stale-link
|
||||
# archive only touches rows carrying this label, so manually-created printer
|
||||
# relationships are never removed by a collector push. Stored in the
|
||||
# assetrelationships.label column (no origin column exists; see BUILD notes).
|
||||
PRINTER_LINK_ORIGIN = 'collector:printers'
|
||||
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
"""
|
||||
@@ -92,6 +98,26 @@ class ComputersPlugin(BasePlugin):
|
||||
'type': 'array',
|
||||
'items': {'name': 'string', 'version': 'string'},
|
||||
},
|
||||
# Printer identifiers reported by the GE-Enforce side, which
|
||||
# runs Get-CimInstance Win32_Printer and marks the default with
|
||||
# the Default flag. An identifier is the printer's windows name,
|
||||
# share name, hostname, or port/IP; the collector resolves it
|
||||
# flexibly to a printer asset. Both optional. Presence of either
|
||||
# key drives the PC->printer relationship sync (and stale-link
|
||||
# archive); absence leaves existing printer links untouched.
|
||||
'defaultprinter': {
|
||||
'type': 'string',
|
||||
'description': ('Default printer identifier (Win32_Printer '
|
||||
'with Default=true): windows name, share '
|
||||
'name, hostname, or port IP.'),
|
||||
},
|
||||
'printers': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': ('All installed network printer identifiers '
|
||||
'(Win32_Printer): windows name / share / '
|
||||
'hostname / IP. Unresolved -> warning.'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -230,14 +256,150 @@ class ComputersPlugin(BasePlugin):
|
||||
computerid=comp.computerid, appid=app.appid,
|
||||
installedversion=version))
|
||||
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'action': action,
|
||||
'assetid': comp.assetid,
|
||||
'identityvalue': hostname,
|
||||
'warnings': warnings,
|
||||
'extra': {
|
||||
'printerlinks': printerlinks,
|
||||
'printerlinkcount': len(printerlinks),
|
||||
},
|
||||
}
|
||||
|
||||
# -- printer relationship sync -----------------------------------------
|
||||
|
||||
def _sync_printer_links(self, pcasset, payload, warnings):
|
||||
"""Idempotently sync PC->printer relationships from collector printer data.
|
||||
|
||||
Default printer -> 'defaultprinter' (directional). Other reported
|
||||
printers -> 'connectedto' (symmetric). Resolves each identifier to a
|
||||
printer asset by windows name / share / hostname / asset number / name
|
||||
or a communications IP. Unresolved identifiers add a warning and never
|
||||
fail the push.
|
||||
|
||||
Stale-link archive: on each push, collector-tagged links (label ==
|
||||
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported
|
||||
desired set are set inactive. Only tagged rows are touched, so manual
|
||||
links survive. Runs only when the payload carried a printer key
|
||||
('defaultprinter' or 'printers'); a PC that reports without printer data
|
||||
keeps its existing links. Returns the desired-link list (created + kept).
|
||||
"""
|
||||
from shopdb.api import (
|
||||
AssetRelationship, RelationshipType, Asset, Communication)
|
||||
|
||||
has_default = 'defaultprinter' in payload
|
||||
has_list = 'printers' in payload
|
||||
if not has_default and not has_list:
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.printers.models import Printer
|
||||
except ImportError:
|
||||
warnings.append('printers plugin unavailable; printer links skipped')
|
||||
return []
|
||||
|
||||
dp_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='defaultprinter').first()
|
||||
ct_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='connectedto').first()
|
||||
if not dp_type or not ct_type:
|
||||
warnings.append('printer relationship types missing; '
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
def resolve(identifier):
|
||||
# first match wins: printer text identity, then a printer IP.
|
||||
ident = (identifier or '').strip()
|
||||
if not ident:
|
||||
return None
|
||||
printer = (
|
||||
Printer.query.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.filter(db.or_(
|
||||
Printer.windowsname.ilike(ident),
|
||||
Printer.hostname.ilike(ident),
|
||||
Printer.sharename.ilike(ident),
|
||||
Asset.assetnumber.ilike(ident),
|
||||
Asset.name.ilike(ident),
|
||||
)).first())
|
||||
if printer:
|
||||
return printer.asset
|
||||
comm = (
|
||||
db.session.query(Communication)
|
||||
.join(Printer, Printer.assetid == Communication.assetid)
|
||||
.filter(Communication.ipaddress == ident)
|
||||
.first())
|
||||
if comm:
|
||||
return db.session.get(Asset, comm.assetid)
|
||||
return None
|
||||
|
||||
pcid = pcasset.assetid
|
||||
desired = set() # (targetassetid, relationshiptypeid) to keep
|
||||
printerlinks = []
|
||||
|
||||
default_id = None
|
||||
default_ident = (payload.get('defaultprinter') or '').strip()
|
||||
if default_ident:
|
||||
target = resolve(default_ident)
|
||||
if target:
|
||||
default_id = target.assetid
|
||||
desired.add((default_id, dp_type.relationshiptypeid))
|
||||
self._sync_one(pcid, default_id, dp_type)
|
||||
printerlinks.append({'assetid': default_id,
|
||||
'relationshiptype': 'defaultprinter'})
|
||||
else:
|
||||
warnings.append(f'unresolved default printer: {default_ident}')
|
||||
|
||||
for ident in payload.get('printers') or []:
|
||||
target = resolve(ident)
|
||||
if not target:
|
||||
warnings.append(f'unresolved printer: {ident}')
|
||||
continue
|
||||
if target.assetid == default_id:
|
||||
continue # already the default link
|
||||
desired.add((target.assetid, ct_type.relationshiptypeid))
|
||||
self._sync_one(pcid, target.assetid, ct_type)
|
||||
printerlinks.append({'assetid': target.assetid,
|
||||
'relationshiptype': 'connectedto'})
|
||||
|
||||
# Archive collector-tagged links no longer reported (manual links, with
|
||||
# a NULL/other label, are never matched here).
|
||||
collector_rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcid,
|
||||
AssetRelationship.relationshiptypeid.in_(
|
||||
[dp_type.relationshiptypeid, ct_type.relationshiptypeid]),
|
||||
AssetRelationship.isactive == True,
|
||||
AssetRelationship.label == PRINTER_LINK_ORIGIN,
|
||||
).all()
|
||||
for rel in collector_rows:
|
||||
if (rel.targetassetid, rel.relationshiptypeid) not in desired:
|
||||
rel.isactive = False
|
||||
|
||||
return printerlinks
|
||||
|
||||
def _sync_one(self, pcid, printerid, reltype):
|
||||
"""Reactivate or create one collector PC->printer link (idempotent)."""
|
||||
from shopdb.api import AssetRelationship
|
||||
existing = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid).first()
|
||||
if existing:
|
||||
# do not re-stamp label: a pre-existing manual row stays manual.
|
||||
if not existing.isactive:
|
||||
existing.isactive = True
|
||||
return existing
|
||||
rel = AssetRelationship(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid,
|
||||
label=PRINTER_LINK_ORIGIN)
|
||||
db.session.add(rel)
|
||||
return rel
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
|
||||
Reference in New Issue
Block a user