diff --git a/netbox/dcim/search_indexes.py b/netbox/dcim/search_indexes.py new file mode 100644 index 00000000000..9ec82fe4efc --- /dev/null +++ b/netbox/dcim/search_indexes.py @@ -0,0 +1,148 @@ +import dcim.filtersets +import dcim.tables +from dcim.models import ( + Cable, + Device, + DeviceType, + Interface, + Location, + Module, + ModuleType, + PowerFeed, + Rack, + RackReservation, + Site, + VirtualChassis, +) +from django.db import models +from search.models import SearchMixin + + +class SiteIndex(SearchMixin): + model = Site + queryset = Site.objects.prefetch_related('region', 'tenant', 'tenant__group') + filterset = dcim.filtersets.SiteFilterSet + table = dcim.tables.SiteTable + url = 'dcim:site_list' + + +class RackIndex(SearchMixin): + 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' + + +class RackReservationIndex(SearchMixin): + model = RackReservation + queryset = RackReservation.objects.prefetch_related('rack', 'user') + filterset = dcim.filtersets.RackReservationFilterSet + table = dcim.tables.RackReservationTable + url = 'dcim:rackreservation_list' + + +class LocationIndex(SearchMixin): + model = Site + 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' + + +class DeviceTypeIndex(SearchMixin): + 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' + + +class DeviceIndex(SearchMixin): + model = DeviceIndex + 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' + + +class ModuleTypeIndex(SearchMixin): + 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' + + +class ModuleIndex(SearchMixin): + 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' + + +class VirtualChassisIndex(SearchMixin): + 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' + + +class CableIndex(SearchMixin): + model = Cable + queryset = Cable.objects.all() + filterset = dcim.filtersets.CableFilterSet + table = dcim.tables.CableTable + url = 'dcim:cable_list' + + +class PowerFeedIndex(SearchMixin): + model = PowerFeed + queryset = PowerFeed.objects.all() + filterset = dcim.filtersets.PowerFeedFilterSet + table = dcim.tables.PowerFeedTable + url = 'dcim:powerfeed_list' + + +DCIM_SEARCH_ORDERING = [ + SiteIndex, + RackIndex, + RackReservationIndex, + LocationIndex, + DeviceTypeIndex, + DeviceIndex, + ModuleTypeIndex, + ModuleIndex, + VirtualChassisIndex, + CableIndex, + PowerFeedIndex, +] diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f96b6085be0..e6f59e7822f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -331,6 +331,7 @@ def _setting(name, default=None): 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', + 'search', ] # Middleware diff --git a/netbox/search/__init__.py b/netbox/search/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/netbox/search/apps.py b/netbox/search/apps.py new file mode 100644 index 00000000000..f5cf8fbbd34 --- /dev/null +++ b/netbox/search/apps.py @@ -0,0 +1,33 @@ +from django.apps import AppConfig + +from django.apps import apps +from django.utils.module_loading import module_has_submodule +from netbox import denormalized + + +def get_app_modules(): + """ + Returns all app modules (installed apps) - yields tuples of (app_name, module) + """ + for app in apps.get_app_configs(): + yield app.name, app.module + + +def get_app_submodules(submodule_name): + """ + Searches each app module for the specified submodule - yields tuples of (app_name, module) + """ + for name, module in get_app_modules(): + if module_has_submodule(module, submodule_name): + yield name, import_module(f"{name}.{submodule_name}") + + +class SearchConfig(AppConfig): + name = "search" + verbose_name = "search" + + def ready(self): + for name, module in get_app_modules(): + submodule_name = "search_indexes" + if module_has_submodule(module, submodule_name): + print(f"{name}.{submodule_name}") diff --git a/netbox/search/backends.py b/netbox/search/backends.py new file mode 100644 index 00000000000..1e3533de2c4 --- /dev/null +++ b/netbox/search/backends.py @@ -0,0 +1,113 @@ +from abc import ABC +from importlib import import_module + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import models +from django.db.models.signals import post_save, pre_delete + +# The cache for the initialized backend. +_backends_cache = {} + + +def get_backend(backend_name=None): + """Initializes and returns the search backend.""" + global _backends_cache + if not backend_name: + backend_name = getattr(settings, "SEARCH_BACKEND", "search.backends.PostgresIcontainsSearchBackend") + + # Try to use the cached backend. + if backend_name in _backends_cache: + return _backends_cache[backend_name] + + # 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 the backend. + backend = backend_cls() + _backends_cache[backend_name] = backend + return backend + + +class SearchEngineError(Exception): + + """Something went wrong with a search engine.""" + + +class SearchBackend(object): + + """A search engine capable of performing multi-table searches.""" + + _created_engines: dict = dict() + + @classmethod + def get_created_engines(cls): + """Returns all created search engines.""" + return list(cls._created_engines.items()) + + def __init__(self, engine_slug: str): + """Initializes the search engine.""" + # Check the slug is unique for this project. + if engine_slug in SearchBackend._created_engines: + raise SearchEngineError(f"A search engine has already been created with the slug {engine_slug}") + + # Initialize this engine. + self._registered_models = {} + self._engine_slug = engine_slug + + # Store a reference to this engine. + self.__class__._created_engines[engine_slug] = self + + def is_registered(self, model): + """Checks whether the given model is registered with this search engine.""" + return model in self._registered_models + + def register(self, model): + """ + Registers the given model with this search engine. + + If the given model is already registered with this search engine, a + RegistrationError will be raised. + """ + # Check for existing registration. + if self.is_registered(model): + raise RegistrationError(f"{model} is already registered with this search engine") + + # Connect to the signalling framework. + if self._use_hooks(): + post_save.connect(self._post_save_receiver, model) + pre_delete.connect(self._pre_delete_receiver, model) + + # Signalling hooks. + + def _use_hooks(self): + raise NotImplementedError + + def _post_save_receiver(self, instance, **kwargs): + """Signal handler for when a registered model has been saved.""" + raise NotImplementedError + + def _pre_delete_receiver(self, instance, **kwargs): + """Signal handler for when a registered model has been deleted.""" + raise NotImplementedError + + # Searching. + + def search(self, search_text, models=(), exclude=(), ranking=True, backend_name=None): + """Performs a search using the given text, returning a queryset of SearchEntry.""" + raise NotImplementedError + + +class PostgresIcontainsSearchBackend(SearchBackend): + def _use_hooks(self): + return False + + +# The main search methods. +default_search_engine = SearchBackend("default") +search = default_search_engine.search diff --git a/netbox/search/models.py b/netbox/search/models.py new file mode 100644 index 00000000000..78f65d8c091 --- /dev/null +++ b/netbox/search/models.py @@ -0,0 +1,102 @@ +from django.core.exceptions import ImproperlyConfigured +from django.db import models + + +class SearchMixin(models.Model): + """ + Base class for building search indexes. + """ + + model = None + queryset = None + filterset = None + table = None + url = None + + class Meta: + abstract = True + + def get_model(self): + """ + Should return the ``Model`` class (not an instance) that the rest of the + ``SearchIndex`` should use. + This method is required & you must override it to return the correct class. + """ + if self.model is not None: + model = self.model + else: + raise ImproperlyConfigured( + f"{self.__class__.__name__}s is missing a Model. Define " + f"{self.__class__.__name__}s.model or override " + f"{self.__class__.__name__}s.get_model()." + ) + + return model + + def get_queryset(self): + """ + Should return the ``QuerySet`` class (not an instance) that the rest of the + ``SearchIndex`` should use. + This method is required & you must override it to return the correct class. + """ + if self.queryset is not None: + queryset = self.queryset + else: + raise ImproperlyConfigured( + f"{self.__class__.__name__}s is missing a QuerySet. Define " + f"{self.__class__.__name__}s.queryset or override " + f"{self.__class__.__name__}s.get_queryset()." + ) + + return queryset + + def get_filterset(self): + """ + Should return the ``FilterSet`` class (not an instance) that the rest of the + ``SearchIndex`` should use. + This method is required & you must override it to return the correct class. + """ + if self.filterset is not None: + filterset = self.filterset + else: + raise ImproperlyConfigured( + f"{self.__class__.__name__}s is missing a FilterSet. Define " + f"{self.__class__.__name__}s.filterset or override " + f"{self.__class__.__name__}s.get_filterset()." + ) + + return filterset + + def get_table(self): + """ + Should return the ``Table`` class (not an instance) that the rest of the + ``SearchIndex`` should use. + This method is required & you must override it to return the correct class. + """ + if self.table is not None: + table = self.table + else: + raise ImproperlyConfigured( + f"{self.__class__.__name__}s is missing a Table. Define " + f"{self.__class__.__name__}s.table or override " + f"{self.__class__.__name__}s.get_table()." + ) + + return table + + def get_url(self): + """ + Should return the ``URL`` class (not an instance) that the rest of the + ``SearchIndex`` should use. + This method is required & you must override it to return the correct class. + """ + if self.url is not None: + url = self.url + else: + raise ImproperlyConfigured( + f"{self.__class__.__name__}s is missing a URL. Define " + f"{self.__class__.__name__}s.url or override " + f"{self.__class__.__name__}s.get_url()." + ) + + return url