1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Closes #14134: Display additional object attributes in global search results (#14154)

* WIP

* Add display_attrs for all indexers

* Linkify object attributes

* Clean up prefetch logic

* Use tooltips for display attributes

* Simplify template code

* Introduce get_indexer() utility function

* Add  to examples in docs

* Use tooltips to display long strings
This commit is contained in:
Jeremy Stretch
2023-11-09 16:21:09 -05:00
committed by GitHub
parent 2562c8745c
commit 3d20276f55
15 changed files with 165 additions and 7 deletions

View File

@@ -10,6 +10,7 @@ class CircuitIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('provider', 'provider_account', 'type', 'status', 'tenant', 'description')
@register_search
@@ -22,6 +23,7 @@ class CircuitTerminationIndex(SearchIndex):
('port_speed', 2000),
('upstream_speed', 2000),
)
display_attrs = ('circuit', 'site', 'provider_network', 'description')
@register_search
@@ -32,6 +34,7 @@ class CircuitTypeIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -42,6 +45,7 @@ class ProviderIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
class ProviderAccountIndex(SearchIndex):
@@ -51,6 +55,7 @@ class ProviderAccountIndex(SearchIndex):
('account', 200),
('comments', 5000),
)
display_attrs = ('provider', 'account', 'description')
@register_search
@@ -62,3 +67,4 @@ class ProviderNetworkIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('provider', 'service_id', 'description')

View File

@@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('type', 'status', 'description')
@register_search

View File

@@ -10,6 +10,7 @@ class CableIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('type', 'status', 'tenant', 'label', 'description')
@register_search
@@ -21,6 +22,7 @@ class ConsolePortIndex(SearchIndex):
('description', 500),
('speed', 2000),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -32,6 +34,7 @@ class ConsoleServerPortIndex(SearchIndex):
('description', 500),
('speed', 2000),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -44,6 +47,9 @@ class DeviceIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = (
'site', 'location', 'rack', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'description',
)
@register_search
@@ -54,6 +60,7 @@ class DeviceBayIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -64,6 +71,7 @@ class DeviceRoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -75,6 +83,7 @@ class DeviceTypeIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('manufacturer', 'part_number', 'description')
@register_search
@@ -85,6 +94,7 @@ class FrontPortIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -99,6 +109,7 @@ class InterfaceIndex(SearchIndex):
('mtu', 2000),
('speed', 2000),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -112,6 +123,7 @@ class InventoryItemIndex(SearchIndex):
('description', 500),
('part_id', 2000),
)
display_attrs = ('device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
@register_search
@@ -122,6 +134,7 @@ class LocationIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('site', 'status', 'tenant', 'description')
@register_search
@@ -132,6 +145,7 @@ class ManufacturerIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -143,6 +157,7 @@ class ModuleIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'description')
@register_search
@@ -153,6 +168,7 @@ class ModuleBayIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'position', 'description')
@register_search
@@ -164,6 +180,7 @@ class ModuleTypeIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('manufacturer', 'model', 'part_number', 'description')
@register_search
@@ -174,6 +191,7 @@ class PlatformIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('manufacturer', 'description')
@register_search
@@ -184,6 +202,7 @@ class PowerFeedIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('power_panel', 'rack', 'status', 'description')
@register_search
@@ -194,6 +213,7 @@ class PowerOutletIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -204,6 +224,7 @@ class PowerPanelIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'location', 'description')
@register_search
@@ -216,6 +237,7 @@ class PowerPortIndex(SearchIndex):
('maximum_draw', 2000),
('allocated_draw', 2000),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -229,6 +251,7 @@ class RackIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'location', 'facility_id', 'tenant', 'status', 'role', 'description')
@register_search
@@ -238,6 +261,7 @@ class RackReservationIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('rack', 'tenant', 'user', 'description')
@register_search
@@ -248,6 +272,7 @@ class RackRoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('device', 'label', 'description',)
@register_search
@@ -258,6 +283,7 @@ class RearPortIndex(SearchIndex):
('label', 200),
('description', 500),
)
display_attrs = ('device', 'label', 'description')
@register_search
@@ -268,6 +294,7 @@ class RegionIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('parent', 'description')
@register_search
@@ -282,6 +309,7 @@ class SiteIndex(SearchIndex):
('shipping_address', 2000),
('comments', 5000),
)
display_attrs = ('region', 'group', 'status', 'description')
@register_search
@@ -292,6 +320,7 @@ class SiteGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('parent', 'description')
@register_search
@@ -303,6 +332,7 @@ class VirtualChassisIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('master', 'domain', 'description')
@register_search
@@ -314,3 +344,4 @@ class VirtualDeviceContextIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('device', 'status', 'identifier', 'description')

