Files
shopdb-flask/shopdb/core/api/search.py
cproudlock 64a5abdb08
All checks were successful
CI / backend (push) Successful in 1m14s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Wire measuring tools into identifiers and global search
The measuringtools plugin was missing from two cross-cutting surfaces:
the asset-identifier matrix (no measuring_tool column or per-type
keys - gauge lab reference is their primary identifier) and global
search (results fell to a generic URL and gaugelabreference was never
searched). Measuring tools now have identifier toggles, gated
gauge-lab and maintenance-reference fields on their form and detail,
a search domain toggle, gage-tag search, and proper labels, routes,
and filter chips in search results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 08:11:39 -04:00

858 lines
30 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
)
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 _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 _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
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}")
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:
_require_enabled('knowledgebase')
from plugins.knowledgebase.models import KnowledgeBase
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 ImportError:
pass # knowledgebase plugin absent or disabled
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:
# Use the 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, 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_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,
db.or_(
Asset.assetnumber.ilike(search_term),
Asset.name.ilike(search_term),
Asset.serialnumber.ilike(search_term),
Asset.gaugelabreference.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.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_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:
_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(
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:
_require_enabled('printers')
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:
_require_enabled('network')
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:
_require_enabled('notifications')
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.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,
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
MachineType.machinetype.ilike(search_term)
)
).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,
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:
_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,
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:
# 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_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)