diff --git a/docs/development/search.md b/docs/development/search.md index 6ccffa7af..1c4eec169 100644 --- a/docs/development/search.md +++ b/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. diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md index e3b861f00..e54844cf0 100644 --- a/docs/plugins/development/search.md +++ b/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 diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py index b80f92d4d..c22b400eb 100644 --- a/netbox/circuits/search.py +++ b/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') diff --git a/netbox/core/search.py b/netbox/core/search.py index e6d3005e6..5ea9db761 100644 --- a/netbox/core/search.py +++ b/netbox/core/search.py @@ -11,6 +11,7 @@ class DataSourceIndex(SearchIndex): ('description', 500), ('comments', 5000), ) + display_attrs = ('type', 'status', 'description') @register_search diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index f70c729f4..0784cfaf8 100644 --- a/netbox/dcim/search.py +++ b/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') diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index b3327d510..bebcabd31 100644 --- a/netbox/extras/models/search.py +++ b/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 diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index 4d97bf5f0..c08acce1b 100644 --- a/netbox/ipam/search.py +++ b/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') diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 6d53e9a97..590188f21 100644 --- a/netbox/netbox/search/__init__.py +++ b/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): diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 4487b6bb8..1fb23a37c 100644 --- a/netbox/netbox/search/backends.py +++ b/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): diff --git a/netbox/netbox/search/utils.py b/netbox/netbox/search/utils.py new file mode 100644 index 000000000..824fbfb3d --- /dev/null +++ b/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) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 97ab44362..cb53310cc 100644 --- a/netbox/netbox/tables/tables.py +++ b/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 diff --git a/netbox/netbox/tables/template_code.py b/netbox/netbox/tables/template_code.py new file mode 100644 index 000000000..24439eeb6 --- /dev/null +++ b/netbox/netbox/tables/template_code.py @@ -0,0 +1,18 @@ +SEARCH_RESULT_ATTRS = """ +{% for name, value in record.display_attrs.items %} + 40 %} data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ value }}"{% endif %} + > + {{ name|bettertitle }}: + {% with url=value.get_absolute_url %} + {% if url %}{% endif %} + {% if value|length > 40 %} + {{ value|truncatechars:"40" }} + {% else %} + {{ value }} + {% endif %} + {% if url %}{% endif %} + {% endwith %} + +{% endfor %} +""" diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py index bee497608..56903d6b1 100644 --- a/netbox/tenancy/search.py +++ b/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',) diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py index 643a9f6de..12174dda4 100644 --- a/netbox/virtualization/search.py +++ b/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') diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py index 1f8097cd7..c8ac023cc 100644 --- a/netbox/wireless/search.py +++ b/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')