"""Global search API endpoint with full search parity.""" import re import ipaddress import logging from datetime import datetime from flask import Blueprint, request from flask_jwt_extended import jwt_required from sqlalchemy.orm import joinedload from shopdb.extensions import db from shopdb.core.models import ( Application, KnowledgeBase, Asset, AssetType, Communication, Vendor, Model ) from shopdb.utils.responses import success_response logger = logging.getLogger(__name__) search_bp = Blueprint('search', __name__) # ServiceNOW URL template SERVICENOW_URL = ( 'https://geit.service-now.com/now/nav/ui/search/' '0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/' 'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/' 'back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui' ) def _classify_query(query): """Analyze the query string to determine its nature.""" return { 'is_ip': bool(re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query)), 'is_sso': bool(re.match(r'^\d{9}$', query)), 'is_servicenow': bool(re.match(r'^(GEINC|GECHG|GERIT|GESCT)\d+', query, re.IGNORECASE)), 'servicenow_prefix': re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE).group(1) if re.match(r'^(GEINC|GECHG|GERIT|GESCT)', query, re.IGNORECASE) else None, 'is_fqdn': bool(re.match(r'^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+$', query)), } 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 == 'equipment' and hasattr(asset, 'equipment') and asset.equipment: plugin_id = asset.equipment.equipmentid 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 url_map = { 'equipment': f"/machines/{plugin_id}", 'computer': f"/pcs/{plugin_id}", 'network_device': f"/network/{plugin_id}", 'printer': f"/printers/{plugin_id}", } url = url_map.get(asset_type_name, f"/assets/{asset.assetid}") 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': plugin_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, db.or_( Application.appname.ilike(search_term), Application.appdescription.ilike(search_term) ) ).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.""" results = [] try: kb_articles = KnowledgeBase.query.filter( KnowledgeBase.isactive == True, db.or_( KnowledgeBase.shortdescription.ilike(search_term), KnowledgeBase.keywords.ilike(search_term) ) ).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 Exception as e: logger.error(f"KnowledgeBase search failed: {e}") return results def _search_employees(query, search_term): """Search Employees in separate wjf_employees database.""" results = [] try: import pymysql emp_conn = pymysql.connect( host='localhost', user='root', password='rootpassword', database='wjf_employees', cursorclass=pymysql.cursors.DictCursor ) with emp_conn.cursor() as cur: cur.execute(''' SELECT SSO, First_Name, Last_Name, Team, Role FROM employees WHERE First_Name LIKE %s OR Last_Name LIKE %s OR CAST(SSO AS CHAR) LIKE %s ORDER BY Last_Name, First_Name LIMIT 10 ''', (search_term, search_term, search_term)) employees = cur.fetchall() emp_conn.close() for emp in employees: 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.""" results = [] try: assets = Asset.query.join(AssetType).options( joinedload(Asset.assettype), joinedload(Asset.location), ).filter( Asset.isactive == True, db.or_( Asset.assetnumber.ilike(search_term), Asset.name.ilike(search_term), Asset.serialnumber.ilike(search_term), Asset.notes.ilike(search_term) ) ).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.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_by_ip(query, search_term): """Search Communications table for IP address matches.""" results = [] try: comms = Communication.query.filter( Communication.ipaddress.ilike(search_term) ).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: 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: from plugins.computers.models import Computer computers = Computer.query.filter( Computer.hostname.ilike(search_term) ).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: from plugins.printers.models import Printer printers = Printer.query.filter( db.or_( Printer.hostname.ilike(search_term), Printer.sharename.ilike(search_term), Printer.windowsname.ilike(search_term), ) ).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: from plugins.network.models import NetworkDevice devices = NetworkDevice.query.filter( NetworkDevice.hostname.ilike(search_term) ).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: from plugins.notifications.models import Notification notifications = Notification.query.options( joinedload(Notification.notificationtype) ).filter( db.or_( Notification.notification.ilike(search_term), Notification.ticketnumber.ilike(search_term) ) ).order_by(Notification.starttime.desc()).limit(15).all() now = datetime.utcnow() 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 equipment/device type name.""" results = [] # Equipment: vendor, model, equipmenttype try: from plugins.equipment.models import Equipment, EquipmentType equipment_assets = db.session.query(Asset).join( Equipment, Equipment.assetid == Asset.assetid ).outerjoin( Vendor, Equipment.vendorid == Vendor.vendorid ).outerjoin( Model, Equipment.modelnumberid == Model.modelnumberid ).outerjoin( EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid ).options( joinedload(Asset.assettype), joinedload(Asset.location), ).filter( Asset.isactive == True, db.or_( Vendor.vendor.ilike(search_term), Model.modelnumber.ilike(search_term), EquipmentType.equipmenttype.ilike(search_term) ) ).limit(10).all() for asset in equipment_assets: results.append(_get_asset_result(asset, query, 30)) except ImportError: pass except Exception as e: logger.error(f"Equipment vendor/model/type search failed: {e}") # Printers: vendor, model, printertype try: 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, db.or_( Vendor.vendor.ilike(search_term), Model.modelnumber.ilike(search_term), PrinterType.printertype.ilike(search_term) ) ).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: 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, db.or_( Vendor.vendor.ilike(search_term), NetworkDeviceType.networkdevicetype.ilike(search_term) ) ).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: import pymysql emp_conn = pymysql.connect( host='localhost', user='root', password='rootpassword', database='wjf_employees', cursorclass=pymysql.cursors.DictCursor ) 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: 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: from plugins.computers.models import Computer hostname_plugins.append(('computer', Computer, 'computerid', '/pcs')) except ImportError: pass try: from plugins.printers.models import Printer hostname_plugins.append(('printer', Printer, 'printerid', '/printers')) except ImportError: pass try: 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' }) classification = _classify_query(query) # ServiceNOW prefix detection - return redirect immediately if classification['is_servicenow']: from urllib.parse import quote servicenow_url = 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_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) # 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)