View File

@@ -4,7 +4,10 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext_lazy as _
from netbox.search.utils import get_indexer
from netbox.registry import registry
from utilities.fields import RestrictedGenericForeignKey
from utilities.utils import content_type_identifier
from ..fields import CachedValueField
__all__ = (
@@ -58,3 +61,19 @@ class CachedValue(models.Model):
def __str__(self):
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
@property
def display_attrs(self):
"""
Render any display attributes associated with this search result.
"""
indexer = get_indexer(self.object_type)
attrs = {}
for attr in indexer.display_attrs:
name = self.object._meta.get_field(attr).verbose_name
if value := getattr(self.object, attr):
if display_func := getattr(self.object, f'get_{attr}_display', None):
attrs[name] = display_func()
else:
attrs[name] = value
return attrs

View File

@@ -11,6 +11,7 @@ class AggregateIndex(SearchIndex):
('date_added', 2000),
('comments', 5000),
)
display_attrs = ('rir', 'tenant', 'description')
@register_search
@@ -20,6 +21,7 @@ class ASNIndex(SearchIndex):
('asn', 100),
('description', 500),
)
display_attrs = ('rir', 'tenant', 'description')
@register_search
@@ -28,6 +30,7 @@ class ASNRangeIndex(SearchIndex):
fields = (
('description', 500),
)
display_attrs = ('rir', 'tenant', 'description')
@register_search
@@ -39,6 +42,7 @@ class FHRPGroupIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('protocol', 'auth_type', 'description')
@register_search
@@ -50,6 +54,7 @@ class IPAddressIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
@register_search
@@ -61,6 +66,7 @@ class IPRangeIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('vrf', 'tenant', 'status', 'role', 'description')
@register_search
@@ -72,6 +78,7 @@ class L2VPNIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('type', 'identifier', 'tenant', 'description')
@register_search
@@ -82,6 +89,7 @@ class PrefixIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description')
@register_search
@@ -92,6 +100,7 @@ class RIRIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -102,6 +111,7 @@ class RoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -112,6 +122,7 @@ class RouteTargetIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('tenant', 'description')
@register_search
@@ -122,6 +133,7 @@ class ServiceIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('device', 'virtual_machine', 'description')
@register_search
@@ -132,6 +144,7 @@ class ServiceTemplateIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('description',)
@register_search
@@ -143,6 +156,7 @@ class VLANIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'group', 'tenant', 'status', 'role', 'description')
@register_search
@@ -154,6 +168,7 @@ class VLANGroupIndex(SearchIndex):
('description', 500),
('max_vid', 2000),
)
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
@register_search
@@ -165,3 +180,4 @@ class VRFIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('rd', 'tenant', 'description')

View File

