Add USB, Notifications, Network plugins and reusable EmployeeSearch component

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>
This commit is contained in:
cproudlock
2026-01-21 16:37:49 -05:00
parent 02d83335ee
commit 9c220a4194
110 changed files with 17693 additions and 600 deletions

View File

@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import (
Machine, Application, KnowledgeBase
Machine, Application, KnowledgeBase,
Asset, AssetType
)
from shopdb.utils.responses import success_response
@@ -46,16 +47,21 @@ def global_search():
search_term = f'%{query}%'
# Search Machines (Equipment and PCs)
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()
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
@@ -110,54 +116,62 @@ def global_search():
})
# Search Applications
apps = Application.query.filter(
Application.isactive == True,
db.or_(
Application.appname.ilike(search_term),
Application.appdescription.ilike(search_term)
)
).limit(10).all()
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
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
})
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
kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True,
db.or_(
KnowledgeBase.shortdescription.ilike(search_term),
KnowledgeBase.keywords.ilike(search_term)
)
).limit(20).all()
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
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
})
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:
@@ -187,17 +201,132 @@ def global_search():
'url': f"/printers/{p.printerid}",
'relevance': relevance
})
except ImportError:
pass # Printers plugin not installed
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
results = results[:30]
unique_results = unique_results[:30]
return success_response({
'results': results,
'results': unique_results,
'query': query,
'total': len(results)
'total': len(unique_results)
})