New Plugins: - USB plugin: Device checkout/checkin with employee lookup, checkout history - Notifications plugin: Announcements with types, scheduling, shopfloor display - Network plugin: Network device management with subnets and VLANs - Equipment and Computers plugins: Asset type separation Frontend: - EmployeeSearch component: Reusable employee lookup with autocomplete - USB views: List, detail, checkout/checkin modals - Notifications views: List, form with recognition mode - Network views: Device list, detail, form - Calendar view with FullCalendar integration - Shopfloor and TV dashboard views - Reports index page - Map editor for asset positioning - Light/dark mode fixes for map tooltips Backend: - Employee search API with external lookup service - Collector API for PowerShell data collection - Reports API endpoints - Slides API for TV dashboard - Fixed AppVersion model (removed BaseModel inheritance) - Added checkout_name column to usbcheckouts table Styling: - Unified detail page styles - Improved pagination (page numbers instead of prev/next) - Dark/light mode theme improvements Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Global search API endpoint."""
|
|
|
|
from flask import Blueprint, request
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.extensions import db
|
|
from shopdb.core.models import (
|
|
Machine, Application, KnowledgeBase,
|
|
Asset, AssetType
|
|
)
|
|
from shopdb.utils.responses import success_response
|
|
|
|
search_bp = Blueprint('search', __name__)
|
|
|
|
|
|
@search_bp.route('', methods=['GET'])
|
|
@jwt_required(optional=True)
|
|
def global_search():
|
|
"""
|
|
Global search across multiple entity types.
|
|
|
|
Returns combined results from:
|
|
- Machines (equipment and PCs)
|
|
- Applications
|
|
- Knowledge Base articles
|
|
- Printers (if available)
|
|
|
|
Results are sorted by relevance score.
|
|
"""
|
|
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'
|
|
})
|
|
|
|
results = []
|
|
search_term = f'%{query}%'
|
|
|
|
# Search Machines (Equipment and PCs)
|
|
try:
|
|
machines = Machine.query.filter(
|
|
Machine.isactive == True,
|
|
db.or_(
|
|
Machine.machinenumber.ilike(search_term),
|
|
Machine.alias.ilike(search_term),
|
|
Machine.hostname.ilike(search_term),
|
|
Machine.serialnumber.ilike(search_term),
|
|
Machine.notes.ilike(search_term)
|
|
)
|
|
).limit(10).all()
|
|
except Exception as e:
|
|
import logging
|
|
logging.error(f"Machine search failed: {e}")
|
|
machines = []
|
|
|
|
for m in machines:
|
|
# Determine type: PC, Printer, or Equipment
|
|
is_pc = m.pctypeid is not None
|
|
is_printer = m.is_printer
|
|
|
|
# Calculate relevance - exact matches score higher
|
|
relevance = 15
|
|
if m.machinenumber and query.lower() == m.machinenumber.lower():
|
|
relevance = 100
|
|
elif m.hostname and query.lower() == m.hostname.lower():
|
|
relevance = 100
|
|
elif m.alias and query.lower() in m.alias.lower():
|
|
relevance = 50
|
|
|
|
display_name = m.hostname if is_pc and m.hostname else m.machinenumber
|
|
if m.alias and not is_pc:
|
|
display_name = f"{m.machinenumber} ({m.alias})"
|
|
|
|
# Determine result type and URL
|
|
if is_printer:
|
|
result_type = 'printer'
|
|
url = f"/printers/{m.machineid}"
|
|
elif is_pc:
|
|
result_type = 'pc'
|
|
url = f"/pcs/{m.machineid}"
|
|
else:
|
|
result_type = 'machine'
|
|
url = f"/machines/{m.machineid}"
|
|
|
|
# Get location - prefer machine's own location, fall back to parent machine's location
|
|
location_name = None
|
|
if m.location:
|
|
location_name = m.location.locationname
|
|
elif m.parent_relationships:
|
|
# Check parent machines for location
|
|
for rel in m.parent_relationships:
|
|
if rel.parent_machine and rel.parent_machine.location:
|
|
location_name = rel.parent_machine.location.locationname
|
|
break
|
|
|
|
# Get machinetype from model (single source of truth)
|
|
mt = m.derived_machinetype
|
|
results.append({
|
|
'type': result_type,
|
|
'id': m.machineid,
|
|
'title': display_name,
|
|
'subtitle': mt.machinetype if mt else None,
|
|
'location': location_name,
|
|
'url': url,
|
|
'relevance': relevance
|
|
})
|
|
|
|
# Search Applications
|
|
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:
|
|
import logging
|
|
logging.error(f"Application search failed: {e}")
|
|
|
|
# Search Knowledge Base
|
|
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:
|
|
# Weight by clicks and keyword match
|
|
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:
|
|
import logging
|
|
logging.error(f"KnowledgeBase search failed: {e}")
|
|
|
|
# Search Printers (check if printers model exists)
|
|
try:
|
|
from shopdb.plugins.printers.models import Printer
|
|
printers = Printer.query.filter(
|
|
Printer.isactive == True,
|
|
db.or_(
|
|
Printer.printercsfname.ilike(search_term),
|
|
Printer.printerwindowsname.ilike(search_term),
|
|
Printer.serialnumber.ilike(search_term),
|
|
Printer.fqdn.ilike(search_term)
|
|
)
|
|
).limit(10).all()
|
|
|
|
for p in printers:
|
|
relevance = 15
|
|
if p.printercsfname and query.lower() == p.printercsfname.lower():
|
|
relevance = 100
|
|
|
|
display_name = p.printercsfname or p.printerwindowsname or f"Printer #{p.printerid}"
|
|
|
|
results.append({
|
|
'type': 'printer',
|
|
'id': p.printerid,
|
|
'title': display_name,
|
|
'subtitle': p.printerwindowsname if p.printercsfname else None,
|
|
'url': f"/printers/{p.printerid}",
|
|
'relevance': relevance
|
|
})
|
|
except Exception as e:
|
|
import logging
|
|
logging.error(f"Printer search failed: {e}")
|
|
|
|
# Search Employees (separate database)
|
|
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'])
|
|
|
|
# Calculate relevance
|
|
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:
|
|
import logging
|
|
logging.error(f"Employee search failed: {e}")
|
|
|
|
# Search unified Assets table
|
|
try:
|
|
assets = Asset.query.join(AssetType).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(10).all()
|
|
|
|
for asset in assets:
|
|
# Calculate relevance
|
|
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
|
|
|
|
# Determine URL and type based on asset type
|
|
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
|
|
url_map = {
|
|
'equipment': f"/equipment/{asset.assetid}",
|
|
'computer': f"/pcs/{asset.assetid}",
|
|
'network_device': f"/network/{asset.assetid}",
|
|
'printer': f"/printers/{asset.assetid}",
|
|
}
|
|
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
|
|
|
|
# Get location name
|
|
location_name = asset.location.locationname if asset.location else None
|
|
|
|
results.append({
|
|
'type': asset_type_name,
|
|
'id': asset.assetid,
|
|
'title': display_name,
|
|
'subtitle': subtitle,
|
|
'location': location_name,
|
|
'url': url,
|
|
'relevance': relevance
|
|
})
|
|
except Exception as e:
|
|
import logging
|
|
logging.error(f"Asset search failed: {e}")
|
|
|
|
# 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)
|
|
|
|
# Limit total results
|
|
unique_results = unique_results[:30]
|
|
|
|
return success_response({
|
|
'results': unique_results,
|
|
'query': query,
|
|
'total': len(unique_results)
|
|
})
|