Преглед изворни кода

8927 plugin search (#10489)

* #7016 base search classes

* 7016 add search indexes

* 7016 add search indexes

* 7016 add search indexes

* 7016 add search indexes

* 7016 add search indexes

* 7016 add search indexes

* 8927 refactor search

* 8927 refactor search

* 8927 refactor search

* 8927 refactor search

* 8927 get search choices working

* 8927 cleanup - optimize

* 8927 use backend search function

* 8927 fix for plugin search

* 8927 add docs

* Move search app to a module under netbox/

* Utilize global registry to register model search classes

* Build search form options from registry

* Determine search categories from model app by default

* Enable dynamic search registration for plugins

* Update docs & improve plugin support

* Clean up search backend class

* Docs for #8927

Co-authored-by: jeremystretch <jstretch@ns1.com>
Arthur Hanson пре 3 година
родитељ
комит
ffce5d968d

+ 1 - 0
docs/plugins/development/index.md

@@ -108,6 +108,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 | `max_version`         | Maximum version of NetBox with which the plugin is compatible                                                            |
 | `max_version`         | Maximum version of NetBox with which the plugin is compatible                                                            |
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                |
 | `middleware`          | A list of middleware classes to append after NetBox's build-in middleware                                                |
 | `queues`              | A list of custom background task queues to create                                                                        |
 | `queues`              | A list of custom background task queues to create                                                                        |
+| `search_extensions`   | The dotted path to the list of search index classes (default: `search.indexes`)                                          |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)              |
 | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`)              |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                      |
 | `menu_items`          | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`)                      |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                 |
 | `graphql_schema`      | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`)                                 |

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

@@ -0,0 +1,29 @@
+# Search
+
+Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
+
+```python
+# search.py
+from netbox.search import SearchMixin
+from .filters import MyModelFilterSet
+from .tables import MyModelTable
+from .models import MyModel
+
+class MyModelIndex(SearchMixin):
+    model = MyModel
+    queryset = MyModel.objects.all()
+    filterset = MyModelFilterSet
+    table = MyModelTable
+    url = 'plugins:myplugin:mymodel_list'
+```
+
+To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
+
+```python
+indexes = [MyModelIndex]
+```
+
+!!! tip
+    The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
+
+::: netbox.search.SearchIndex

+ 1 - 0
docs/release-notes/version-3.4.md

@@ -26,6 +26,7 @@ A new `PluginMenu` class has been introduced, which enables a plugin to inject a
 
 
 ### Plugins API
 ### Plugins API
 
 
+* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
 * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
 * [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
 * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
 * [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
 * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
 * [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter

+ 1 - 0
mkdocs.yml

@@ -132,6 +132,7 @@ nav:
             - GraphQL API: 'plugins/development/graphql-api.md'
             - GraphQL API: 'plugins/development/graphql-api.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Background Tasks: 'plugins/development/background-tasks.md'
             - Exceptions: 'plugins/development/exceptions.md'
             - Exceptions: 'plugins/development/exceptions.md'
+            - Search: 'plugins/development/search.md'
     - Administration:
     - Administration:
         - Authentication:
         - Authentication:
             - Overview: 'administration/authentication/overview.md'
             - Overview: 'administration/authentication/overview.md'

+ 1 - 1
netbox/circuits/apps.py

@@ -6,4 +6,4 @@ class CircuitsConfig(AppConfig):
     verbose_name = "Circuits"
     verbose_name = "Circuits"
 
 
     def ready(self):
     def ready(self):
-        import circuits.signals
+        from . import signals, search

+ 34 - 0
netbox/circuits/search.py

@@ -0,0 +1,34 @@
+import circuits.filtersets
+import circuits.tables
+from circuits.models import Circuit, Provider, ProviderNetwork
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+
+
+@register_search()
+class ProviderIndex(SearchIndex):
+    model = Provider
+    queryset = Provider.objects.annotate(count_circuits=count_related(Circuit, 'provider'))
+    filterset = circuits.filtersets.ProviderFilterSet
+    table = circuits.tables.ProviderTable
+    url = 'circuits:provider_list'
+
+
+@register_search()
+class CircuitIndex(SearchIndex):
+    model = Circuit
+    queryset = Circuit.objects.prefetch_related(
+        'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
+    )
+    filterset = circuits.filtersets.CircuitFilterSet
+    table = circuits.tables.CircuitTable
+    url = 'circuits:circuit_list'
+
+
+@register_search()
+class ProviderNetworkIndex(SearchIndex):
+    model = ProviderNetwork
+    queryset = ProviderNetwork.objects.prefetch_related('provider')
+    filterset = circuits.filtersets.ProviderNetworkFilterSet
+    table = circuits.tables.ProviderNetworkTable
+    url = 'circuits:providernetwork_list'

+ 1 - 1
netbox/dcim/apps.py

@@ -8,7 +8,7 @@ class DCIMConfig(AppConfig):
     verbose_name = "DCIM"
     verbose_name = "DCIM"
 
 
     def ready(self):
     def ready(self):
-        import dcim.signals
+        from . import signals, search
         from .models import CableTermination
         from .models import CableTermination
 
 
         # Register denormalized fields
         # Register denormalized fields

+ 143 - 0
netbox/dcim/search.py

@@ -0,0 +1,143 @@
+import dcim.filtersets
+import dcim.tables
+from dcim.models import (
+    Cable,
+    Device,
+    DeviceType,
+    Location,
+    Module,
+    ModuleType,
+    PowerFeed,
+    Rack,
+    RackReservation,
+    Site,
+    VirtualChassis,
+)
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+
+
+@register_search()
+class SiteIndex(SearchIndex):
+    model = Site
+    queryset = Site.objects.prefetch_related('region', 'tenant', 'tenant__group')
+    filterset = dcim.filtersets.SiteFilterSet
+    table = dcim.tables.SiteTable
+    url = 'dcim:site_list'
+
+
+@register_search()
+class RackIndex(SearchIndex):
+    model = Rack
+    queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
+        device_count=count_related(Device, 'rack')
+    )
+    filterset = dcim.filtersets.RackFilterSet
+    table = dcim.tables.RackTable
+    url = 'dcim:rack_list'
+
+
+@register_search()
+class RackReservationIndex(SearchIndex):
+    model = RackReservation
+    queryset = RackReservation.objects.prefetch_related('rack', 'user')
+    filterset = dcim.filtersets.RackReservationFilterSet
+    table = dcim.tables.RackReservationTable
+    url = 'dcim:rackreservation_list'
+
+
+@register_search()
+class LocationIndex(SearchIndex):
+    model = Location
+    queryset = Location.objects.add_related_count(
+        Location.objects.add_related_count(Location.objects.all(), Device, 'location', 'device_count', cumulative=True),
+        Rack,
+        'location',
+        'rack_count',
+        cumulative=True,
+    ).prefetch_related('site')
+    filterset = dcim.filtersets.LocationFilterSet
+    table = dcim.tables.LocationTable
+    url = 'dcim:location_list'
+
+
+@register_search()
+class DeviceTypeIndex(SearchIndex):
+    model = DeviceType
+    queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(
+        instance_count=count_related(Device, 'device_type')
+    )
+    filterset = dcim.filtersets.DeviceTypeFilterSet
+    table = dcim.tables.DeviceTypeTable
+    url = 'dcim:devicetype_list'
+
+
+@register_search()
+class DeviceIndex(SearchIndex):
+    model = Device
+    queryset = Device.objects.prefetch_related(
+        'device_type__manufacturer',
+        'device_role',
+        'tenant',
+        'tenant__group',
+        'site',
+        'rack',
+        'primary_ip4',
+        'primary_ip6',
+    )
+    filterset = dcim.filtersets.DeviceFilterSet
+    table = dcim.tables.DeviceTable
+    url = 'dcim:device_list'
+
+
+@register_search()
+class ModuleTypeIndex(SearchIndex):
+    model = ModuleType
+    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+        instance_count=count_related(Module, 'module_type')
+    )
+    filterset = dcim.filtersets.ModuleTypeFilterSet
+    table = dcim.tables.ModuleTypeTable
+    url = 'dcim:moduletype_list'
+
+
+@register_search()
+class ModuleIndex(SearchIndex):
+    model = Module
+    queryset = Module.objects.prefetch_related(
+        'module_type__manufacturer',
+        'device',
+        'module_bay',
+    )
+    filterset = dcim.filtersets.ModuleFilterSet
+    table = dcim.tables.ModuleTable
+    url = 'dcim:module_list'
+
+
+@register_search()
+class VirtualChassisIndex(SearchIndex):
+    model = VirtualChassis
+    queryset = VirtualChassis.objects.prefetch_related('master').annotate(
+        member_count=count_related(Device, 'virtual_chassis')
+    )
+    filterset = dcim.filtersets.VirtualChassisFilterSet
+    table = dcim.tables.VirtualChassisTable
+    url = 'dcim:virtualchassis_list'
+
+
+@register_search()
+class CableIndex(SearchIndex):
+    model = Cable
+    queryset = Cable.objects.all()
+    filterset = dcim.filtersets.CableFilterSet
+    table = dcim.tables.CableTable
+    url = 'dcim:cable_list'
+
+
+@register_search()
+class PowerFeedIndex(SearchIndex):
+    model = PowerFeed
+    queryset = PowerFeed.objects.all()
+    filterset = dcim.filtersets.PowerFeedFilterSet
+    table = dcim.tables.PowerFeedTable
+    url = 'dcim:powerfeed_list'

+ 1 - 2
netbox/extras/apps.py

@@ -5,5 +5,4 @@ class ExtrasConfig(AppConfig):
     name = "extras"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
-        import extras.lookups
-        import extras.signals
+        from . import lookups, search, signals

+ 7 - 0
netbox/extras/plugins/__init__.py

@@ -9,6 +9,7 @@ from django.template.loader import get_template
 from extras.plugins.utils import import_object
 from extras.plugins.utils import import_object
 from extras.registry import registry
 from extras.registry import registry
 from netbox.navigation import MenuGroup
 from netbox.navigation import MenuGroup
+from netbox.search import register_search
 from utilities.choices import ButtonColorChoices
 from utilities.choices import ButtonColorChoices
 
 
 
 
@@ -60,6 +61,7 @@ class PluginConfig(AppConfig):
 
 
     # Default integration paths. Plugin authors can override these to customize the paths to
     # Default integration paths. Plugin authors can override these to customize the paths to
     # integrated components.
     # integrated components.
+    search_indexes = 'search.indexes'
     graphql_schema = 'graphql.schema'
     graphql_schema = 'graphql.schema'
     menu = 'navigation.menu'
     menu = 'navigation.menu'
     menu_items = 'navigation.menu_items'
     menu_items = 'navigation.menu_items'
@@ -69,6 +71,11 @@ class PluginConfig(AppConfig):
     def ready(self):
     def ready(self):
         plugin_name = self.name.rsplit('.', 1)[-1]
         plugin_name = self.name.rsplit('.', 1)[-1]
 
 
+        # Search extensions
+        search_indexes = import_object(f"{self.__module__}.{self.search_indexes}") or []
+        for idx in search_indexes:
+            register_search()(idx)
+
         # Register template content (if defined)
         # Register template content (if defined)
         template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
         template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
         if template_extensions is not None:
         if template_extensions is not None:

+ 1 - 0
netbox/extras/registry.py

@@ -29,4 +29,5 @@ registry['model_features'] = {
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
     feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
 }
 }
 registry['denormalized_fields'] = collections.defaultdict(list)
 registry['denormalized_fields'] = collections.defaultdict(list)
+registry['search'] = collections.defaultdict(dict)
 registry['views'] = collections.defaultdict(dict)
 registry['views'] = collections.defaultdict(dict)

+ 14 - 0
netbox/extras/search.py

@@ -0,0 +1,14 @@
+import extras.filtersets
+import extras.tables
+from extras.models import JournalEntry
+from netbox.search import SearchIndex, register_search
+
+
+@register_search()
+class JournalEntryIndex(SearchIndex):
+    model = JournalEntry
+    queryset = JournalEntry.objects.prefetch_related('assigned_object', 'created_by')
+    filterset = extras.filtersets.JournalEntryFilterSet
+    table = extras.tables.JournalEntryTable
+    url = 'extras:journalentry_list'
+    category = 'Journal'

+ 13 - 0
netbox/extras/tests/dummy_plugin/search.py

@@ -0,0 +1,13 @@
+from netbox.search import SearchIndex
+from .models import DummyModel
+
+
+class DummyModelIndex(SearchIndex):
+    model = DummyModel
+    queryset = DummyModel.objects.all()
+    url = 'plugins:dummy_plugin:dummy_models'
+
+
+indexes = (
+    DummyModelIndex,
+)

+ 1 - 1
netbox/ipam/apps.py

@@ -6,4 +6,4 @@ class IPAMConfig(AppConfig):
     verbose_name = "IPAM"
     verbose_name = "IPAM"
 
 
     def ready(self):
     def ready(self):
-        import ipam.signals
+        from . import signals, search

+ 69 - 0
netbox/ipam/search.py

@@ -0,0 +1,69 @@
+import ipam.filtersets
+import ipam.tables
+from ipam.models import ASN, VLAN, VRF, Aggregate, IPAddress, Prefix, Service
+from netbox.search import SearchIndex, register_search
+
+
+@register_search()
+class VRFIndex(SearchIndex):
+    model = VRF
+    queryset = VRF.objects.prefetch_related('tenant', 'tenant__group')
+    filterset = ipam.filtersets.VRFFilterSet
+    table = ipam.tables.VRFTable
+    url = 'ipam:vrf_list'
+
+
+@register_search()
+class AggregateIndex(SearchIndex):
+    model = Aggregate
+    queryset = Aggregate.objects.prefetch_related('rir')
+    filterset = ipam.filtersets.AggregateFilterSet
+    table = ipam.tables.AggregateTable
+    url = 'ipam:aggregate_list'
+
+
+@register_search()
+class PrefixIndex(SearchIndex):
+    model = Prefix
+    queryset = Prefix.objects.prefetch_related(
+        'site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'
+    )
+    filterset = ipam.filtersets.PrefixFilterSet
+    table = ipam.tables.PrefixTable
+    url = 'ipam:prefix_list'
+
+
+@register_search()
+class IPAddressIndex(SearchIndex):
+    model = IPAddress
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group')
+    filterset = ipam.filtersets.IPAddressFilterSet
+    table = ipam.tables.IPAddressTable
+    url = 'ipam:ipaddress_list'
+
+
+@register_search()
+class VLANIndex(SearchIndex):
+    model = VLAN
+    queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role')
+    filterset = ipam.filtersets.VLANFilterSet
+    table = ipam.tables.VLANTable
+    url = 'ipam:vlan_list'
+
+
+@register_search()
+class ASNIndex(SearchIndex):
+    model = ASN
+    queryset = ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group')
+    filterset = ipam.filtersets.ASNFilterSet
+    table = ipam.tables.ASNTable
+    url = 'ipam:asn_list'
+
+
+@register_search()
+class ServiceIndex(SearchIndex):
+    model = Service
+    queryset = Service.objects.prefetch_related('device', 'virtual_machine')
+    filterset = ipam.filtersets.ServiceFilterSet
+    table = ipam.tables.ServiceTable
+    url = 'ipam:service_list'

+ 21 - 28
netbox/netbox/forms/__init__.py

@@ -1,31 +1,15 @@
 from django import forms
 from django import forms
 
 
-from netbox.search import SEARCH_TYPE_HIERARCHY
+from netbox.search.backends import default_search_engine
 from utilities.forms import BootstrapMixin
 from utilities.forms import BootstrapMixin
-from .base import *
-
-
-def build_search_choices():
-    result = list()
-    result.append(('', 'All Objects'))
-    for category, items in SEARCH_TYPE_HIERARCHY.items():
-        subcategories = list()
-        for slug, obj in items.items():
-            name = obj['queryset'].model._meta.verbose_name_plural
-            name = name[0].upper() + name[1:]
-            subcategories.append((slug, name))
-        result.append((category, tuple(subcategories)))
 
 
-    return tuple(result)
-
-
-OBJ_TYPE_CHOICES = build_search_choices()
+from .base import *
 
 
 
 
-def build_options():
-    options = [{"label": OBJ_TYPE_CHOICES[0][1], "items": []}]
+def build_options(choices):
+    options = [{"label": choices[0][1], "items": []}]
 
 
-    for label, choices in OBJ_TYPE_CHOICES[1:]:
+    for label, choices in choices[1:]:
         items = []
         items = []
 
 
         for value, choice_label in choices:
         for value, choice_label in choices:
@@ -36,10 +20,19 @@ def build_options():
 
 
 
 
 class SearchForm(BootstrapMixin, forms.Form):
 class SearchForm(BootstrapMixin, forms.Form):
-    q = forms.CharField(
-        label='Search'
-    )
-    obj_type = forms.ChoiceField(
-        choices=OBJ_TYPE_CHOICES, required=False, label='Type'
-    )
-    options = build_options()
+    q = forms.CharField(label='Search')
+    options = None
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields["obj_type"] = forms.ChoiceField(
+            choices=default_search_engine.get_search_choices(),
+            required=False,
+            label='Type'
+        )
+
+    def get_options(self):
+        if not self.options:
+            self.options = build_options(default_search_engine.get_search_choices())
+
+        return self.options

+ 0 - 274
netbox/netbox/search.py

@@ -1,274 +0,0 @@
-import circuits.filtersets
-import circuits.tables
-import dcim.filtersets
-import dcim.tables
-import extras.filtersets
-import extras.tables
-import ipam.filtersets
-import ipam.tables
-import tenancy.filtersets
-import tenancy.tables
-import virtualization.filtersets
-import wireless.tables
-import wireless.filtersets
-import virtualization.tables
-from circuits.models import Circuit, ProviderNetwork, Provider
-from dcim.models import (
-    Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site,
-    VirtualChassis,
-)
-from extras.models import JournalEntry
-from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
-from tenancy.models import Contact, Tenant, ContactAssignment
-from utilities.utils import count_related
-from wireless.models import WirelessLAN, WirelessLink
-from virtualization.models import Cluster, VirtualMachine
-
-CIRCUIT_TYPES = {
-    'provider': {
-        'queryset': Provider.objects.annotate(
-            count_circuits=count_related(Circuit, 'provider')
-        ),
-        'filterset': circuits.filtersets.ProviderFilterSet,
-        'table': circuits.tables.ProviderTable,
-        'url': 'circuits:provider_list',
-    },
-    'circuit': {
-        'queryset': Circuit.objects.prefetch_related(
-            'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
-        ),
-        'filterset': circuits.filtersets.CircuitFilterSet,
-        'table': circuits.tables.CircuitTable,
-        'url': 'circuits:circuit_list',
-    },
-    'providernetwork': {
-        'queryset': ProviderNetwork.objects.prefetch_related('provider'),
-        'filterset': circuits.filtersets.ProviderNetworkFilterSet,
-        'table': circuits.tables.ProviderNetworkTable,
-        'url': 'circuits:providernetwork_list',
-    },
-}
-
-DCIM_TYPES = {
-    'site': {
-        'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'),
-        'filterset': dcim.filtersets.SiteFilterSet,
-        'table': dcim.tables.SiteTable,
-        'url': 'dcim:site_list',
-    },
-    'rack': {
-        'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
-            device_count=count_related(Device, 'rack')
-        ),
-        'filterset': dcim.filtersets.RackFilterSet,
-        'table': dcim.tables.RackTable,
-        'url': 'dcim:rack_list',
-    },
-    'rackreservation': {
-        'queryset': RackReservation.objects.prefetch_related('rack', 'user'),
-        'filterset': dcim.filtersets.RackReservationFilterSet,
-        'table': dcim.tables.RackReservationTable,
-        'url': 'dcim:rackreservation_list',
-    },
-    'location': {
-        'queryset': Location.objects.add_related_count(
-            Location.objects.add_related_count(
-                Location.objects.all(),
-                Device,
-                'location',
-                'device_count',
-                cumulative=True
-            ),
-            Rack,
-            'location',
-            'rack_count',
-            cumulative=True
-        ).prefetch_related('site'),
-        'filterset': dcim.filtersets.LocationFilterSet,
-        'table': dcim.tables.LocationTable,
-        'url': 'dcim:location_list',
-    },
-    'devicetype': {
-        'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
-            instance_count=count_related(Device, 'device_type')
-        ),
-        'filterset': dcim.filtersets.DeviceTypeFilterSet,
-        'table': dcim.tables.DeviceTypeTable,
-        'url': 'dcim:devicetype_list',
-    },
-    'device': {
-        'queryset': Device.objects.prefetch_related(
-            'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4',
-            'primary_ip6',
-        ),
-        'filterset': dcim.filtersets.DeviceFilterSet,
-        'table': dcim.tables.DeviceTable,
-        'url': 'dcim:device_list',
-    },
-    'moduletype': {
-        'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
-            instance_count=count_related(Module, 'module_type')
-        ),
-        'filterset': dcim.filtersets.ModuleTypeFilterSet,
-        'table': dcim.tables.ModuleTypeTable,
-        'url': 'dcim:moduletype_list',
-    },
-    'module': {
-        'queryset': Module.objects.prefetch_related(
-            'module_type__manufacturer', 'device', 'module_bay',
-        ),
-        'filterset': dcim.filtersets.ModuleFilterSet,
-        'table': dcim.tables.ModuleTable,
-        'url': 'dcim:module_list',
-    },
-    'virtualchassis': {
-        'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
-            member_count=count_related(Device, 'virtual_chassis')
-        ),
-        'filterset': dcim.filtersets.VirtualChassisFilterSet,
-        'table': dcim.tables.VirtualChassisTable,
-        'url': 'dcim:virtualchassis_list',
-    },
-    'cable': {
-        'queryset': Cable.objects.all(),
-        'filterset': dcim.filtersets.CableFilterSet,
-        'table': dcim.tables.CableTable,
-        'url': 'dcim:cable_list',
-    },
-    'powerfeed': {
-        'queryset': PowerFeed.objects.all(),
-        'filterset': dcim.filtersets.PowerFeedFilterSet,
-        'table': dcim.tables.PowerFeedTable,
-        'url': 'dcim:powerfeed_list',
-    },
-}
-
-IPAM_TYPES = {
-    'vrf': {
-        'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'),
-        'filterset': ipam.filtersets.VRFFilterSet,
-        'table': ipam.tables.VRFTable,
-        'url': 'ipam:vrf_list',
-    },
-    'aggregate': {
-        'queryset': Aggregate.objects.prefetch_related('rir'),
-        'filterset': ipam.filtersets.AggregateFilterSet,
-        'table': ipam.tables.AggregateTable,
-        'url': 'ipam:aggregate_list',
-    },
-    'prefix': {
-        'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'),
-        'filterset': ipam.filtersets.PrefixFilterSet,
-        'table': ipam.tables.PrefixTable,
-        'url': 'ipam:prefix_list',
-    },
-    'ipaddress': {
-        'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'),
-        'filterset': ipam.filtersets.IPAddressFilterSet,
-        'table': ipam.tables.IPAddressTable,
-        'url': 'ipam:ipaddress_list',
-    },
-    'vlan': {
-        'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'),
-        'filterset': ipam.filtersets.VLANFilterSet,
-        'table': ipam.tables.VLANTable,
-        'url': 'ipam:vlan_list',
-    },
-    'asn': {
-        'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'),
-        'filterset': ipam.filtersets.ASNFilterSet,
-        'table': ipam.tables.ASNTable,
-        'url': 'ipam:asn_list',
-    },
-    'service': {
-        'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
-        'filterset': ipam.filtersets.ServiceFilterSet,
-        'table': ipam.tables.ServiceTable,
-        'url': 'ipam:service_list',
-    },
-}
-
-TENANCY_TYPES = {
-    'tenant': {
-        'queryset': Tenant.objects.prefetch_related('group'),
-        'filterset': tenancy.filtersets.TenantFilterSet,
-        'table': tenancy.tables.TenantTable,
-        'url': 'tenancy:tenant_list',
-    },
-    'contact': {
-        'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
-            assignment_count=count_related(ContactAssignment, 'contact')),
-        'filterset': tenancy.filtersets.ContactFilterSet,
-        'table': tenancy.tables.ContactTable,
-        'url': 'tenancy:contact_list',
-    },
-}
-
-VIRTUALIZATION_TYPES = {
-    'cluster': {
-        'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
-            device_count=count_related(Device, 'cluster'),
-            vm_count=count_related(VirtualMachine, 'cluster')
-        ),
-        'filterset': virtualization.filtersets.ClusterFilterSet,
-        'table': virtualization.tables.ClusterTable,
-        'url': 'virtualization:cluster_list',
-    },
-    'virtualmachine': {
-        'queryset': VirtualMachine.objects.prefetch_related(
-            'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6',
-        ),
-        'filterset': virtualization.filtersets.VirtualMachineFilterSet,
-        'table': virtualization.tables.VirtualMachineTable,
-        'url': 'virtualization:virtualmachine_list',
-    },
-}
-
-WIRELESS_TYPES = {
-    'wirelesslan': {
-        'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
-            interface_count=count_related(Interface, 'wireless_lans')
-        ),
-        'filterset': wireless.filtersets.WirelessLANFilterSet,
-        'table': wireless.tables.WirelessLANTable,
-        'url': 'wireless:wirelesslan_list',
-    },
-    'wirelesslink': {
-        'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'),
-        'filterset': wireless.filtersets.WirelessLinkFilterSet,
-        'table': wireless.tables.WirelessLinkTable,
-        'url': 'wireless:wirelesslink_list',
-    },
-}
-
-JOURNAL_TYPES = {
-    'journalentry': {
-        'queryset': JournalEntry.objects.prefetch_related('assigned_object', 'created_by'),
-        'filterset': extras.filtersets.JournalEntryFilterSet,
-        'table': extras.tables.JournalEntryTable,
-        'url': 'extras:journalentry_list',
-    },
-}
-
-SEARCH_TYPE_HIERARCHY = {
-    'Circuits': CIRCUIT_TYPES,
-    'DCIM': DCIM_TYPES,
-    'IPAM': IPAM_TYPES,
-    'Tenancy': TENANCY_TYPES,
-    'Virtualization': VIRTUALIZATION_TYPES,
-    'Wireless': WIRELESS_TYPES,
-    'Journal': JOURNAL_TYPES,
-}
-
-
-def build_search_types():
-    result = dict()
-
-    for app_types in SEARCH_TYPE_HIERARCHY.values():
-        for name, items in app_types.items():
-            result[name] = items
-
-    return result
-
-
-SEARCH_TYPES = build_search_types()

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

@@ -0,0 +1,33 @@
+from extras.registry import registry
+
+
+class SearchIndex:
+    """
+    Base class for building search indexes.
+
+    Attrs:
+        model: The model class for which this index is used.
+    """
+    model = None
+
+    @classmethod
+    def get_category(cls):
+        """
+        Return the title of the search category under which this model is registered.
+        """
+        if hasattr(cls, 'category'):
+            return cls.category
+        return cls.model._meta.app_config.verbose_name
+
+
+def register_search():
+    def _wrapper(cls):
+        model = cls.model
+        app_label = model._meta.app_label
+        model_name = model._meta.model_name
+
+        registry['search'][app_label][model_name] = cls
+
+        return cls
+
+    return _wrapper

+ 125 - 0
netbox/netbox/search/backends.py

@@ -0,0 +1,125 @@
+from collections import defaultdict
+from importlib import import_module
+
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from django.urls import reverse
+
+from extras.registry import registry
+from netbox.constants import SEARCH_MAX_RESULTS
+
+# The cache for the initialized backend.
+_backends_cache = {}
+
+
+class SearchEngineError(Exception):
+    """Something went wrong with a search engine."""
+    pass
+
+
+class SearchBackend:
+    """A search engine capable of performing multi-table searches."""
+    _search_choice_options = tuple()
+
+    def get_registry(self):
+        r = {}
+        for app_label, models in registry['search'].items():
+            r.update(**models)
+
+        return r
+
+    def get_search_choices(self):
+        """Return the set of choices for individual object types, organized by category."""
+        if not self._search_choice_options:
+
+            # Organize choices by category
+            categories = defaultdict(dict)
+            for app_label, models in registry['search'].items():
+                for name, cls in models.items():
+                    title = cls.model._meta.verbose_name.title()
+                    categories[cls.get_category()][name] = title
+
+            # Compile a nested tuple of choices for form rendering
+            results = (
+                ('', 'All Objects'),
+                *[(category, choices.items()) for category, choices in categories.items()]
+            )
+
+            self._search_choice_options = results
+
+        return self._search_choice_options
+
+    def search(self, request, value, **kwargs):
+        """Execute a search query for the given value."""
+        raise NotImplementedError
+
+    def cache(self, instance):
+        """Create or update the cached copy of an instance."""
+        raise NotImplementedError
+
+
+class FilterSetSearchBackend(SearchBackend):
+    """
+    Legacy search backend. Performs a discrete database query for each registered object type, using the FilterSet
+    class specified by the index for each.
+    """
+    def search(self, request, value, **kwargs):
+        results = []
+
+        search_registry = self.get_registry()
+        for obj_type in search_registry.keys():
+
+            queryset = search_registry[obj_type].queryset
+            url = search_registry[obj_type].url
+
+            # Restrict the queryset for the current user
+            if hasattr(queryset, 'restrict'):
+                queryset = queryset.restrict(request.user, 'view')
+
+            filterset = getattr(search_registry[obj_type], 'filterset', None)
+            if not filterset:
+                # This backend requires a FilterSet class for the model
+                continue
+
+            table = getattr(search_registry[obj_type], 'table', None)
+            if not table:
+                # This backend requires a Table class for the model
+                continue
+
+            # Construct the results table for this object type
+            filtered_queryset = filterset({'q': value}, queryset=queryset).qs
+            table = table(filtered_queryset, orderable=False)
+            table.paginate(per_page=SEARCH_MAX_RESULTS)
+
+            if table.page:
+                results.append({
+                    'name': queryset.model._meta.verbose_name_plural,
+                    'table': table,
+                    'url': f"{reverse(url)}?q={value}"
+                })
+
+        return results
+
+    def cache(self, instance):
+        # This backend does not utilize a cache
+        pass
+
+
+def get_backend():
+    """Initializes and returns the configured search backend."""
+    backend_name = settings.SEARCH_BACKEND
+
+    # Load the backend class
+    backend_module_name, backend_cls_name = backend_name.rsplit('.', 1)
+    backend_module = import_module(backend_module_name)
+    try:
+        backend_cls = getattr(backend_module, backend_cls_name)
+    except AttributeError:
+        raise ImproperlyConfigured(f"Could not find a class named {backend_module_name} in {backend_cls_name}")
+
+    # Initialize and return the backend instance
+    return backend_cls()
+
+
+default_search_engine = get_backend()
+search = default_search_engine.search

+ 1 - 1
netbox/netbox/settings.py

@@ -121,6 +121,7 @@ REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATO
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
 SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
+SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.FilterSetSearchBackend')
 SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
 SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', DEFAULT_SENTRY_DSN)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False)
 SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
 SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0)
@@ -648,7 +649,6 @@ RQ_QUEUES = {
 #
 #
 
 
 for plugin_name in PLUGINS:
 for plugin_name in PLUGINS:
-
     # Import plugin module
     # Import plugin module
     try:
     try:
         plugin = importlib.import_module(plugin_name)
         plugin = importlib.import_module(plugin_name)

+ 4 - 21
netbox/netbox/views/__init__.py

@@ -23,7 +23,7 @@ from extras.tables import ObjectChangeTable
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
 from netbox.constants import SEARCH_MAX_RESULTS
 from netbox.constants import SEARCH_MAX_RESULTS
 from netbox.forms import SearchForm
 from netbox.forms import SearchForm
-from netbox.search import SEARCH_TYPES
+from netbox.search.backends import default_search_engine
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from virtualization.models import Cluster, VirtualMachine
 from virtualization.models import Cluster, VirtualMachine
 from wireless.models import WirelessLAN, WirelessLink
 from wireless.models import WirelessLAN, WirelessLink
@@ -153,31 +153,14 @@ class SearchView(View):
         results = []
         results = []
 
 
         if form.is_valid():
         if form.is_valid():
-
+            search_registry = default_search_engine.get_registry()
             # If an object type has been specified, redirect to the dedicated view for it
             # If an object type has been specified, redirect to the dedicated view for it
             if form.cleaned_data['obj_type']:
             if form.cleaned_data['obj_type']:
                 object_type = form.cleaned_data['obj_type']
                 object_type = form.cleaned_data['obj_type']
-                url = reverse(SEARCH_TYPES[object_type]['url'])
+                url = reverse(search_registry[object_type].url)
                 return redirect(f"{url}?q={form.cleaned_data['q']}")
                 return redirect(f"{url}?q={form.cleaned_data['q']}")
 
 
-            for obj_type in SEARCH_TYPES.keys():
-
-                queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
-                filterset = SEARCH_TYPES[obj_type]['filterset']
-                table = SEARCH_TYPES[obj_type]['table']
-                url = SEARCH_TYPES[obj_type]['url']
-
-                # Construct the results table for this object type
-                filtered_queryset = filterset({'q': form.cleaned_data['q']}, queryset=queryset).qs
-                table = table(filtered_queryset, orderable=False)
-                table.paginate(per_page=SEARCH_MAX_RESULTS)
-
-                if table.page:
-                    results.append({
-                        'name': queryset.model._meta.verbose_name_plural,
-                        'table': table,
-                        'url': f"{reverse(url)}?q={form.cleaned_data.get('q')}"
-                    })
+            results = default_search_engine.search(request, form.cleaned_data['q'])
 
 
         return render(request, 'search.html', {
         return render(request, 'search.html', {
             'form': form,
             'form': form,

+ 3 - 0
netbox/tenancy/apps.py

@@ -3,3 +3,6 @@ from django.apps import AppConfig
 
 
 class TenancyConfig(AppConfig):
 class TenancyConfig(AppConfig):
     name = 'tenancy'
     name = 'tenancy'
+
+    def ready(self):
+        from . import search

+ 25 - 0
netbox/tenancy/search.py

@@ -0,0 +1,25 @@
+import tenancy.filtersets
+import tenancy.tables
+from netbox.search import SearchIndex, register_search
+from tenancy.models import Contact, ContactAssignment, Tenant
+from utilities.utils import count_related
+
+
+@register_search()
+class TenantIndex(SearchIndex):
+    model = Tenant
+    queryset = Tenant.objects.prefetch_related('group')
+    filterset = tenancy.filtersets.TenantFilterSet
+    table = tenancy.tables.TenantTable
+    url = 'tenancy:tenant_list'
+
+
+@register_search()
+class ContactIndex(SearchIndex):
+    model = Contact
+    queryset = Contact.objects.prefetch_related('group', 'assignments').annotate(
+        assignment_count=count_related(ContactAssignment, 'contact')
+    )
+    filterset = tenancy.filtersets.ContactFilterSet
+    table = tenancy.tables.ContactTable
+    url = 'tenancy:contact_list'

+ 6 - 4
netbox/utilities/templatetags/search.py

@@ -1,16 +1,18 @@
 from typing import Dict
 from typing import Dict
-from netbox.forms import SearchForm
+
 from django import template
 from django import template
 
 
-register = template.Library()
+from netbox.forms import SearchForm
 
 
+register = template.Library()
 search_form = SearchForm()
 search_form = SearchForm()
 
 
 
 
 @register.inclusion_tag("search/searchbar.html")
 @register.inclusion_tag("search/searchbar.html")
 def search_options(request) -> Dict:
 def search_options(request) -> Dict:
-    """Provide search options to template."""
+
+    # Provide search options to template.
     return {
     return {
-        'options': search_form.options,
+        'options': search_form.get_options(),
         'request': request,
         'request': request,
     }
     }

+ 3 - 0
netbox/virtualization/apps.py

@@ -3,3 +3,6 @@ from django.apps import AppConfig
 
 
 class VirtualizationConfig(AppConfig):
 class VirtualizationConfig(AppConfig):
     name = 'virtualization'
     name = 'virtualization'
+
+    def ready(self):
+        from . import search

+ 33 - 0
netbox/virtualization/search.py

@@ -0,0 +1,33 @@
+import virtualization.filtersets
+import virtualization.tables
+from dcim.models import Device
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+from virtualization.models import Cluster, VirtualMachine
+
+
+@register_search()
+class ClusterIndex(SearchIndex):
+    model = Cluster
+    queryset = Cluster.objects.prefetch_related('type', 'group').annotate(
+        device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster')
+    )
+    filterset = virtualization.filtersets.ClusterFilterSet
+    table = virtualization.tables.ClusterTable
+    url = 'virtualization:cluster_list'
+
+
+@register_search()
+class VirtualMachineIndex(SearchIndex):
+    model = VirtualMachine
+    queryset = VirtualMachine.objects.prefetch_related(
+        'cluster',
+        'tenant',
+        'tenant__group',
+        'platform',
+        'primary_ip4',
+        'primary_ip6',
+    )
+    filterset = virtualization.filtersets.VirtualMachineFilterSet
+    table = virtualization.tables.VirtualMachineTable
+    url = 'virtualization:virtualmachine_list'

+ 1 - 1
netbox/wireless/apps.py

@@ -5,4 +5,4 @@ class WirelessConfig(AppConfig):
     name = 'wireless'
     name = 'wireless'
 
 
     def ready(self):
     def ready(self):
-        import wireless.signals
+        from . import signals, search

+ 26 - 0
netbox/wireless/search.py

@@ -0,0 +1,26 @@
+import wireless.filtersets
+import wireless.tables
+from dcim.models import Interface
+from netbox.search import SearchIndex, register_search
+from utilities.utils import count_related
+from wireless.models import WirelessLAN, WirelessLink
+
+
+@register_search()
+class WirelessLANIndex(SearchIndex):
+    model = WirelessLAN
+    queryset = WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
+        interface_count=count_related(Interface, 'wireless_lans')
+    )
+    filterset = wireless.filtersets.WirelessLANFilterSet
+    table = wireless.tables.WirelessLANTable
+    url = 'wireless:wirelesslan_list'
+
+
+@register_search()
+class WirelessLinkIndex(SearchIndex):
+    model = WirelessLink
+    queryset = WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device')
+    filterset = wireless.filtersets.WirelessLinkFilterSet
+    table = wireless.tables.WirelessLinkTable
+    url = 'wireless:wirelesslink_list'