diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py index 3efa9aaa7..acab4f966 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 = '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}") 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/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/netbox/search/__init__.py b/netbox/netbox/search/__init__.py index 261db8903..2fcf533d5 100644 --- a/netbox/netbox/search/__init__.py +++ b/netbox/netbox/search/__init__.py @@ -1,4 +1,4 @@ -from .register import register_search +from extras.registry import registry class SearchIndex: @@ -12,3 +12,16 @@ class SearchIndex: 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 index 0aa025d9a..0a87f8be1 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -3,7 +3,6 @@ from importlib import import_module from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.db.models.signals import post_save, pre_delete from django.urls import reverse from extras.registry import registry @@ -14,16 +13,13 @@ _backends_cache = {} class SearchEngineError(Exception): - """Something went wrong with a search engine.""" + pass class SearchBackend(object): - """A search engine capable of performing multi-table searches.""" - _created_engines: dict = dict() - _search_choices = {} _search_choice_options = tuple() @classmethod @@ -38,94 +34,44 @@ class SearchBackend(object): 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, key, model): - """Checks whether the given model is registered with this search engine.""" - return key in self._registered_models - - def register(self, key, 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(key, model): - raise RegistrationError(f"{model} is already registered with this search engine") - - self._registered_models[key] = model - - # add to the search choices - if model.choice_header not in self._search_choices: - self._search_choices[model.choice_header] = {} - - if key not in self._search_choices[model.choice_header]: - self._search_choices[model.choice_header][key] = model.queryset.model._meta.verbose_name_plural - - # 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) - def get_registry(self): - # return self._registered_models - r = {} for app_label, models in registry['search'].items(): r.update(**models) + return r - # Signalling hooks. - def get_search_choices(self): - if self._search_choice_options: - return self._search_choice_options + 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(): - model = cls.queryset.model - title = model._meta.verbose_name.title() - categories[cls.get_category()][name] = title + # 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()] - ) + # 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 + self._search_choice_options = results return self._search_choice_options - 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): + def search(self, request, 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 def search(self, request, search_text): results = [] diff --git a/netbox/netbox/search/register.py b/netbox/netbox/search/register.py deleted file mode 100644 index 470aa290a..000000000 --- a/netbox/netbox/search/register.py +++ /dev/null @@ -1,57 +0,0 @@ -import importlib -import inspect -import sys - -from django.apps import apps -from django.conf import settings -from django.utils.module_loading import module_has_submodule - -from extras.registry import registry -from .backends import default_search_engine - - -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 register(): - - # for name, module in SEARCH_TYPES.items(): - # default_search_engine.register(name, module) - - for app_label, models in registry['search'].items(): - for name, cls in models.items(): - default_search_engine.register(name, cls) - - for name, module in get_app_modules(): - submodule_name = "search_indexes" - if module_has_submodule(module, submodule_name): - module_name = f"{name}.{submodule_name}" - if name in settings.PLUGINS: - search_module = importlib.import_module(module_name) - else: - search_module = sys.modules[module_name] - - cls_objects = inspect.getmembers(search_module, predicate=inspect.isclass) - for cls_name, cls_obj in inspect.getmembers(search_module, predicate=inspect.isclass): - if getattr(cls_obj, "search_index", False) and getattr(cls_obj, "model", None): - cls_name = cls_obj.model.__name__.lower() - if not default_search_engine.is_registered(cls_name, cls_obj): - default_search_engine.register(cls_name, cls_obj) - - -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