display FQDN keying + kiosk dropdown + dispatcher + multi-word search

This commit is contained in:
2026-07-29 07:45:33 -04:00
parent 14c0d4be1e
commit 02147827f0
12 changed files with 408 additions and 112 deletions

View File

@@ -120,6 +120,9 @@ export const computersApi = {
list(params = {}) {
return api.get('/computers', { params })
},
displayKiosks() {
return api.get('/computers/display-kiosks')
},
get(id) {
return api.get(`/computers/${id}`)
},

View File

@@ -19,16 +19,16 @@
<table>
<thead>
<tr>
<th>IP Address</th>
<th>Kiosk (FQDN / IP)</th>
<th>Display</th>
<th>Business Unit</th>
<th>Location</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="d in items" :key="d.dashboarddefaultid">
<td class="mono">{{ d.ipaddress }}</td>
<td class="mono">{{ d.fqdn || d.ipaddress }}</td>
<td>{{ roleLabel(d.displayrole) }}</td>
<td>{{ d.businessunit || '-' }}</td>
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
@@ -57,9 +57,19 @@
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="ipaddress">IP Address *</label>
<label for="kiosk">Kiosk</label>
<select id="kiosk" v-model="form.fqdn" class="form-control">
<option value="">Select a kiosk (or enter an IP below)...</option>
<option v-for="k in kiosks" :key="k.computerid" :value="k.fqdn" :disabled="!k.fqdn">
{{ k.hostname }}{{ k.fqdn ? ' - ' + k.fqdn : ' (no serial reported yet)' }}
</option>
</select>
<small class="form-hint">Pick a reporting display to fill its FQDN, or enter an IP manually below.</small>
</div>
<div class="form-group">
<label for="ipaddress">IP Address (optional)</label>
<input id="ipaddress" v-model="form.ipaddress" type="text" class="form-control"
placeholder="e.g., 10.20.30.40" required />
placeholder="e.g., 10.20.30.40" />
</div>
<div class="form-group">
<label for="displayrole">Display *</label>
@@ -68,10 +78,10 @@
</select>
</div>
<div class="form-group" v-if="form.displayrole === 'dashboard'">
<label for="businessunitid">Business Unit *</label>
<label for="businessunitid">Location *</label>
<select id="businessunitid" v-model="form.businessunitid" class="form-control"
:required="form.displayrole === 'dashboard'">
<option value="">Select business unit...</option>
<option value="">Select location...</option>
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
{{ bu.businessunit }}
</option>
@@ -112,7 +122,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { dashboardDefaultsApi, businessUnitsApi } from '../../api'
import { dashboardDefaultsApi, businessUnitsApi, computersApi } from '../../api'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
@@ -128,6 +138,7 @@ function roleLabel(value) {
const items = ref([])
const businessUnits = ref([])
const kiosks = ref([])
const loading = ref(true)
const showModal = ref(false)
@@ -138,7 +149,7 @@ const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' })
const form = ref({ fqdn: '', ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' })
onMounted(async () => {
try {
@@ -147,6 +158,12 @@ onMounted(async () => {
} catch (err) {
console.error('Error loading business units:', err)
}
try {
const kioskResp = await computersApi.displayKiosks()
kiosks.value = kioskResp.data.data || []
} catch (err) {
console.error('Error loading display kiosks:', err)
}
await loadData()
})
@@ -165,11 +182,12 @@ async function loadData() {
function openModal(item = null) {
editing.value = item
form.value = item ? {
fqdn: item.fqdn || '',
ipaddress: item.ipaddress || '',
displayrole: item.displayrole || 'dashboard',
businessunitid: item.businessunitid || '',
description: item.description || ''
} : { ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' }
} : { fqdn: '', ipaddress: '', displayrole: 'dashboard', businessunitid: '', description: '' }
error.value = ''
showModal.value = true
}

View File

@@ -82,7 +82,7 @@ export const settingsGroups = [
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default location' },
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
],
},

View File

@@ -0,0 +1,62 @@
"""dashboarddefaults: add fqdn, make ipaddress nullable.
Map a display PC to its role/location by its STABLE FQDN (derived from the BIOS
serial, F<serial>.<domain>) instead of only its DHCP-changeable IP. IP stays as
a nullable fallback. Idempotent.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d31_dashboarddefault_fqdn'
down_revision = '7d30_apitoken_resourcescopes'
branch_labels = None
depends_on = None
def _has_column(bind, table, column):
inspector = sa.inspect(bind)
if table not in inspector.get_table_names():
return False
return column in {c['name'] for c in inspector.get_columns(table)}
def _col_nullable(bind, table, column):
inspector = sa.inspect(bind)
for c in inspector.get_columns(table):
if c['name'] == column:
return c.get('nullable', True)
return True
def _has_index(bind, table, index):
inspector = sa.inspect(bind)
if table not in inspector.get_table_names():
return False
return index in {i['name'] for i in inspector.get_indexes(table)}
def upgrade():
bind = op.get_bind()
if not _has_column(bind, 'dashboarddefaults', 'fqdn'):
op.add_column('dashboarddefaults',
sa.Column('fqdn', sa.String(191), nullable=True))
if not _has_index(bind, 'dashboarddefaults', 'idx_dashboarddefaults_fqdn'):
op.create_index('idx_dashboarddefaults_fqdn', 'dashboarddefaults',
['fqdn'], unique=True)
# IP is now optional (an FQDN-only mapping has no IP).
if not _col_nullable(bind, 'dashboarddefaults', 'ipaddress'):
op.alter_column('dashboarddefaults', 'ipaddress',
existing_type=sa.String(50), nullable=True)
def downgrade():
bind = op.get_bind()
if _has_index(bind, 'dashboarddefaults', 'idx_dashboarddefaults_fqdn'):
op.drop_index('idx_dashboarddefaults_fqdn',
table_name='dashboarddefaults')
if _has_column(bind, 'dashboarddefaults', 'fqdn'):
op.drop_column('dashboarddefaults', 'fqdn')
# ipaddress is left nullable: reverting to NOT NULL could fail on FQDN-only
# rows that legitimately have no IP.

View File

@@ -298,6 +298,42 @@ def _sync_access_methods(comp, data):
# Computers CRUD
# =============================================================================
@computers_bp.route('/display-kiosks', methods=['GET'])
@jwt_required(optional=True)
def list_display_kiosks():
"""Reporting display kiosks, for the Dashboard Defaults picker.
Returns the computers whose type is the one gea-shopfloor-display maps to
(default 'Kiosk'), each with its DERIVED FQDN (F<serial>.<domain>, domain
from the display_fqdn_domain setting) so an admin picks a kiosk from a
dropdown instead of typing an IP/FQDN. Keeps the same F<serial>.<domain>
format as core derive_display_fqdn (duplicated to avoid a contract bump).
"""
from ..pctypemap import pctype_mapping
from shopdb.api import Setting
display_type_name = pctype_mapping().get('gea-shopfloor-display', 'Kiosk')
ctype = ComputerType.query.filter_by(computertype=display_type_name).first()
if not ctype:
return success_response([])
domain = (Setting.get('display_fqdn_domain', 'device.geaerospace.net')
or 'device.geaerospace.net').strip().strip('.')
rows = (db.session.query(Computer).join(Asset)
.filter(Computer.computertypeid == ctype.computertypeid,
Asset.isactive == True)
.order_by(Computer.hostname).all())
out = []
for comp in rows:
serial = (comp.asset.serialnumber or '').strip() if comp.asset else ''
out.append({
'computerid': comp.computerid,
'hostname': comp.hostname,
'serialnumber': serial or None,
'fqdn': f'F{serial}.{domain}'.lower() if serial else None,
})
return success_response(out)
@computers_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_computers():

View File

@@ -111,28 +111,48 @@ try {{
if ($shopdbConfig.BaseUrl) {{ $KioskBaseUrl = ([string]$shopdbConfig.BaseUrl).TrimEnd('/') }}
}} catch {{}}
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
if (-not (Test-Path -LiteralPath $displayTypeFile)) {{
Write-Host "display-type.txt not found at $displayTypeFile; nothing to configure."
# PREFERRED: resolve this display's role from the server by its FQDN, so a change
# in Settings > Dashboard Defaults takes effect with no reimage / local edit. The
# FQDN is F<BIOS serial>.<domain> (GE device naming); domain from HKLM
# DisplayFqdnDomain or the default. display-role is a PUBLIC endpoint (no token).
$path = $null
$serial = ''
try {{ $serial = ([string](Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber).Trim() }} catch {{}}
$fqdnDomain = 'device.geaerospace.net'
try {{
$ddom = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\GE\\ShopDB' -Name DisplayFqdnDomain -ErrorAction Stop
if ($ddom.DisplayFqdnDomain) {{ $fqdnDomain = ([string]$ddom.DisplayFqdnDomain).Trim().Trim('.') }}
}} catch {{}}
if ($serial) {{
$fqdn = ('F' + $serial + '.' + $fqdnDomain).ToLower()
try {{
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$roleUri = "$KioskBaseUrl/api/dashboarddefaults/display-role?fqdn=$([uri]::EscapeDataString($fqdn))"
$resp = Invoke-RestMethod -Uri $roleUri -TimeoutSec 20 -ErrorAction Stop
if ($resp -and $resp.data -and $resp.data.path) {{
$path = [string]$resp.data.path
Write-Host "server role for $fqdn -> $path"
}}
}} catch {{ Write-Host "display-role lookup failed for $fqdn : $($_.Exception.Message)" }}
}}
# FALLBACK: the local display-type.txt map (offline, or no server mapping yet).
if (-not $path) {{
$displayTypeFile = 'C:\\Enrollment\\display-type.txt'
if (Test-Path -LiteralPath $displayTypeFile) {{
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
if (-not [string]::IsNullOrWhiteSpace($displayType)) {{
$matchedKey = $DisplayTypeTargets.Keys | Where-Object {{ $_ -ieq $displayType }} | Select-Object -First 1
if ($matchedKey) {{ $path = [string]$DisplayTypeTargets[$matchedKey]; Write-Host "local display-type '$displayType' -> $path" }}
}}
}}
}}
if (-not $path) {{
Write-Host 'No display role from server or display-type.txt; nothing to configure.'
return
}}
$displayType = (Get-Content -LiteralPath $displayTypeFile -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($displayType)) {{
Write-Host 'display-type.txt is empty; nothing to configure.'
return
}}
# Case-insensitive lookup so 'lobby' and 'Lobby' both resolve.
$matchedKey = $DisplayTypeTargets.Keys |
Where-Object {{ $_ -ieq $displayType }} |
Select-Object -First 1
if (-not $matchedKey) {{
Write-Host "Unknown display-type '$displayType'; known types: $($DisplayTypeTargets.Keys -join ', ')."
return
}}
$kioskUrl = "$KioskBaseUrl$($DisplayTypeTargets[$matchedKey])"
$kioskUrl = "$KioskBaseUrl$path"
# Resolve msedge.exe (baked into the display image).
$edge = $null

View File

@@ -32,6 +32,18 @@ def _serialize(default):
return data
def _resolve_default(fqdn, ipaddress):
"""Find the active mapping for a display: FQDN first (stable), then IP."""
default = None
if fqdn:
default = DashboardDefault.query.filter_by(
fqdn=fqdn, isactive=True).first()
if not default and ipaddress:
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
return default
@dashboarddefaults_bp.route('/visitor-location', methods=['GET'])
def visitor_location():
"""Resolve the business unit for the calling display by its IP.
@@ -39,16 +51,18 @@ def visitor_location():
Unauthenticated: kiosks/lobby displays hit this. Returns the mapped
business unit, or a null businessunitid when the IP is not mapped.
"""
fqdn = (request.args.get('fqdn') or '').strip().lower() or None
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
default = _resolve_default(fqdn, ipaddress)
if not default:
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'businessunitid': None,
'businessunit': None,
})
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'businessunitid': default.businessunitid,
'businessunit': default.businessunit.businessunit
@@ -64,11 +78,12 @@ def display_role():
(dashboard / lobby / partskiosk), the frontend path it maps to, and the
business unit for the dashboard role. Unmapped IP -> null role.
"""
fqdn = (request.args.get('fqdn') or '').strip().lower() or None
ipaddress = request.args.get('ipaddress') or _request_ip()
default = DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first()
default = _resolve_default(fqdn, ipaddress)
if not default:
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'role': None,
'path': None,
@@ -76,6 +91,7 @@ def display_role():
'businessunit': None,
})
return success_response({
'fqdn': fqdn,
'ipaddress': ipaddress,
'role': default.displayrole,
'path': default.displaypath,
@@ -100,36 +116,42 @@ def list_defaults():
def create_default():
"""Create a visitor-IP -> business-unit mapping."""
data = request.get_json() or {}
ipaddress = (data.get('ipaddress') or '').strip()
fqdn = (data.get('fqdn') or '').strip().lower() or None
ipaddress = (data.get('ipaddress') or '').strip() or None
role = (data.get('displayrole') or 'dashboard').strip()
businessunitid = data.get('businessunitid')
if not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR, 'ipaddress is required')
if not fqdn and not ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR,
'fqdn or ipaddress is required')
if role not in DISPLAY_ROLES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'displayrole must be one of {", ".join(DISPLAY_ROLES)}')
# Only the dashboard role needs a business unit.
# Only the dashboard role needs a business unit (location).
if role == 'dashboard':
if not businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'businessunitid is required for the dashboard role')
'a location is required for the dashboard role')
if not db.session.get(BusinessUnit, businessunitid):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
return error_response(ErrorCodes.NOT_FOUND, 'Location not found',
http_code=404)
else:
businessunitid = None
if DashboardDefault.query.filter_by(ipaddress=ipaddress, isactive=True).first():
if fqdn and DashboardDefault.query.filter_by(fqdn=fqdn, isactive=True).first():
return error_response(ErrorCodes.CONFLICT,
f"{fqdn} is already mapped", http_code=409)
if ipaddress and DashboardDefault.query.filter_by(
ipaddress=ipaddress, isactive=True).first():
return error_response(ErrorCodes.CONFLICT,
f"IP {ipaddress} is already mapped", http_code=409)
default = DashboardDefault(ipaddress=ipaddress, displayrole=role,
default = DashboardDefault(fqdn=fqdn, ipaddress=ipaddress, displayrole=role,
businessunitid=businessunitid,
description=data.get('description'))
db.session.add(default)
db.session.flush()
AuditLog.log('created', 'DashboardDefault', entityid=default.dashboarddefaultid,
entityname=ipaddress)
entityname=(fqdn or ipaddress))
db.session.commit()
return success_response(_serialize(default), message='Mapping created',
http_code=201)
@@ -156,17 +178,22 @@ def update_default(default_id):
return error_response(ErrorCodes.NOT_FOUND, 'Business unit not found',
http_code=404)
default.businessunitid = data['businessunitid']
if 'ipaddress' in data and data['ipaddress']:
default.ipaddress = data['ipaddress'].strip()
if 'fqdn' in data:
default.fqdn = (data.get('fqdn') or '').strip().lower() or None
if 'ipaddress' in data:
default.ipaddress = (data.get('ipaddress') or '').strip() or None
if 'description' in data:
default.description = data['description']
# Non-dashboard roles carry no business unit; dashboard needs one.
if not default.fqdn and not default.ipaddress:
return error_response(ErrorCodes.VALIDATION_ERROR,
'fqdn or ipaddress is required')
# Non-dashboard roles carry no location; dashboard needs one.
if default.displayrole != 'dashboard':
default.businessunitid = None
elif not default.businessunitid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'businessunitid is required for the dashboard role')
'a location is required for the dashboard role')
db.session.commit()
return success_response(_serialize(default), message='Mapping updated')

