diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index b7b03b5bf..cad77c7fe 100644 --- a/docs/plugins/development/index.md +++ b/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 | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | | `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`) | | `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`) | diff --git a/docs/plugins/development/search.md b/docs/plugins/development/search.md new file mode 100644 index 000000000..13edd4527 --- /dev/null +++ b/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 diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index bbb386577..6a47c7a2b 100644 --- a/docs/release-notes/version-3.4.md +++ b/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 +* [#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 * [#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 diff --git a/mkdocs.yml b/mkdocs.yml index 4e2cb73dd..a7977d7ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,6 +132,7 @@ nav: - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' - Exceptions: 'plugins/development/exceptions.md' + - Search: 'plugins/development/search.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py index bc0b7d87d..3acf3b98c 100644 --- a/netbox/circuits/apps.py +++ b/netbox/circuits/apps.py @@ -6,4 +6,4 @@ class CircuitsConfig(AppConfig): verbose_name = "Circuits" def ready(self): - import circuits.signals + from . import signals, search diff --git a/netbox/circuits/search.py b/netbox/circuits/search.py new file mode 100644 index 000000000..5adfb97fb --- /dev/null +++ b/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' diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 4be2df659..bfb09e601 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -8,7 +8,7 @@ class DCIMConfig(AppConfig): verbose_name = "DCIM" def ready(self): - import dcim.signals + from . import signals, search from .models import CableTermination # Register denormalized fields diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py new file mode 100644 index 000000000..b179402ce --- /dev/null +++ b/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' diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 7500157c0..965eb033e 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -5,5 +5,4 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): - import extras.lookups - import extras.signals + from . import lookups, search, signals diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 3efa9aaa7..78a056216 100644 --- a/netbox/extras/plugins/__init__.py +++ b/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.registry import registry from netbox.navigation import MenuGroup +from netbox.search import register_search 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 # integrated components. + search_indexes = 'search.indexes' graphql_schema = 'graphql.schema' menu = 'navigation.menu' menu_items = 'navigation.menu_items' @@ -69,6 +71,11 @@ class PluginConfig(AppConfig): def ready(self): 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) template_extensions = import_object(f"{self.__module__}.{self.template_extensions}") if template_extensions is not None: diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index b748b6f90..f89499842 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -29,4 +29,5 @@ registry['model_features'] = { feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } registry['denormalized_fields'] = collections.defaultdict(list) +registry['search'] = collections.defaultdict(dict) registry['views'] = collections.defaultdict(dict) diff --git a/netbox/extras/search.py b/netbox/extras/search.py new file mode 100644 index 000000000..ae6c9e7b9 --- /dev/null +++ b/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' diff --git a/netbox/extras/tests/dummy_plugin/search.py b/netbox/extras/tests/dummy_plugin/search.py new file mode 100644 index 000000000..4a1b7e666 --- /dev/null +++ b/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, +) diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index 413c8c1bc..4b0820fef 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -6,4 +6,4 @@ class IPAMConfig(AppConfig): verbose_name = "IPAM" def ready(self): - import ipam.signals + from . import signals, search diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py new file mode 100644 index 000000000..2f4599321 --- /dev/null +++ b/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' diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index d1451e003..eb1311d98 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -1,31 +1,15 @@ 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 .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))) +def build_options(choices): + options = [{"label": choices[0][1], "items": []}] - return tuple(result) - - -OBJ_TYPE_CHOICES = build_search_choices() - - -def build_options(): - options = [{"label": OBJ_TYPE_CHOICES[0][1], "items": []}] - - for label, choices in OBJ_TYPE_CHOICES[1:]: + for label, choices in choices[1:]: items = [] for value, choice_label in choices: @@ -36,10 +20,19 @@ def build_options(): 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 diff --git a/netbox/netbox/search.py b/netbox/netbox/search.py deleted file mode 100644 index 6d1c5d4a3..000000000 --- a/netbox/netbox/search.py +++ /dev/null @@ -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() diff --git a/netbox/netbox/search/__init__.py b/netbox/netbox/search/__init__.py new file mode 100644 index 000000000..0664dc6ca --- /dev/null +++ b/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 diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py new file mode 100644 index 000000000..b6cead5bd --- /dev/null +++ b/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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a0d788c73..5d4dbb809 100644 --- a/netbox/netbox/settings.py +++ b/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('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) 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_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) @@ -648,7 +649,6 @@ RQ_QUEUES = { # for plugin_name in PLUGINS: - # Import plugin module try: plugin = importlib.import_module(plugin_name) diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index bc1f0e2ca..d880ba64c 100644 --- a/netbox/netbox/views/__init__.py +++ b/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 netbox.constants import SEARCH_MAX_RESULTS 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 virtualization.models import Cluster, VirtualMachine from wireless.models import WirelessLAN, WirelessLink @@ -153,31 +153,14 @@ class SearchView(View): results = [] 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 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']}") - 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', { 'form': form, diff --git a/netbox/tenancy/apps.py b/netbox/tenancy/apps.py index 53cb9a056..eeb141152 100644 --- a/netbox/tenancy/apps.py +++ b/netbox/tenancy/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class TenancyConfig(AppConfig): name = 'tenancy' + + def ready(self): + from . import search diff --git a/netbox/tenancy/search.py b/netbox/tenancy/search.py new file mode 100644 index 000000000..e52b1859e --- /dev/null +++ b/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' diff --git a/netbox/utilities/templatetags/search.py b/netbox/utilities/templatetags/search.py index 5726ae5d5..ca8f3ba2a 100644 --- a/netbox/utilities/templatetags/search.py +++ b/netbox/utilities/templatetags/search.py @@ -1,16 +1,18 @@ from typing import Dict -from netbox.forms import SearchForm + from django import template -register = template.Library() +from netbox.forms import SearchForm +register = template.Library() search_form = SearchForm() @register.inclusion_tag("search/searchbar.html") def search_options(request) -> Dict: - """Provide search options to template.""" + + # Provide search options to template. return { - 'options': search_form.options, + 'options': search_form.get_options(), 'request': request, } diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index 35d6e8266..1b6b110df 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class VirtualizationConfig(AppConfig): name = 'virtualization' + + def ready(self): + from . import search diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py new file mode 100644 index 000000000..5b24f7fa0 --- /dev/null +++ b/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' diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py index 59e47aba5..51dee4188 100644 --- a/netbox/wireless/apps.py +++ b/netbox/wireless/apps.py @@ -5,4 +5,4 @@ class WirelessConfig(AppConfig): name = 'wireless' def ready(self): - import wireless.signals + from . import signals, search diff --git a/netbox/wireless/search.py b/netbox/wireless/search.py new file mode 100644 index 000000000..89ac23af8 --- /dev/null +++ b/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'