@@ -33,10 +33,12 @@ class SearchIndex:
category: The label of the group under which this indexer is categorized (for form field display). If none,
the name of the model's app will be used.
fields: An iterable of two-tuples defining the model fields to be indexed and the weight associated with each.
display_attrs: An iterable of additional object attributes to include when displaying search results.
"""
model = None
category = None
fields = ()
display_attrs = ()
@staticmethod
def get_field_type(instance, field_name):

View File

@@ -3,7 +3,8 @@ from collections import defaultdict
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured
from django.db.models import F, Window, Q
from django.db.models import F, Window, Q, prefetch_related_objects
from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window
from django.db.models.signals import post_delete, post_save
from django.utils.module_loading import import_string
@@ -13,7 +14,7 @@ from netaddr.core import AddrFormatError
from extras.models import CachedValue, CustomField
from netbox.registry import registry
from utilities.querysets import RestrictedPrefetch
from utilities.utils import title
from utilities.utils import content_type_identifier, title
from . import FieldTypes, LookupTypes, get_indexer
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
@@ -103,17 +104,17 @@ class CachedValueSearchBackend(SearchBackend):
def search(self, value, user=None, object_types=None, lookup=DEFAULT_LOOKUP_TYPE):
# Build the filter used to find relevant CachedValue records
query_filter = Q(**{f'value__{lookup}': value})
if object_types:
# Limit results by object type
query_filter &= Q(object_type__in=object_types)
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
# Partial string matches are valid only on string values
# "Starts/ends with" matches are valid only on string values
query_filter &= Q(type=FieldTypes.STRING)
if lookup == LookupTypes.PARTIAL:
elif lookup == LookupTypes.PARTIAL:
try:
# If the value looks like an IP address, add an extra match for CIDR values
address = str(netaddr.IPNetwork(value.strip()).cidr)
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
except (AddrFormatError, ValueError):
@@ -129,6 +130,12 @@ class CachedValueSearchBackend(SearchBackend):
)
)[:MAX_RESULTS]
# Gather all ContentTypes present in the search results (used for prefetching related
# objects). This must be done before generating the final results list, which returns
# a RawQuerySet.
content_type_ids = set(queryset.values_list('object_type', flat=True))
content_types = ContentType.objects.filter(pk__in=content_type_ids)
# Construct a Prefetch to pre-fetch only those related objects for which the
# user has permission to view.
if user:
@@ -144,12 +151,34 @@ class CachedValueSearchBackend(SearchBackend):
params
)
# Iterate through each ContentType represented in the search results and prefetch any
# related objects necessary to render the prescribed display attributes (display_attrs).
for ct in content_types:
model = ct.model_class()
indexer = registry['search'].get(content_type_identifier(ct))
if not (display_attrs := getattr(indexer, 'display_attrs', None)):
continue
# Add ForeignKey fields to prefetch list
prefetch_fields = []
for attr in display_attrs:
field = model._meta.get_field(attr)
if type(field) is ForeignKey:
prefetch_fields.append(f'object__{attr}')
# Compile a list of all CachedValues referencing this object type, and prefetch
# any related objects
if prefetch_fields:
objects = [r for r in results if r.object_type == ct]
prefetch_related_objects(objects, *prefetch_fields)
# Omit any results pertaining to an object the user does not have permission to view
ret = []
for r in results:
if r.object is not None:
r.name = str(r.object)
ret.append(r)
return ret
def cache(self, instances, indexer=None, remove_existing=True):

View File

@@ -0,0 +1,14 @@
from netbox.registry import registry
from utilities.utils import content_type_identifier
__all__ = (
'get_indexer',
)
def get_indexer(content_type):
"""
Return the registered search indexer for the given ContentType.
"""
ct_identifier = content_type_identifier(content_type)
return registry['search'].get(ct_identifier)

View File

@@ -15,6 +15,7 @@ from extras.choices import CustomFieldVisibilityChoices
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import get_viewname, highlight_string, title
from .template_code import *
__all__ = (
'BaseTable',
@@ -236,6 +237,10 @@ class SearchTable(tables.Table):
value = tables.Column(
verbose_name=_('Value'),
)
attrs = columns.TemplateColumn(
template_code=SEARCH_RESULT_ATTRS,
verbose_name=_('Attributes')
)
trim_length = 30

View File

@@ -0,0 +1,18 @@
SEARCH_RESULT_ATTRS = """
{% for name, value in record.display_attrs.items %}
<span class="badge bg-secondary"
{% if value|length > 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %}
>
{{ name|bettertitle }}:
{% with url=value.get_absolute_url %}
{% if url %}<a href="url">{% endif %}
{% if value|length > 40 %}
{{ value|truncatechars:"40" }}
{% else %}
{{ value }}
{% endif %}
{% if url %}</a>{% endif %}
{% endwith %}
</span>
{% endfor %}
"""

View File

@@ -15,6 +15,7 @@ class ContactIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('group', 'title', 'phone', 'email', 'description')
@register_search
@@ -25,6 +26,7 @@ class ContactGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -35,6 +37,7 @@ class ContactRoleIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -46,6 +49,7 @@ class TenantIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('group', 'description')
@register_search
@@ -56,3 +60,4 @@ class TenantGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)

View File

@@ -10,6 +10,7 @@ class ClusterIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('type', 'group', 'status', 'tenant', 'site', 'description')
@register_search
@@ -20,6 +21,7 @@ class ClusterGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -30,6 +32,7 @@ class ClusterTypeIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -40,6 +43,7 @@ class VirtualMachineIndex(SearchIndex):
('description', 500),
('comments', 5000),
)
display_attrs = ('site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'description')
@register_search
@@ -51,3 +55,4 @@ class VMInterfaceIndex(SearchIndex):
('description', 500),
('mtu', 2000),
)
display_attrs = ('virtual_machine', 'description')

View File

@@ -11,6 +11,7 @@ class WirelessLANIndex(SearchIndex):
('auth_psk', 2000),
('comments', 5000),
)
display_attrs = ('group', 'status', 'vlan', 'tenant', 'description')
@register_search
@@ -21,6 +22,7 @@ class WirelessLANGroupIndex(SearchIndex):
('slug', 110),
('description', 500),
)
display_attrs = ('description',)
@register_search
@@ -32,3 +34,4 @@ class WirelessLinkIndex(SearchIndex):
('auth_psk', 2000),
('comments', 5000),
)
display_attrs = ('status', 'tenant', 'description')