View File

@@ -23,6 +23,24 @@ logger = logging.getLogger(__name__)
search_bp = Blueprint('search', __name__)
def _word_match(query, *columns):
"""SQL clause matching rows that contain EVERY word of the query, each word
in any of the columns, in any order.
A plain `col.ilike('%CSF Roles%')` needs the exact contiguous phrase, so a
multi-word search returned nothing. This splits the query into words and ANDs
them (OR across the columns per word): 'CSF Roles' matches a row with 'CSF'
and 'Roles' anywhere in the searched fields.
"""
words = [w for w in query.split() if w]
if not words:
words = ['']
return db.and_(*[
db.or_(*[c.ilike(f'%{w}%') for c in columns])
for w in words
])
def _require_enabled(name):
"""Raise ImportError when the named plugin is disabled.
@@ -194,10 +212,7 @@ def _search_applications(query, search_term):
try:
apps = Application.query.filter(
Application.isactive == True,
db.or_(
Application.appname.ilike(search_term),
Application.appdescription.ilike(search_term)
)
_word_match(query, Application.appname, Application.appdescription)
).limit(10).all()
for app in apps:
@@ -228,10 +243,8 @@ def _search_knowledgebase(query, search_term):
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)
)
_word_match(query, KnowledgeBase.shortdescription,
KnowledgeBase.keywords)
).limit(20).all()
for kb in kb_articles:
@@ -255,7 +268,7 @@ def _search_knowledgebase(query, search_term):
return results
def _employee_rows(search_term):
def _employee_rows(query):
"""Employee rows matching search_term, from the SELFHOSTED directory table
or the external HR DB per the employee_directory_mode setting. Returns dicts
shaped like the external query (SSO/First_Name/Last_Name/Team/Role)."""
@@ -267,25 +280,30 @@ def _employee_rows(search_term):
except ImportError:
return []
rows = DirectoryEmployee.query.filter(
db.or_(
DirectoryEmployee.firstname.ilike(search_term),
DirectoryEmployee.lastname.ilike(search_term),
db.cast(DirectoryEmployee.sso, db.String).ilike(search_term),
)
_word_match(query, DirectoryEmployee.firstname,
DirectoryEmployee.lastname,
db.cast(DirectoryEmployee.sso, db.String))
).order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname).limit(10).all()
return [{'SSO': e.sso, 'First_Name': e.firstname or '',
'Last_Name': e.lastname or '', 'Team': e.team, 'Role': e.role}
for e in rows]
# External HR DB: shared env-backed connection helper; never hardcode creds.
# Word-AND so "First Last" matches (each word in any name/SSO field), not just
# the exact contiguous phrase. Parameterized - one LIKE triple per word.
from shopdb.utils.employee_db import employee_connection
words = [w for w in query.split() if w] or ['']
clauses, params = [], []
for w in words:
clauses.append('(First_Name LIKE %s OR Last_Name LIKE %s '
'OR CAST(SSO AS CHAR) LIKE %s)')
like = f'%{w}%'
params += [like, like, like]
where = ' AND '.join(clauses)
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))
cur.execute(
'SELECT SSO, First_Name, Last_Name, Team, Role FROM employees '
f'WHERE {where} ORDER BY Last_Name, First_Name LIMIT 10', params)
rows = cur.fetchall()
emp_conn.close()
return rows
@@ -295,7 +313,7 @@ def _search_employees(query, search_term):
"""Search employees (selfhosted directory or external HR DB, per mode)."""
results = []
try:
for emp in _employee_rows(search_term):
for emp in _employee_rows(query):
full_name = f"{emp['First_Name'].strip()} {emp['Last_Name'].strip()}"
sso_str = str(emp['SSO'])
@@ -329,12 +347,8 @@ def _search_assets(query, search_term):
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)
)
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.notes)
).limit(15).all()
for asset in assets:
@@ -372,12 +386,8 @@ def _search_measuringtools(query, search_term):
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),
)
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.gaugelabreference)
).limit(15).all()
for asset in assets:
@@ -421,7 +431,7 @@ def _search_customfields(query, search_term):
Asset.isactive == True,
CustomField.searchable == True,
CustomField.isactive == True,
CustomFieldValue.value.ilike(search_term),
_word_match(query, CustomFieldValue.value),
).limit(15).all()
for asset, field in rows:
@@ -438,7 +448,7 @@ def _search_by_ip(query, search_term):
results = []
try:
comms = Communication.query.filter(
Communication.ipaddress.ilike(search_term)
_word_match(query, Communication.ipaddress)
).options(
joinedload(Communication.asset).joinedload(Asset.assettype),
joinedload(Communication.asset).joinedload(Asset.location),
@@ -498,7 +508,7 @@ def _search_hostnames(query, search_term):
_require_enabled('computers')
from plugins.computers.models import Computer
computers = Computer.query.filter(
Computer.hostname.ilike(search_term)
_word_match(query, Computer.hostname)
).options(
joinedload(Computer.asset).joinedload(Asset.assettype),
joinedload(Computer.asset).joinedload(Asset.location),
@@ -520,11 +530,8 @@ def _search_hostnames(query, search_term):
_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),
)
_word_match(query, Printer.hostname, Printer.sharename,
Printer.windowsname)
).options(
joinedload(Printer.asset).joinedload(Asset.assettype),
joinedload(Printer.asset).joinedload(Asset.location),
@@ -547,7 +554,7 @@ def _search_hostnames(query, search_term):
_require_enabled('network')
from plugins.network.models import NetworkDevice
devices = NetworkDevice.query.filter(
NetworkDevice.hostname.ilike(search_term)
_word_match(query, NetworkDevice.hostname)
).options(
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
joinedload(NetworkDevice.asset).joinedload(Asset.location),
@@ -577,10 +584,8 @@ def _search_notifications(query, search_term):
notifications = Notification.query.options(
joinedload(Notification.notificationtype)
).filter(
db.or_(
Notification.notification.ilike(search_term),
Notification.ticketnumber.ilike(search_term)
)
_word_match(query, Notification.notification,
Notification.ticketnumber)
).order_by(Notification.starttime.desc()).limit(15).all()
now = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -635,11 +640,8 @@ def _search_vendor_model_type(query, search_term):
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
MachineType.machinetype.ilike(search_term)
)
_word_match(query, Vendor.vendor, Model.modelnumber,
MachineType.machinetype)
).limit(10).all()
for asset in machine_assets:
@@ -666,11 +668,8 @@ def _search_vendor_model_type(query, search_term):
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
PrinterType.printertype.ilike(search_term)
)
_word_match(query, Vendor.vendor, Model.modelnumber,
PrinterType.printertype)
).limit(10).all()
for asset in printer_assets:
@@ -695,10 +694,8 @@ def _search_vendor_model_type(query, search_term):
joinedload(Asset.location),
).filter(
Asset.isactive == True,
db.or_(
Vendor.vendor.ilike(search_term),
NetworkDeviceType.networkdevicetype.ilike(search_term)
)
_word_match(query, Vendor.vendor,
NetworkDeviceType.networkdevicetype)
).limit(10).all()
for asset in netdev_assets:

View File

@@ -18,14 +18,41 @@ DISPLAY_ROLE_PATHS = {
}
DISPLAY_ROLES = tuple(DISPLAY_ROLE_PATHS.keys())
# GE device naming: a PC's DNS name is 'F' + its BIOS serial under the device
# domain, e.g. FABC1234.device.geaerospace.net. The domain is a setting so other
# sites can point elsewhere; the collector already reports the serial, so the
# server derives the stable FQDN without the kiosk having to report it.
DEFAULT_DISPLAY_FQDN_DOMAIN = 'device.geaerospace.net'
def derive_display_fqdn(serialnumber):
"""FQDN for a display PC from its BIOS serial: F<serial>.<domain> (lower).
Returns None when there is no serial. Domain from the display_fqdn_domain
setting, falling back to DEFAULT_DISPLAY_FQDN_DOMAIN.
"""
serial = (serialnumber or '').strip()
if not serial:
return None
from .setting import Setting
domain = (Setting.get('display_fqdn_domain', DEFAULT_DISPLAY_FQDN_DOMAIN)
or DEFAULT_DISPLAY_FQDN_DOMAIN).strip().strip('.')
return f'F{serial}.{domain}'.lower()
class DashboardDefault(BaseModel):
"""Maps a display PC IP address to its display role (+ business unit)."""
"""Maps a display PC (by FQDN, or IP) to its display role (+ location)."""
__tablename__ = 'dashboarddefaults'
dashboarddefaultid = db.Column(db.Integer, primary_key=True)
ipaddress = db.Column(db.String(50), unique=True, nullable=False)
# Which display the IP drives. Only the dashboard role uses businessunitid.
# A mapping is keyed by FQDN (stable, DHCP-proof) and/or IP. At least one is
# required (enforced in the API). FQDN is preferred; IP is the fallback for a
# manual entry or a PC not yet reporting a serial.
# 191 = the utf8mb4-safe unique-index length (191*4 < 767) so the index does
# not depend on innodb_large_prefix; FQDNs (F<serial>.<domain>) fit easily.
fqdn = db.Column(db.String(191), unique=True, nullable=True, index=True)
ipaddress = db.Column(db.String(50), unique=True, nullable=True)
# Which display the mapping drives. Only the dashboard role uses businessunitid.
displayrole = db.Column(db.String(20), nullable=False, default='dashboard')
businessunitid = db.Column(
db.Integer,
@@ -41,4 +68,4 @@ class DashboardDefault(BaseModel):
return DISPLAY_ROLE_PATHS.get(self.displayrole)
def __repr__(self):
return f"<DashboardDefault {self.ipaddress} -> {self.displayrole}>"
return f"<DashboardDefault {self.fqdn or self.ipaddress} -> {self.displayrole}>"

View File

@@ -129,7 +129,29 @@ def test_dashboard_role_requires_businessunit(client, db, auth_headers):
'ipaddress': '10.20.30.55', 'displayrole': 'dashboard',
}, headers=auth_headers)
assert resp.status_code == 400
assert 'businessunitid' in resp.get_json()['data']['error']['message'].lower()
assert 'location' in resp.get_json()['data']['error']['message'].lower()
def test_fqdn_mapping_create_and_resolve(client, db, auth_headers):
# A mapping can be keyed by FQDN (no IP), and /display-role resolves it.
resp = client.post('/api/dashboarddefaults', json={
'fqdn': 'FABC123.device.geaerospace.net', 'displayrole': 'lobby',
}, headers=auth_headers)
assert resp.status_code == 201, resp.data
assert resp.get_json()['data']['fqdn'] == 'fabc123.device.geaerospace.net'
# resolve by FQDN (case-insensitive)
r = client.get('/api/dashboarddefaults/display-role?fqdn=fabc123.device.geaerospace.net')
body = r.get_json()['data']
assert body['role'] == 'lobby'
assert body['path'] == '/tv'
def test_mapping_requires_fqdn_or_ip(client, db, auth_headers):
resp = client.post('/api/dashboarddefaults', json={'displayrole': 'lobby'},
headers=auth_headers)
assert resp.status_code == 400
assert 'fqdn or ipaddress' in resp.get_json()['data']['error']['message'].lower()
def test_invalid_role_rejected(client, db, auth_headers):

View File

@@ -0,0 +1,45 @@
"""The /computers/display-kiosks picker: reporting displays with a derived FQDN.
Feeds the Dashboard Defaults kiosk dropdown so an admin picks a display instead
of typing an IP. The FQDN is F<serial>.<domain> (device naming), derived from the
BIOS serial the collector already reports.
"""
import pytest
from shopdb.core.models import AssetType
from plugins.computers.models import ComputerType
KEY = 'testcollectorkey'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
def test_display_kiosks_derives_fqdn(client, db, collector_key):
db.session.add(AssetType(assettype='computer', pluginname='computers',
tablename='computers'))
db.session.add(ComputerType(computertype='Kiosk'))
db.session.commit()
# a display reports in via the collector (pctype -> Kiosk type, with serial)
resp = client.post('/api/collector/computers', json={
'hostname': 'WJDISP01', 'serialnumber': 'ABC1234',
'pctype': 'gea-shopfloor-display',
}, headers={'X-API-Key': KEY})
assert resp.status_code == 200, resp.data
rows = client.get('/api/computers/display-kiosks').get_json()['data']
match = [k for k in rows if k['hostname'] == 'WJDISP01']
assert match, rows
assert match[0]['fqdn'] == 'fabc1234.device.geaerospace.net'
assert match[0]['serialnumber'] == 'ABC1234'
def test_display_kiosks_empty_when_no_kiosk_type(client, db):
# No 'Kiosk' ComputerType -> empty list, not an error.
assert client.get('/api/computers/display-kiosks').get_json()['data'] == []

View File

@@ -0,0 +1,39 @@
"""Global search matches multi-word queries by WORD (every word, any order or
field), not just the exact contiguous phrase.
Regression: 'CSF Roles' returned nothing because search did a single
ilike('%CSF Roles%'). Now each word must match (ORed across fields), so a record
with both words - even reversed or split - is found; a record with only one is
not.
"""
from shopdb.core.models import AssetType, Asset
def _seed(client, db, auth_headers, assetnumber, name):
if not AssetType.query.filter_by(assettype='computer').first():
db.session.add(AssetType(assettype='computer', pluginname='computer',
tablename='computer', description='c'))
db.session.commit()
r = client.post('/api/computers', json={'assetnumber': assetnumber},
headers=auth_headers)
assert r.status_code == 201, r.get_json()
asset = Asset.query.filter_by(assetnumber=assetnumber).first()
asset.name = name
db.session.commit()
def _titles(client, auth_headers, term):
r = client.get(f'/api/search?q={term}', headers=auth_headers)
assert r.status_code == 200, r.get_json()
return {h['title'] for h in r.get_json()['data']['results']}
def test_multiword_matches_all_words_any_order(client, db, auth_headers):
_seed(client, db, auth_headers, 'MW-1', 'CSF Roles Alpha') # contiguous
_seed(client, db, auth_headers, 'MW-2', 'Roles for the CSF Beta') # split/reversed
_seed(client, db, auth_headers, 'MW-3', 'CSF only') # one word only
titles = _titles(client, auth_headers, 'CSF Roles')
assert 'CSF Roles Alpha' in titles # exact phrase still works
assert 'Roles for the CSF Beta' in titles # words split/reversed now match
assert 'CSF only' not in titles # missing a word -> excluded