The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
1079 lines
40 KiB
Python
1079 lines
40 KiB
Python
"""Global search API endpoint with full search parity."""
|
|
|
|
import re
|
|
import ipaddress
|
|
import logging
|
|
|
|
from datetime import datetime, timezone
|
|
from flask import Blueprint, request, current_app
|
|
from flask_jwt_extended import jwt_required
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import (
|
|
Application, Setting,
|
|
Asset, AssetType, Communication, Vendor, Model,
|
|
CustomField, CustomFieldValue
|
|
)
|
|
from shopdb.core.api.settings import get_cached_settings
|
|
from shopdb.utils.responses import success_response
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
search_bp = Blueprint('search', __name__)
|
|
|
|
|
|
def _word_match(query, *columns):
|
|
"""SQL clause matching rows that contain EVERY word of the query, each word
|
|
in any of the columns, in any order.
|
|
|
|
A plain `col.ilike('%CSF Roles%')` needs the exact contiguous phrase, so a
|
|
multi-word search returned nothing. This splits the query into words and ANDs
|
|
them (OR across the columns per word): 'CSF Roles' matches a row with 'CSF'
|
|
and 'Roles' anywhere in the searched fields.
|
|
"""
|
|
words = [w for w in query.split() if w]
|
|
if not words:
|
|
words = ['']
|
|
return db.and_(*[
|
|
db.or_(*[c.ilike(f'%{w}%') for c in columns])
|
|
for w in words
|
|
])
|
|
|
|
|
|
def _require_enabled(name):
|
|
"""Raise ImportError when the named plugin is disabled.
|
|
|
|
Each plugin-scoped search block already catches ImportError and skips the
|
|
domain, so a disabled plugin is treated exactly like an absent one: its rows
|
|
drop out of search results. Honors runtime enable/disable.
|
|
"""
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if pm and not pm.registry.is_enabled(name):
|
|
raise ImportError(f'{name} plugin disabled')
|
|
|
|
# Shipped GE defaults. Settings override these per-site; identical fallbacks
|
|
# live here so this consumer works even if the settings seed has not run.
|
|
SERVICENOW_URL_DEFAULT = (
|
|
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
|
|
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
|
|
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
|
)
|
|
SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT'
|
|
EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$'
|
|
|
|
|
|
def _get_search_integrations():
|
|
"""Resolve the settings-driven search integration config.
|
|
|
|
Reads employeeid_pattern, servicenow_ticket_prefixes, servicenow_enabled
|
|
and servicenow_search_url from cached settings, falling back to the shipped
|
|
GE defaults for any missing key. An invalid employeeid_pattern regex falls
|
|
back to the default rather than raising (search must never 500 on bad
|
|
config). ServiceNow is inactive when disabled, when the URL is blank, or
|
|
when no ticket prefixes are configured.
|
|
"""
|
|
settings = get_cached_settings()
|
|
|
|
# Employee-ID pattern. Bad regex falls back so search never 500s.
|
|
pattern = settings.get('employeeid_pattern') or EMPLOYEEID_PATTERN_DEFAULT
|
|
try:
|
|
employeeid_re = re.compile(pattern)
|
|
except re.error:
|
|
employeeid_re = re.compile(EMPLOYEEID_PATTERN_DEFAULT)
|
|
|
|
# Ticket prefixes -> case-insensitive alternation built at request time.
|
|
prefixes_raw = settings.get('servicenow_ticket_prefixes')
|
|
if prefixes_raw is None:
|
|
prefixes_raw = SERVICENOW_PREFIXES_DEFAULT
|
|
prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()]
|
|
|
|
servicenow_enabled = settings.get('servicenow_enabled')
|
|
if servicenow_enabled is None:
|
|
servicenow_enabled = True
|
|
|
|
servicenow_url = settings.get('servicenow_search_url')
|
|
if servicenow_url is None:
|
|
servicenow_url = SERVICENOW_URL_DEFAULT
|
|
|
|
servicenow_active = bool(servicenow_enabled) and bool(servicenow_url) and bool(prefixes)
|
|
|
|
prefix_re = None
|
|
if servicenow_active:
|
|
alternation = '|'.join(re.escape(p) for p in prefixes)
|
|
prefix_re = re.compile(r'^(' + alternation + r')\d+', re.IGNORECASE)
|
|
|
|
return {
|
|
'employeeid_re': employeeid_re,
|
|
'prefix_re': prefix_re,
|
|
'servicenow_active': servicenow_active,
|
|
'servicenow_url': servicenow_url,
|
|
}
|
|
|
|
|
|
def _classify_query(query, integrations):
|
|
"""Analyze the query string to determine its nature."""
|
|
prefix_re = integrations['prefix_re']
|
|
sn_match = prefix_re.match(query) if prefix_re else None
|
|
return {
|
|
'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)),
|
|
'is_sso': bool(integrations['employeeid_re'].match(query)),
|
|
'is_servicenow': bool(sn_match),
|
|
'servicenow_prefix': sn_match.group(1) if sn_match else None,
|
|
'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)),
|
|
}
|
|
|
|
|
|
def _asset_presentation_map():
|
|
"""assettype -> route from enabled plugins' get_asset_presentation (ADR-010).
|
|
|
|
Lets core route a plugin-owned asset type in search rows without hardcoding
|
|
the route. Cached per request (built once per search, not per result).
|
|
"""
|
|
from flask import g
|
|
cached = getattr(g, '_asset_presentation_map', None)
|
|
if cached is not None:
|
|
return cached
|
|
result = {}
|
|
pm = current_app.extensions.get('plugin_manager')
|
|
if pm:
|
|
for plugin_name, plugin in pm.get_all_plugins().items():
|
|
if not pm.registry.is_enabled(plugin_name):
|
|
continue
|
|
try:
|
|
for entry in plugin.get_asset_presentation() or []:
|
|
if entry.get('assettype') and entry.get('route'):
|
|
result[entry['assettype']] = entry['route']
|
|
except Exception:
|
|
continue
|
|
g._asset_presentation_map = result
|
|
return result
|
|
|
|
|
|
def _get_asset_result(asset, query, relevance=None):
|
|
"""Build a search result dict from an Asset object."""
|
|
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
|
|
|
|
plugin_id = asset.assetid
|
|
if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine:
|
|
plugin_id = asset.machine.machineid
|
|
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
|
|
plugin_id = asset.computer.computerid
|
|
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
|
|
plugin_id = asset.network_device.networkdeviceid
|
|
elif asset_type_name == 'printer' and hasattr(asset, 'printer') and asset.printer:
|
|
plugin_id = asset.printer.printerid
|
|
elif asset_type_name == 'measuring_tool' and hasattr(asset, 'measuringtool') and asset.measuringtool:
|
|
plugin_id = asset.measuringtool.measuringtoolid
|
|
|
|
# Prefer a plugin's declared get_asset_presentation route (ADR-010): core
|
|
# stops hardcoding the plugin's route as each plugin declares one. Those
|
|
# routes take the core assetid (via a by-asset resolver). Types that have
|
|
# not declared fall back to the legacy id-keyed map below.
|
|
presentation = _asset_presentation_map()
|
|
if asset_type_name in presentation:
|
|
url = presentation[asset_type_name].replace('{assetid}', str(asset.assetid))
|
|
result_id = asset.assetid
|
|
else:
|
|
url_map = {
|
|
'machine': f"/machines/{plugin_id}",
|
|
'computer': f"/pcs/{plugin_id}",
|
|
'network_device': f"/network/{plugin_id}",
|
|
'printer': f"/printers/{plugin_id}",
|
|
'measuring_tool': f"/measuringtools/{plugin_id}",
|
|
}
|
|
url = url_map.get(asset_type_name, f"/assets/{asset.assetid}")
|
|
result_id = plugin_id
|
|
|
|
display_name = asset.display_name
|
|
subtitle = None
|
|
if asset.name and asset.assetnumber != asset.name:
|
|
subtitle = asset.assetnumber
|
|
|
|
location_name = asset.location.locationname if asset.location else None
|
|
|
|
if relevance is None:
|
|
relevance = 15
|
|
|
|
return {
|
|
'type': asset_type_name,
|
|
'id': result_id,
|
|
'title': display_name,
|
|
'subtitle': subtitle,
|
|
'location': location_name,
|
|
'url': url,
|
|
'relevance': relevance
|
|
}
|
|
|
|
|
|
def _search_applications(query, search_term):
|
|
"""Search Applications by name and description."""
|
|
results = []
|
|
try:
|
|
apps = Application.query.filter(
|
|
Application.isactive == True,
|
|
_word_match(query, Application.appname, Application.appdescription)
|
|
).limit(10).all()
|
|
|
|
for app in apps:
|
|
relevance = 20
|
|
if query.lower() == app.appname.lower():
|
|
relevance = 100
|
|
elif query.lower() in app.appname.lower():
|
|
relevance = 50
|
|
|
|
results.append({
|
|
'type': 'application',
|
|
'id': app.appid,
|
|
'title': app.appname,
|
|
'subtitle': app.appdescription[:100] if app.appdescription else None,
|
|
'url': f"/applications/{app.appid}",
|
|
'relevance': relevance
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Application search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_knowledgebase(query, search_term):
|
|
"""Search Knowledge Base by description and keywords.
|
|
|
|
An article whose topic is a RETIRED application is excluded, matching
|
|
GET /api/knowledgebase. Filtering it out of the plugin's own listing while
|
|
global search still returned it is not a rule at all: the article was two
|
|
keystrokes away, and the result printed the retired application's name as its
|
|
subject, which reads as though it were still in service.
|
|
|
|
A null topic still matches. Not every article is about an application.
|
|
"""
|
|
results = []
|
|
try:
|
|
_require_enabled('knowledgebase')
|
|
from plugins.knowledgebase.models import KnowledgeBase
|
|
retired = db.session.query(Application.appid).filter(
|
|
Application.isactive.is_(False))
|
|
kb_articles = KnowledgeBase.query.filter(
|
|
KnowledgeBase.isactive == True,
|
|
db.or_(KnowledgeBase.appid.is_(None),
|
|
KnowledgeBase.appid.notin_(retired)),
|
|
_word_match(query, KnowledgeBase.shortdescription,
|
|
KnowledgeBase.keywords)
|
|
).limit(20).all()
|
|
|
|
for kb in kb_articles:
|
|
relevance = 10 + (kb.clicks or 0) * 0.1
|
|
if kb.keywords and query.lower() in kb.keywords.lower():
|
|
relevance += 15
|
|
|
|
results.append({
|
|
'type': 'knowledgebase',
|
|
'id': kb.linkid,
|
|
'title': kb.shortdescription,
|
|
'subtitle': kb.application.appname if kb.application else None,
|
|
'url': f"/knowledgebase/{kb.linkid}",
|
|
'linkurl': kb.linkurl,
|
|
'relevance': relevance
|
|
})
|
|
except ImportError:
|
|
pass # knowledgebase plugin absent or disabled
|
|
except Exception as e:
|
|
logger.error(f"KnowledgeBase search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _employee_rows(query):
|
|
"""Employee rows matching search_term, from the SELFHOSTED directory table
|
|
or the external HR DB per the employee_directory_mode setting. Returns dicts
|
|
shaped like the external query (SSO/First_Name/Last_Name/Team/Role)."""
|
|
settings = get_cached_settings()
|
|
mode = (settings.get('employee_directory_mode') or 'external').lower()
|
|
if mode == 'selfhosted':
|
|
try:
|
|
from plugins.employees.models import DirectoryEmployee
|
|
except ImportError:
|
|
return []
|
|
rows = DirectoryEmployee.query.filter(
|
|
_word_match(query, DirectoryEmployee.firstname,
|
|
DirectoryEmployee.lastname,
|
|
db.cast(DirectoryEmployee.sso, db.String))
|
|
).order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).limit(10).all()
|
|
return [{'SSO': e.sso, 'First_Name': e.firstname or '',
|
|
'Last_Name': e.lastname or '', 'Team': e.team, 'Role': e.role}
|
|
for e in rows]
|
|
# External HR DB: shared env-backed connection helper; never hardcode creds.
|
|
# Word-AND so "First Last" matches (each word in any name/SSO field), not just
|
|
# the exact contiguous phrase. Parameterized - one LIKE triple per word.
|
|
from shopdb.utils.employee_db import employee_connection
|
|
words = [w for w in query.split() if w] or ['']
|
|
clauses, params = [], []
|
|
for w in words:
|
|
clauses.append('(First_Name LIKE %s OR Last_Name LIKE %s '
|
|
'OR CAST(SSO AS CHAR) LIKE %s)')
|
|
like = f'%{w}%'
|
|
params += [like, like, like]
|
|
where = ' AND '.join(clauses)
|
|
emp_conn = employee_connection()
|
|
with emp_conn.cursor() as cur:
|
|
cur.execute(
|
|
'SELECT SSO, First_Name, Last_Name, Team, Role FROM employees '
|
|
f'WHERE {where} ORDER BY Last_Name, First_Name LIMIT 10', params)
|
|
rows = cur.fetchall()
|
|
emp_conn.close()
|
|
return rows
|
|
|
|
|
|
def _search_employees(query, search_term):
|
|
"""Search employees (selfhosted directory or external HR DB, per mode)."""
|
|
results = []
|
|
try:
|
|
for emp in _employee_rows(query):
|
|
full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
|
|
sso_str = str(emp['SSO'])
|
|
|
|
relevance = 20
|
|
if query == sso_str:
|
|
relevance = 100
|
|
elif query.lower() == full_name.lower():
|
|
relevance = 95
|
|
elif query.lower() in full_name.lower():
|
|
relevance = 60
|
|
|
|
results.append({
|
|
'type': 'employee',
|
|
'id': emp['SSO'],
|
|
'title': full_name,
|
|
'subtitle': emp.get('Team') or emp.get('Role') or f"SSO: {sso_str}",
|
|
'url': f"/employees/{emp['SSO']}",
|
|
'relevance': relevance
|
|
})
|
|
except Exception as e:
|
|
logger.error(f"Employee search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_assets(query, search_term):
|
|
"""Search unified Assets table by number, name, serial, notes and the two
|
|
optional identifiers.
|
|
|
|
gaugelabreference and maintenancereference are searched for EVERY asset type
|
|
(ADR-001). Settings lets a site enable either identifier on machines, PCs,
|
|
printers and network devices, but only the measuring-tools searcher looked at
|
|
gaugelabreference and nothing looked at maintenancereference at all - so a
|
|
tag an operator was told to record was one nobody could search by. An
|
|
identifier that can be entered has to be findable, or it is a write-only
|
|
field.
|
|
|
|
The per-type `identifier_<name>_<assettype>_enabled` toggles are NOT applied
|
|
here. They govern whether the field is SHOWN on that type; a value already in
|
|
the row is still the tag written on the physical machine, and matching it is
|
|
strictly better than returning nothing to someone reading it off a label.
|
|
"""
|
|
results = []
|
|
try:
|
|
assets = Asset.query.join(AssetType).options(
|
|
joinedload(Asset.assettype),
|
|
joinedload(Asset.location),
|
|
).filter(
|
|
Asset.isactive == True,
|
|
_word_match(query, Asset.assetnumber, Asset.name,
|
|
Asset.serialnumber, Asset.notes,
|
|
Asset.gaugelabreference, Asset.maintenancereference)
|
|
).limit(15).all()
|
|
|
|
for asset in assets:
|
|
relevance = 15
|
|
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
|
|
relevance = 100
|
|
elif asset.name and query.lower() == asset.name.lower():
|
|
relevance = 90
|
|
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
|
|
relevance = 88
|
|
elif asset.maintenancereference and query.lower() == asset.maintenancereference.lower():
|
|
relevance = 86
|
|
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
|
relevance = 85
|
|
elif asset.name and query.lower() in asset.name.lower():
|
|
relevance = 50
|
|
|
|
results.append(_get_asset_result(asset, query, relevance))
|
|
except Exception as e:
|
|
logger.error(f"Asset search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_measuringtools(query, search_term):
|
|
"""Search measuring tools, including the gaugelabreference identifier.
|
|
|
|
The generic asset search already matches number/name/serial across all asset
|
|
types; this gated searcher adds gaugelabreference (the gage tag the gage lab
|
|
searches by) and drops out when the measuringtools plugin is disabled.
|
|
"""
|
|
results = []
|
|
try:
|
|
_require_enabled('measuringtools')
|
|
from plugins.measuringtools.models import MeasuringTool
|
|
assets = db.session.query(Asset).join(
|
|
MeasuringTool, MeasuringTool.assetid == Asset.assetid
|
|
).options(
|
|
joinedload(Asset.assettype),
|
|
joinedload(Asset.location),
|
|
).filter(
|
|
Asset.isactive == True,
|
|
_word_match(query, Asset.assetnumber, Asset.name,
|
|
Asset.serialnumber, Asset.gaugelabreference)
|
|
).limit(15).all()
|
|
|
|
for asset in assets:
|
|
relevance = 15
|
|
if asset.assetnumber and query.lower() == asset.assetnumber.lower():
|
|
relevance = 100
|
|
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
|
|
relevance = 90
|
|
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
|
relevance = 85
|
|
elif asset.name and query.lower() in asset.name.lower():
|
|
relevance = 50
|
|
results.append(_get_asset_result(asset, query, relevance))
|
|
except ImportError:
|
|
pass # measuringtools plugin absent or disabled
|
|
except Exception as e:
|
|
logger.error(f"Measuring tool search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_usbdevices(query, search_term):
|
|
"""Search USB devices by serial, asset tag and product name.
|
|
|
|
A USB device is NOT an asset - it lives in the usb plugin's own table - so
|
|
the generic asset search cannot see it and these records were unreachable
|
|
from search entirely. Serial number is the field people actually have in
|
|
hand: it is what is printed on the stick they are holding.
|
|
|
|
currentusername is deliberately NOT searched. It records who holds the
|
|
device, and making search a way to list what a named person has checked out
|
|
is a different feature from finding a device.
|
|
"""
|
|
results = []
|
|
try:
|
|
_require_enabled('usb')
|
|
from plugins.usb.models import USBDevice
|
|
devices = USBDevice.query.filter(
|
|
USBDevice.isactive == True,
|
|
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
|
USBDevice.label, USBDevice.productname)
|
|
).limit(10).all()
|
|
|
|
for device in devices:
|
|
relevance = 20
|
|
if query.lower() == (device.serialnumber or '').lower():
|
|
relevance = 100
|
|
elif query.lower() == (device.assetnumber or '').lower():
|
|
relevance = 90
|
|
elif query.lower() == (device.label or '').lower():
|
|
relevance = 85
|
|
elif query.lower() in (device.label or '').lower():
|
|
relevance = 50
|
|
elif query.lower() in (device.productname or '').lower():
|
|
relevance = 40
|
|
|
|
results.append({
|
|
'type': 'usb_device',
|
|
'id': device.usbdeviceid,
|
|
'title': device.label or device.productname or device.serialnumber,
|
|
'subtitle': device.assetnumber or device.serialnumber,
|
|
'url': f'/usb/{device.usbdeviceid}',
|
|
'relevance': relevance,
|
|
})
|
|
except ImportError:
|
|
pass # usb plugin absent or disabled
|
|
except Exception as e:
|
|
logger.error(f"USB device search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_printeditems(query, search_term):
|
|
"""Search printed items by bin code, gage-lab tag, name and description.
|
|
|
|
Printed items are their own records, not assets, so the generic asset search
|
|
never covered them. itemcode (the bin label, e.g. 3DP-0042) and gagelabtag
|
|
are both unique and both printed on physical labels, which makes them the
|
|
likeliest thing anyone types into search.
|
|
"""
|
|
results = []
|
|
try:
|
|
_require_enabled('printedparts')
|
|
from plugins.printedparts.models import PrintedItem
|
|
items = PrintedItem.query.filter(
|
|
PrintedItem.isactive == True,
|
|
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
|
PrintedItem.itemname, PrintedItem.itemdescription)
|
|
).limit(10).all()
|
|
|
|
for item in items:
|
|
relevance = 20
|
|
if query.lower() == (item.itemcode or '').lower():
|
|
relevance = 100
|
|
elif query.lower() == (item.gagelabtag or '').lower():
|
|
relevance = 95
|
|
elif query.lower() == (item.itemname or '').lower():
|
|
relevance = 90
|
|
elif query.lower() in (item.itemname or '').lower():
|
|
relevance = 50
|
|
|
|
subtitle = item.itemcode or item.gagelabtag
|
|
if item.binlocation:
|
|
subtitle = f'{subtitle} - {item.binlocation}' if subtitle else item.binlocation
|
|
|
|
results.append({
|
|
'type': 'printed_item',
|
|
'id': item.printeditemid,
|
|
'title': item.itemname,
|
|
'subtitle': subtitle,
|
|
'url': f'/printedparts/{item.printeditemid}',
|
|
'relevance': relevance,
|
|
})
|
|
except ImportError:
|
|
pass # printedparts plugin absent or disabled
|
|
except Exception as e:
|
|
logger.error(f"Printed item search failed: {e}")
|
|
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,
|
|
_word_match(query, CustomFieldValue.value),
|
|
).limit(15).all()
|
|
|
|
for asset, field in rows:
|
|
result = _get_asset_result(asset, query, relevance=40)
|
|
result['subtitle'] = field.label
|
|
results.append(result)
|
|
except Exception as e:
|
|
logger.error(f"Custom field search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_by_ip(query, search_term):
|
|
"""Search Communications table for IP address matches."""
|
|
results = []
|
|
try:
|
|
comms = Communication.query.filter(
|
|
_word_match(query, Communication.ipaddress)
|
|
).options(
|
|
joinedload(Communication.asset).joinedload(Asset.assettype),
|
|
joinedload(Communication.asset).joinedload(Asset.location),
|
|
).limit(10).all()
|
|
|
|
seen_assets = set()
|
|
for comm in comms:
|
|
asset = comm.asset
|
|
if not asset or not asset.isactive or asset.assetid in seen_assets:
|
|
continue
|
|
seen_assets.add(asset.assetid)
|
|
|
|
relevance = 80 if query == comm.ipaddress else 40
|
|
result = _get_asset_result(asset, query, relevance)
|
|
result['subtitle'] = comm.ipaddress
|
|
results.append(result)
|
|
except Exception as e:
|
|
logger.error(f"IP search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_subnets(query):
|
|
"""Find which subnet an IP address belongs to."""
|
|
results = []
|
|
try:
|
|
_require_enabled('network')
|
|
from plugins.network.models import Subnet
|
|
ip_obj = ipaddress.ip_address(query)
|
|
subnets = Subnet.query.filter(Subnet.isactive == True).all()
|
|
for subnet in subnets:
|
|
try:
|
|
network = ipaddress.ip_network(subnet.cidr, strict=False)
|
|
if ip_obj in network:
|
|
results.append({
|
|
'type': 'subnet',
|
|
'id': subnet.subnetid,
|
|
'title': f'{subnet.name} ({subnet.cidr})',
|
|
'subtitle': subnet.description or subnet.subnettype,
|
|
'url': f'/network',
|
|
'relevance': 70
|
|
})
|
|
except ValueError:
|
|
continue
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Subnet search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_hostnames(query, search_term):
|
|
"""Search hostname fields across Computer, Printer, NetworkDevice."""
|
|
results = []
|
|
|
|
# Search Computers
|
|
try:
|
|
_require_enabled('computers')
|
|
from plugins.computers.models import Computer
|
|
computers = Computer.query.filter(
|
|
_word_match(query, Computer.hostname)
|
|
).options(
|
|
joinedload(Computer.asset).joinedload(Asset.assettype),
|
|
joinedload(Computer.asset).joinedload(Asset.location),
|
|
).limit(10).all()
|
|
|
|
for comp in computers:
|
|
if comp.asset and comp.asset.isactive:
|
|
relevance = 85 if query.lower() == (comp.hostname or '').lower() else 40
|
|
result = _get_asset_result(comp.asset, query, relevance)
|
|
result['subtitle'] = comp.hostname
|
|
results.append(result)
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Computer hostname search failed: {e}")
|
|
|
|
# Search Printers
|
|
try:
|
|
_require_enabled('printers')
|
|
from plugins.printers.models import Printer
|
|
printers = Printer.query.filter(
|
|
_word_match(query, Printer.hostname, Printer.sharename,
|
|
Printer.windowsname)
|
|
).options(
|
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
|
joinedload(Printer.asset).joinedload(Asset.location),
|
|
).limit(10).all()
|
|
|
|
for printer in printers:
|
|
if printer.asset and printer.asset.isactive:
|
|
match_field = printer.hostname or printer.sharename or ''
|
|
relevance = 85 if query.lower() == match_field.lower() else 40
|
|
result = _get_asset_result(printer.asset, query, relevance)
|
|
result['subtitle'] = printer.hostname or printer.sharename
|
|
results.append(result)
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Printer hostname search failed: {e}")
|
|
|
|
# Search Network Devices
|
|
try:
|
|
_require_enabled('network')
|
|
from plugins.network.models import NetworkDevice
|
|
devices = NetworkDevice.query.filter(
|
|
_word_match(query, NetworkDevice.hostname)
|
|
).options(
|
|
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
|
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
|
).limit(10).all()
|
|
|
|
for device in devices:
|
|
if device.asset and device.asset.isactive:
|
|
relevance = 85 if query.lower() == (device.hostname or '').lower() else 40
|
|
result = _get_asset_result(device.asset, query, relevance)
|
|
result['subtitle'] = device.hostname
|
|
results.append(result)
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Network device hostname search failed: {e}")
|
|
|
|
return results
|
|
|
|
|
|
def _search_notifications(query, search_term):
|
|
"""Search notifications with time-weighted relevance."""
|
|
results = []
|
|
try:
|
|
_require_enabled('notifications')
|
|
from plugins.notifications.models import Notification
|
|
|
|
notifications = Notification.query.options(
|
|
joinedload(Notification.notificationtype)
|
|
).filter(
|
|
_word_match(query, Notification.notification,
|
|
Notification.ticketnumber)
|
|
).order_by(Notification.starttime.desc()).limit(15).all()
|
|
|
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
for notif in notifications:
|
|
base_relevance = 20
|
|
if notif.ticketnumber and query.lower() == notif.ticketnumber.lower():
|
|
base_relevance = 85
|
|
|
|
# Time-weighted relevance
|
|
if notif.is_current:
|
|
base_relevance *= 3
|
|
elif notif.starttime and notif.starttime > now:
|
|
base_relevance *= 2
|
|
elif notif.endtime and (now - notif.endtime).days < 7:
|
|
base_relevance = int(base_relevance * 1.5)
|
|
|
|
results.append({
|
|
'type': 'notification',
|
|
'id': notif.notificationid,
|
|
'title': notif.title,
|
|
'subtitle': notif.notificationtype.typename if notif.notificationtype else None,
|
|
'url': f'/notifications',
|
|
'relevance': min(int(base_relevance), 100),
|
|
'ticketnumber': notif.ticketnumber,
|
|
'iscurrent': notif.is_current
|
|
})
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Notification search failed: {e}")
|
|
return results
|
|
|
|
|
|
def _search_vendor_model_type(query, search_term):
|
|
"""Search assets by vendor name, model name, or machine/device type name."""
|
|
results = []
|
|
|
|
# Machines: vendor, model, machinetype
|
|
try:
|
|
_require_enabled('machines')
|
|
from plugins.machines.models import Machine, MachineType
|
|
machine_assets = db.session.query(Asset).join(
|
|
Machine, Machine.assetid == Asset.assetid
|
|
).outerjoin(
|
|
Vendor, Machine.vendorid == Vendor.vendorid
|
|
).outerjoin(
|
|
Model, Machine.modelnumberid == Model.modelnumberid
|
|
).outerjoin(
|
|
MachineType, Machine.machinetypeid == MachineType.machinetypeid
|
|
).options(
|
|
joinedload(Asset.assettype),
|
|
joinedload(Asset.location),
|
|
).filter(
|
|
Asset.isactive == True,
|
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
|
MachineType.machinetype)
|
|
).limit(10).all()
|
|
|
|
for asset in machine_assets:
|
|
results.append(_get_asset_result(asset, query, 30))
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Machine vendor/model/type search failed: {e}")
|
|
|
|
# Printers: vendor, model, printertype
|
|
try:
|
|
_require_enabled('printers')
|
|
from plugins.printers.models import Printer, PrinterType
|
|
printer_assets = db.session.query(Asset).join(
|
|
Printer, Printer.assetid == Asset.assetid
|
|
).outerjoin(
|
|
Vendor, Printer.vendorid == Vendor.vendorid
|
|
).outerjoin(
|
|
Model, Printer.modelnumberid == Model.modelnumberid
|
|
).outerjoin(
|
|
PrinterType, Printer.printertypeid == PrinterType.printertypeid
|
|
).options(
|
|
joinedload(Asset.assettype),
|
|
joinedload(Asset.location),
|
|
).filter(
|
|
Asset.isactive == True,
|
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
|
PrinterType.printertype)
|
|
).limit(10).all()
|
|
|
|
for asset in printer_assets:
|
|
results.append(_get_asset_result(asset, query, 30))
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Printer vendor/model/type search failed: {e}")
|
|
|
|
# Network Devices: vendor, networkdevicetype
|
|
try:
|
|
_require_enabled('network')
|
|
from plugins.network.models import NetworkDevice, NetworkDeviceType
|
|
netdev_assets = db.session.query(Asset).join(
|
|
NetworkDevice, NetworkDevice.assetid == Asset.assetid
|
|
).outerjoin(
|
|
Vendor, NetworkDevice.vendorid == Vendor.vendorid
|
|
).outerjoin(
|
|
NetworkDeviceType, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
|
|
).options(
|
|
joinedload(Asset.assettype),
|
|
joinedload(Asset.location),
|
|
).filter(
|
|
Asset.isactive == True,
|
|
_word_match(query, Vendor.vendor,
|
|
NetworkDeviceType.networkdevicetype)
|
|
).limit(10).all()
|
|
|
|
for asset in netdev_assets:
|
|
results.append(_get_asset_result(asset, query, 30))
|
|
except ImportError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Network device vendor/type search failed: {e}")
|
|
|
|
return results
|
|
|
|
|
|
def _check_smart_redirect(query, classification):
|
|
"""Check if query exactly matches a single entity for smart redirect."""
|
|
# Exact SSO match
|
|
if classification['is_sso']:
|
|
try:
|
|
# Shared env-backed connection helper; never hardcode creds.
|
|
from shopdb.utils.employee_db import employee_connection
|
|
emp_conn = employee_connection()
|
|
with emp_conn.cursor() as cur:
|
|
cur.execute(
|
|
'SELECT SSO, First_Name, Last_Name FROM employees WHERE SSO = %s LIMIT 1',
|
|
(query,)
|
|
)
|
|
emp = cur.fetchone()
|
|
emp_conn.close()
|
|
if emp:
|
|
name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
|
|
return {
|
|
'type': 'employee',
|
|
'url': f"/employees/{emp['SSO']}",
|
|
'label': name
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
# Exact asset number match
|
|
try:
|
|
asset = Asset.query.options(
|
|
joinedload(Asset.assettype),
|
|
).filter(
|
|
Asset.assetnumber == query,
|
|
Asset.isactive == True
|
|
).first()
|
|
if asset:
|
|
result = _get_asset_result(asset, query)
|
|
return {
|
|
'type': result['type'],
|
|
'url': result['url'],
|
|
'label': asset.display_name
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
# Exact printer CSF/share name
|
|
try:
|
|
_require_enabled('printers')
|
|
from plugins.printers.models import Printer
|
|
printer = Printer.query.options(
|
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
|
).filter(
|
|
db.or_(
|
|
Printer.sharename == query,
|
|
Printer.windowsname == query
|
|
)
|
|
).first()
|
|
if printer and printer.asset and printer.asset.isactive:
|
|
return {
|
|
'type': 'printer',
|
|
'url': f"/printers/{printer.printerid}",
|
|
'label': printer.sharename or printer.asset.display_name
|
|
}
|
|
except ImportError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
# Exact hostname match (FQDN or bare hostname)
|
|
hostname_plugins = []
|
|
try:
|
|
_require_enabled('computers')
|
|
from plugins.computers.models import Computer
|
|
hostname_plugins.append(('computer', Computer, 'computerid', '/pcs'))
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
_require_enabled('printers')
|
|
from plugins.printers.models import Printer
|
|
hostname_plugins.append(('printer', Printer, 'printerid', '/printers'))
|
|
except ImportError:
|
|
pass
|
|
try:
|
|
_require_enabled('network')
|
|
from plugins.network.models import NetworkDevice
|
|
hostname_plugins.append(('network_device', NetworkDevice, 'networkdeviceid', '/network'))
|
|
except ImportError:
|
|
pass
|
|
|
|
for type_name, PluginModel, id_field, url_prefix in hostname_plugins:
|
|
try:
|
|
device = PluginModel.query.options(
|
|
joinedload(PluginModel.asset)
|
|
).filter(
|
|
PluginModel.hostname == query
|
|
).first()
|
|
if device and device.asset and device.asset.isactive:
|
|
return {
|
|
'type': type_name,
|
|
'url': f"{url_prefix}/{getattr(device, id_field)}",
|
|
'label': device.hostname
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
# Exact IP match
|
|
if classification['is_ip']:
|
|
try:
|
|
comm = Communication.query.options(
|
|
joinedload(Communication.asset).joinedload(Asset.assettype)
|
|
).filter(
|
|
Communication.ipaddress == query
|
|
).first()
|
|
if comm and comm.asset and comm.asset.isactive:
|
|
result = _get_asset_result(comm.asset, query)
|
|
return {
|
|
'type': result['type'],
|
|
'url': result['url'],
|
|
'label': f"{comm.asset.display_name} ({comm.ipaddress})"
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
@search_bp.route('', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def global_search():
|
|
"""
|
|
Global search across multiple entity types.
|
|
|
|
Returns combined results from assets, applications, knowledge base,
|
|
employees, notifications, IP addresses, hostnames, and vendor/model/type.
|
|
Supports smart redirects and ServiceNOW ticket detection.
|
|
"""
|
|
query = request.args.get('q', '').strip()
|
|
|
|
if not query or len(query) < 2:
|
|
return success_response({
|
|
'results': [],
|
|
'query': query,
|
|
'message': 'Search query must be at least 2 characters'
|
|
})
|
|
|
|
if len(query) > 200:
|
|
return success_response({
|
|
'results': [],
|
|
'query': query[:200],
|
|
'message': 'Search query too long'
|
|
})
|
|
|
|
integrations = _get_search_integrations()
|
|
classification = _classify_query(query, integrations)
|
|
|
|
# ServiceNOW prefix detection - return redirect immediately
|
|
if classification['is_servicenow']:
|
|
from urllib.parse import quote
|
|
servicenow_url = integrations['servicenow_url'].format(ticket=quote(query))
|
|
return success_response({
|
|
'results': [],
|
|
'query': query,
|
|
'total': 0,
|
|
'counts': {},
|
|
'redirect': {
|
|
'type': 'servicenow',
|
|
'url': servicenow_url,
|
|
'label': f'Open {query} in ServiceNOW'
|
|
}
|
|
})
|
|
|
|
results = []
|
|
search_term = f'%{query}%'
|
|
|
|
# Run all search domains
|
|
results.extend(_search_applications(query, search_term))
|
|
results.extend(_search_knowledgebase(query, search_term))
|
|
results.extend(_search_employees(query, search_term))
|
|
results.extend(_search_assets(query, search_term))
|
|
results.extend(_search_measuringtools(query, search_term))
|
|
results.extend(_search_usbdevices(query, search_term))
|
|
results.extend(_search_printeditems(query, search_term))
|
|
results.extend(_search_customfields(query, search_term))
|
|
results.extend(_search_notifications(query, search_term))
|
|
results.extend(_search_hostnames(query, search_term))
|
|
results.extend(_search_vendor_model_type(query, search_term))
|
|
|
|
# IP-specific searches
|
|
if classification['is_ip']:
|
|
results.extend(_search_by_ip(query, search_term))
|
|
results.extend(_search_subnets(query))
|
|
|
|
# Sort by relevance (highest first)
|
|
results.sort(key=lambda x: x['relevance'], reverse=True)
|
|
|
|
# Remove duplicates (prefer higher relevance)
|
|
seen_ids = {}
|
|
unique_results = []
|
|
for r in results:
|
|
key = (r['type'], r['id'])
|
|
if key not in seen_ids:
|
|
seen_ids[key] = True
|
|
unique_results.append(r)
|
|
|
|
# Drop result types disabled in Settings > Search (search_<type>_enabled).
|
|
# Missing key = enabled. One query, default-on.
|
|
disabled_types = {
|
|
s.key[len('search_'):-len('_enabled')]
|
|
for s in Setting.query.filter_by(category='search').all()
|
|
if s.get_typed_value() is False
|
|
}
|
|
if disabled_types:
|
|
unique_results = [r for r in unique_results if r['type'] not in disabled_types]
|
|
|
|
# Compute type counts before truncation
|
|
type_counts = {}
|
|
for r in unique_results:
|
|
t = r['type']
|
|
type_counts[t] = type_counts.get(t, 0) + 1
|
|
|
|
total_all = len(unique_results)
|
|
|
|
# Limit total results
|
|
unique_results = unique_results[:50]
|
|
|
|
# Check for smart redirect
|
|
response_data = {
|
|
'results': unique_results,
|
|
'query': query,
|
|
'total': len(unique_results),
|
|
'total_all': total_all,
|
|
'counts': type_counts,
|
|
}
|
|
|
|
redirect = _check_smart_redirect(query, classification)
|
|
if redirect:
|
|
response_data['redirect'] = redirect
|
|
|
|
return success_response(response_data)
|