ソースを参照

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
Jeremy Stretch 2 年 前
コミット
3d20276f55

+ 1 - 0
docs/development/search.md

@@ -17,6 +17,7 @@ class MyModelIndex(SearchIndex):
         ('description', 500),
         ('comments', 5000),
     )
+    display_attrs = ('site', 'device', 'status', 'description')
 ```
 
 A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.

+ 3 - 0
docs/plugins/development/search.md

@@ -14,8 +14,11 @@ class MyModelIndex(SearchIndex):
         ('description', 500),
         ('comments', 5000),
     )
+    display_attrs = ('site', 'device', 'status', 'description')
 ```
 
+Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
+
 To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
 
 ```python

+ 6 - 0
netbox/circuits/search.py

@@ -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')

+ 1 - 0
netbox/core/search.py

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

+ 31 - 0
netbox/dcim/search.py

@@ -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')

+ 19 - 0
netbox/extras/models/search.py

@@ -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

+ 16 - 0
netbox/ipam/search.py

@@ -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')

+ 2 - 0
netbox/netbox/search/__init__.py

@@ -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):

+ 36 - 7
netbox/netbox/search/backends.py

@@ -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):

+ 14 - 0
netbox/netbox/search/utils.py

@@ -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)

+ 5 - 0
netbox/netbox/tables/tables.py

@@ -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
 

+ 18 - 0
netbox/netbox/tables/template_code.py

@@ -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 %}
+"""

+ 5 - 0
netbox/tenancy/search.py

@@ -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',)

+ 5 - 0
netbox/virtualization/search.py

@@ -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')

+ 3 - 0
netbox/wireless/search.py

@@ -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')