Add per-domain global-search toggles
Lets an admin choose which content types appear in global search results, independent of whether the owning plugin is enabled (the existing _require_enabled gating was all-or-nothing per plugin). - settings.py: SEARCH_DOMAINS const (9 result types: application, knowledgebase, employee, equipment, computer, printer, network_device, notification, subnet) + seed keys search_<type>_enabled (boolean, default true) in build_default_settings (covers API seed + CLI). - search.py: global_search loads disabled types in one query (category 'search') and filters the deduped results by type before counts/truncation. Missing key = enabled. - SystemSettings.vue: "Global Search" section, one toggle per domain (mirrors the identifier pattern; create-on-404 fallback so an un-reseeded deploy still works). - Tests: domain included by default, disabled domain excluded, seed creates the 9 keys. 166 tests pass, naming green, build green. Verified live: toggling search_knowledgebase_enabled off drops knowledgebase from search counts and back on restores it. Dev DB seeded (9 keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -414,6 +414,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Search Section -->
|
||||||
|
<div class="section-card">
|
||||||
|
<h2 class="section-title">Global Search</h2>
|
||||||
|
|
||||||
|
<div class="setting-group">
|
||||||
|
<p class="setting-description">
|
||||||
|
Choose which content types appear in global search results. Disabling a
|
||||||
|
type here hides it from search without disabling the plugin elsewhere.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="setting-row" v-for="domain in searchDomains" :key="domain.key">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<span>{{ domain.label }}</span>
|
||||||
|
<button
|
||||||
|
class="toggle-btn"
|
||||||
|
:class="{ active: searchValue(domain.key) }"
|
||||||
|
@click="toggleSearchDomain(domain.key)"
|
||||||
|
:disabled="saving"
|
||||||
|
>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
@@ -476,6 +502,26 @@ function matrixValue(name, assettype) {
|
|||||||
return key in identifierMatrix ? identifierMatrix[key] : true
|
return key in identifierMatrix ? identifierMatrix[key] : true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global-search domain toggles: keys follow search_<type>_enabled.
|
||||||
|
// Missing = enabled (default on).
|
||||||
|
const searchDomains = [
|
||||||
|
{ key: 'application', label: 'Applications' },
|
||||||
|
{ key: 'knowledgebase', label: 'Knowledge Base' },
|
||||||
|
{ key: 'employee', label: 'Employees' },
|
||||||
|
{ key: 'equipment', label: 'Equipment' },
|
||||||
|
{ key: 'computer', label: 'PCs' },
|
||||||
|
{ key: 'printer', label: 'Printers' },
|
||||||
|
{ key: 'network_device', label: 'Network Devices' },
|
||||||
|
{ key: 'notification', label: 'Notifications' },
|
||||||
|
{ key: 'subnet', label: 'Subnets' }
|
||||||
|
]
|
||||||
|
const searchMatrix = reactive({})
|
||||||
|
|
||||||
|
function searchValue(domainKey) {
|
||||||
|
const key = `search_${domainKey}_enabled`
|
||||||
|
return key in searchMatrix ? searchMatrix[key] : true
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const testingEmail = ref(false)
|
const testingEmail = ref(false)
|
||||||
@@ -538,6 +584,8 @@ async function loadSettings() {
|
|||||||
settings[setting.key] = setting.value
|
settings[setting.key] = setting.value
|
||||||
} else if (/^identifier_.+_(equipment|computer|printer|network_device)_enabled$/.test(setting.key)) {
|
} else if (/^identifier_.+_(equipment|computer|printer|network_device)_enabled$/.test(setting.key)) {
|
||||||
identifierMatrix[setting.key] = setting.value !== false
|
identifierMatrix[setting.key] = setting.value !== false
|
||||||
|
} else if (/^search_.+_enabled$/.test(setting.key)) {
|
||||||
|
searchMatrix[setting.key] = setting.value !== false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -591,6 +639,40 @@ async function toggleIdentifier(name, assettype) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleSearchDomain(domainKey) {
|
||||||
|
const key = `search_${domainKey}_enabled`
|
||||||
|
const newValue = !searchValue(domainKey)
|
||||||
|
const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
try {
|
||||||
|
await settingsApi.update(key, newValue)
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.status === 404) {
|
||||||
|
await settingsApi.create({
|
||||||
|
key,
|
||||||
|
value: newValue,
|
||||||
|
valuetype: 'boolean',
|
||||||
|
category: 'search',
|
||||||
|
description: `Include ${label} in global search results`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
searchMatrix[key] = newValue
|
||||||
|
success.value = 'Setting saved'
|
||||||
|
setTimeout(() => { success.value = '' }, 2000)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || 'Failed to save setting'
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSetting(key, value) {
|
async function saveSetting(key, value) {
|
||||||
try {
|
try {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import joinedload
|
|||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import (
|
from shopdb.core.models import (
|
||||||
Application,
|
Application, Setting,
|
||||||
Asset, AssetType, Communication, Vendor, Model
|
Asset, AssetType, Communication, Vendor, Model
|
||||||
)
|
)
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
@@ -718,6 +718,16 @@ def global_search():
|
|||||||
seen_ids[key] = True
|
seen_ids[key] = True
|
||||||
unique_results.append(r)
|
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
|
# Compute type counts before truncation
|
||||||
type_counts = {}
|
type_counts = {}
|
||||||
for r in unique_results:
|
for r in unique_results:
|
||||||
|
|||||||
@@ -27,6 +27,22 @@ IDENTIFIER_LABELS = {
|
|||||||
}
|
}
|
||||||
IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device']
|
IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device']
|
||||||
|
|
||||||
|
# Global-search result types that can be toggled on/off independently of whether
|
||||||
|
# the owning plugin is enabled. Keys match the `type` field on search results;
|
||||||
|
# seed keys are search_<type>_enabled (boolean, default true). Drives the
|
||||||
|
# Settings "Search" toggles and the filter in shopdb/core/api/search.py.
|
||||||
|
SEARCH_DOMAINS = {
|
||||||
|
'application': 'Applications',
|
||||||
|
'knowledgebase': 'Knowledge Base',
|
||||||
|
'employee': 'Employees',
|
||||||
|
'equipment': 'Equipment',
|
||||||
|
'computer': 'PCs',
|
||||||
|
'printer': 'Printers',
|
||||||
|
'network_device': 'Network Devices',
|
||||||
|
'notification': 'Notifications',
|
||||||
|
'subnet': 'Subnets',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_secret(key: str) -> bool:
|
def _is_secret(key: str) -> bool:
|
||||||
return 'password' in key or 'token' in key or 'secret' in key
|
return 'password' in key or 'token' in key or 'secret' in key
|
||||||
@@ -182,7 +198,19 @@ def build_default_settings():
|
|||||||
for assettype in IDENTIFIER_ASSETTYPES
|
for assettype in IDENTIFIER_ASSETTYPES
|
||||||
]
|
]
|
||||||
|
|
||||||
defaults = identifierdefaults + [
|
# Per-domain global-search toggles (search_<type>_enabled).
|
||||||
|
searchdefaults = [
|
||||||
|
{
|
||||||
|
'key': f'search_{key}_enabled',
|
||||||
|
'value': 'true',
|
||||||
|
'valuetype': 'boolean',
|
||||||
|
'category': 'search',
|
||||||
|
'description': f'Include {label} in global search results',
|
||||||
|
}
|
||||||
|
for key, label in SEARCH_DOMAINS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
defaults = identifierdefaults + searchdefaults + [
|
||||||
# Zabbix integration
|
# Zabbix integration
|
||||||
{
|
{
|
||||||
'key': 'zabbix_enabled',
|
'key': 'zabbix_enabled',
|
||||||
|
|||||||
44
tests/test_core/test_search_settings.py
Normal file
44
tests/test_core/test_search_settings.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Per-domain global-search toggles (search_<type>_enabled).
|
||||||
|
|
||||||
|
A content type can be hidden from search independently of whether its plugin is
|
||||||
|
enabled, by setting search_<type>_enabled = false.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.core.api.settings import SEARCH_DOMAINS
|
||||||
|
|
||||||
|
|
||||||
|
def _kb_types(client, auth_headers, term):
|
||||||
|
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return [r for r in resp.get_json()['data']['results']
|
||||||
|
if r.get('type') == 'knowledgebase']
|
||||||
|
|
||||||
|
|
||||||
|
def test_domain_included_by_default(client, db, auth_headers):
|
||||||
|
"""With no toggle set, the knowledgebase domain appears in search."""
|
||||||
|
client.post('/api/knowledgebase',
|
||||||
|
json={'shortdescription': 'searchtoggle alpha',
|
||||||
|
'linkurl': 'https://kb.example/a'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert _kb_types(client, auth_headers, 'searchtoggle')
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_domain_excluded(client, db, auth_headers):
|
||||||
|
"""search_knowledgebase_enabled=false hides KB results."""
|
||||||
|
from shopdb.core.models import Setting
|
||||||
|
client.post('/api/knowledgebase',
|
||||||
|
json={'shortdescription': 'searchtoggle beta',
|
||||||
|
'linkurl': 'https://kb.example/b'},
|
||||||
|
headers=auth_headers)
|
||||||
|
Setting.set('search_knowledgebase_enabled', False, valuetype='boolean',
|
||||||
|
category='search')
|
||||||
|
assert _kb_types(client, auth_headers, 'searchtoggle') == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_seed_creates_domain_keys(client, db, auth_headers):
|
||||||
|
"""Seeding produces one boolean setting per search domain."""
|
||||||
|
resp = client.post('/api/settings/seed', headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
from shopdb.core.models import Setting
|
||||||
|
keys = {s.key for s in Setting.query.filter_by(category='search').all()}
|
||||||
|
assert {f'search_{k}_enabled' for k in SEARCH_DOMAINS} <= keys
|
||||||
Reference in New Issue
Block a user