Repository: netbox-community/netbox

Issue:
ImportErrors inside plugins are currently ignored → add logging
### NetBox version

v3.4.0

### Feature type

Change to existing functionality

### Proposed functionality

This has been requested before, see #4628. I'm currently writing my first plugin and just spent about a day trying to figure out why it won't get loaded. No matter how far I turned up the log level, this `pass` in `extras/plugins/urls.py` always caught the `ImportError` inside my plugin:

```python

    # Check if the plugin specifies any base URLs
    try:
        urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
        plugin_patterns.append(
            path(f"{base_url}/", include((urlpatterns, app.label)))
        )
    except ImportError:
        pass
```

I propose replacing the `pass` with a log message that says something like "could not load plugin base URL {plugin_path}.urls.urlpatterns" and also log a traceback.

Since this mechanism is used to allow having plugins without API URLs (or base URLs), this is not neccessarily an error and should probably be log level `INFO` and the traceback should be `DEBUG`, I assume?

I have a patch ready, and can submit a PR, if this seems like a good idea. Log would look something like this:

```
gunicorn[1337]: Plugin base URL patterns could not be loaded: my_little_plugin.urls.urlpatterns

gunicorn[1337]: Traceback (most recent call last):
gunicorn[1337]: File "/opt/netbox/netbox/extras/plugins/urls.py", line 33, in <module>
gunicorn[1337]: urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
gunicorn[1337]: File "/opt/netbox-3.4.0/venv/lib64/python3.8/site-packages/my_little_plugin/forms.py", line 2, in <module>
[...]
gunicorn[1337]: from dcim.models import Devic, Interface
gunicorn[1337]: ImportError: cannot import name 'Devic' from 'dcim.models' (/opt/netbox/netbox/dcim/models/__init__.py)
```

### Use case

I hope this saves plugin developers some head-scratching.

### Database changes

_No response_

### External dependencies

_No response_


Discussion:
@jeremystretch I'd like to take this
@abhi1693 all yours.
> I have a patch ready, and can submit a PR, if this seems like a good idea.

I had pretty much the same thing ready, just didn't want to submit a PR without asking first, to follow the guidelines. Anyhow, thanks for fixing!

Resolving patch:
diff --git a/netbox/extras/plugins/__init__.py b/netbox/extras/plugins/__init__.py
--- a/netbox/extras/plugins/__init__.py
+++ b/netbox/extras/plugins/__init__.py
@@ -1,4 +1,5 @@
 import collections
+from importlib.util import find_spec
 
 from django.apps import AppConfig
 from django.conf import settings
@@ -21,6 +22,15 @@
     'template_extensions': collections.defaultdict(list),
 }
 
+DEFAULT_RESOURCE_PATHS = {
+    'search_indexes': 'search.indexes',
+    'graphql_schema': 'graphql.schema',
+    'menu': 'navigation.menu',
+    'menu_items': 'navigation.menu_items',
+    'template_extensions': 'template_content.template_extensions',
+    'user_preferences': 'preferences.preferences',
+}
+
 
 #
 # Plugin AppConfig class
@@ -58,58 +68,50 @@ class PluginConfig(AppConfig):
     # Django apps to append to INSTALLED_APPS when plugin requires them.
     django_apps = []
 
-    # 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'
-    template_extensions = 'template_content.template_extensions'
-    user_preferences = 'preferences.preferences'
+    # Optional plugin resources
+    search_indexes = None
+    graphql_schema = None
+    menu = None
+    menu_items = None
+    template_extensions = None
+    user_preferences = None
+
+    def _load_resource(self, name):
+        # Import from the configured path, if defined.
+        if getattr(self, name):
+            return import_string(f"{self.__module__}.{self.name}")
+        # Fall back to the resource's default path. Return None if the module has not been provided.
+        default_path = DEFAULT_RESOURCE_PATHS[name]
+        default_module = f'{self.__module__}.{default_path}'.rsplit('.', 1)[0]
+        if find_spec(default_module):
+            setattr(self, name, default_path)
+            return import_string(f"{self.__module__}.{default_path}")
 
     def ready(self):
         plugin_name = self.name.rsplit('.', 1)[-1]
 
         # Register search extensions (if defined)
-        try:
-            search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
-            for idx in search_indexes:
-                register_search(idx)
-        except ImportError:
-            pass
+        search_indexes = self._load_resource('search_indexes') or []
+        for idx in search_indexes:
+            register_search(idx)
 
         # Register template content (if defined)
-        try:
-            template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
+        if template_extensions := self._load_resource('template_extensions'):
             register_template_extensions(template_extensions)
-        except ImportError:
-            pass
 
         # Register navigation menu and/or menu items (if defined)
-        try:
-            menu = import_string(f"{self.__module__}.{self.menu}")
+        if menu := self._load_resource('menu'):
             register_menu(menu)
-        except ImportError:
-            pass
-        try:
-            menu_items = import_string(f"{self.__module__}.{self.menu_items}")
+        if menu_items := self._load_resource('menu_items'):
             register_menu_items(self.verbose_name, menu_items)
-        except ImportError:
-            pass
 
         # Register GraphQL schema (if defined)
-        try:
-            graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
+        if graphql_schema := self._load_resource('graphql_schema'):
             register_graphql_schema(graphql_schema)
-        except ImportError:
-            pass
 
         # Register user preferences (if defined)
-        try:
-            user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
+        if user_preferences := self._load_resource('user_preferences'):
             register_user_preferences(plugin_name, user_preferences)
-        except ImportError:
-            pass
 
     @classmethod
     def validate(cls, user_config, netbox_version):
diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py
--- a/netbox/extras/plugins/urls.py
+++ b/netbox/extras/plugins/urls.py
@@ -1,9 +1,11 @@
+from importlib import import_module
+
 from django.apps import apps
 from django.conf import settings
 from django.conf.urls import include
 from django.contrib.admin.views.decorators import staff_member_required
 from django.urls import path
-from django.utils.module_loading import import_string
+from django.utils.module_loading import import_string, module_has_submodule
 
 from . import views
 
@@ -19,24 +21,21 @@
 
 # Register base/API URL patterns for each plugin
 for plugin_path in settings.PLUGINS:
+    plugin = import_module(plugin_path)
     plugin_name = plugin_path.split('.')[-1]
     app = apps.get_app_config(plugin_name)
     base_url = getattr(app, 'base_url') or app.label
 
     # Check if the plugin specifies any base URLs
-    try:
+    if module_has_submodule(plugin, 'urls'):
         urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
         plugin_patterns.append(
             path(f"{base_url}/", include((urlpatterns, app.label)))
         )
-    except ImportError:
-        pass
 
     # Check if the plugin specifies any API URLs
-    try:
+    if module_has_submodule(plugin, 'api.urls'):
         urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
         plugin_api_patterns.append(
             path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
         )
-    except ImportError:
-        pass


Test patch: