Files
shopdb-flask/shopdb/core/api/search.py
cproudlock b8c22244a1
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:02:07 -04:00

810 lines
28 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 == '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:
_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_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 equipment/device type name."""
results = []
# Equipment: vendor, model, equipmenttype
try:
_require_enabled('equipment')
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:
_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_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)