diff --git a/netbox/extras/plugins/urls.py b/netbox/extras/plugins/urls.py index f3f591cb0..b4360dc9e 100644 --- a/netbox/extras/plugins/urls.py +++ b/netbox/extras/plugins/urls.py @@ -18,13 +18,14 @@ plugin_admin_patterns = [ ] # Register base/API URL patterns for each plugin -for plugin in settings.PLUGINS: - app = apps.get_app_config(plugin) +for plugin_path in settings.PLUGINS: + 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: - urlpatterns = import_string(f"{plugin}.urls.urlpatterns") + urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns") plugin_patterns.append( path(f"{base_url}/", include((urlpatterns, app.label))) ) @@ -33,7 +34,7 @@ for plugin in settings.PLUGINS: # Check if the plugin specifies any API URLs try: - urlpatterns = import_string(f"{plugin}.api.urls.urlpatterns") + urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns") plugin_api_patterns.append( path(f"{base_url}/", include((urlpatterns, f"{app.label}-api"))) ) diff --git a/netbox/extras/tests/dummy_plugin/__init__.py b/netbox/extras/tests/dummy_plugin/__init__.py new file mode 100644 index 000000000..eaaff4f5e --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/__init__.py @@ -0,0 +1,15 @@ +from extras.plugins import PluginConfig + + +class DummyPluginConfig(PluginConfig): + name = 'extras.tests.dummy_plugin' + verbose_name = 'Dummy plugin' + version = '0.0' + description = 'For testing purposes only' + base_url = 'dummy-plugin' + middleware = [ + 'extras.tests.dummy_plugin.middleware.DummyMiddleware' + ] + + +config = DummyPluginConfig diff --git a/netbox/extras/tests/dummy_plugin/admin.py b/netbox/extras/tests/dummy_plugin/admin.py new file mode 100644 index 000000000..83bc22ad8 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from netbox.admin import admin_site +from .models import DummyModel + + +@admin.register(DummyModel, site=admin_site) +class DummyModelAdmin(admin.ModelAdmin): + list_display = ('name', 'number') diff --git a/netbox/extras/tests/dummy_plugin/api/serializers.py b/netbox/extras/tests/dummy_plugin/api/serializers.py new file mode 100644 index 000000000..101786168 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/api/serializers.py @@ -0,0 +1,9 @@ +from rest_framework.serializers import ModelSerializer +from extras.tests.dummy_plugin.models import DummyModel + + +class DummySerializer(ModelSerializer): + + class Meta: + model = DummyModel + fields = ('id', 'name', 'number') diff --git a/netbox/extras/tests/dummy_plugin/api/urls.py b/netbox/extras/tests/dummy_plugin/api/urls.py new file mode 100644 index 000000000..d6c27809b --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/api/urls.py @@ -0,0 +1,6 @@ +from rest_framework import routers +from .views import DummyViewSet + +router = routers.DefaultRouter() +router.register('dummy-models', DummyViewSet) +urlpatterns = router.urls diff --git a/netbox/extras/tests/dummy_plugin/api/views.py b/netbox/extras/tests/dummy_plugin/api/views.py new file mode 100644 index 000000000..1977ec2af --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/api/views.py @@ -0,0 +1,8 @@ +from rest_framework.viewsets import ModelViewSet +from extras.tests.dummy_plugin.models import DummyModel +from .serializers import DummySerializer + + +class DummyViewSet(ModelViewSet): + queryset = DummyModel.objects.all() + serializer_class = DummySerializer diff --git a/netbox/extras/tests/dummy_plugin/middleware.py b/netbox/extras/tests/dummy_plugin/middleware.py new file mode 100644 index 000000000..97592c3b2 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/middleware.py @@ -0,0 +1,7 @@ +class DummyMiddleware: + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) diff --git a/netbox/extras/tests/dummy_plugin/migrations/0001_initial.py b/netbox/extras/tests/dummy_plugin/migrations/0001_initial.py new file mode 100644 index 000000000..4342d9576 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/migrations/0001_initial.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DummyModel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=20)), + ('number', models.IntegerField(default=100)), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/extras/tests/dummy_plugin/migrations/__init__.py b/netbox/extras/tests/dummy_plugin/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/tests/dummy_plugin/models.py b/netbox/extras/tests/dummy_plugin/models.py new file mode 100644 index 000000000..9bd39a46b --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/models.py @@ -0,0 +1,13 @@ +from django.db import models + + +class DummyModel(models.Model): + name = models.CharField( + max_length=20 + ) + number = models.IntegerField( + default=100 + ) + + class Meta: + ordering = ['name'] diff --git a/netbox/extras/tests/dummy_plugin/navigation.py b/netbox/extras/tests/dummy_plugin/navigation.py new file mode 100644 index 000000000..75b500916 --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/navigation.py @@ -0,0 +1,25 @@ +from extras.plugins import PluginMenuButton, PluginMenuItem + + +menu_items = ( + PluginMenuItem( + link='plugins:dummy_plugin:dummy_models', + link_text='Item 1', + buttons=( + PluginMenuButton( + link='admin:dummy_plugin_dummymodel_add', + title='Add a new dummy model', + icon_class='fa-plus', + ), + PluginMenuButton( + link='admin:dummy_plugin_dummymodel_add', + title='Add a new dummy model', + icon_class='fa-plus', + ), + ) + ), + PluginMenuItem( + link='plugins:dummy_plugin:dummy_models', + link_text='Item 2', + ), +) diff --git a/netbox/extras/tests/dummy_plugin/template_content.py b/netbox/extras/tests/dummy_plugin/template_content.py new file mode 100644 index 000000000..fed17ca0b --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/template_content.py @@ -0,0 +1,20 @@ +from extras.plugins import PluginTemplateExtension + + +class SiteContent(PluginTemplateExtension): + model = 'dcim.site' + + def left_page(self): + return "SITE CONTENT - LEFT PAGE" + + def right_page(self): + return "SITE CONTENT - RIGHT PAGE" + + def full_width_page(self): + return "SITE CONTENT - FULL WIDTH PAGE" + + def full_buttons(self): + return "SITE CONTENT - BUTTONS" + + +template_extensions = [SiteContent] diff --git a/netbox/extras/tests/dummy_plugin/urls.py b/netbox/extras/tests/dummy_plugin/urls.py new file mode 100644 index 000000000..053a7443e --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + + +urlpatterns = ( + path('models/', views.DummyModelsView.as_view(), name='dummy_models'), +) diff --git a/netbox/extras/tests/dummy_plugin/views.py b/netbox/extras/tests/dummy_plugin/views.py new file mode 100644 index 000000000..4512758df --- /dev/null +++ b/netbox/extras/tests/dummy_plugin/views.py @@ -0,0 +1,11 @@ +from django.http import HttpResponse +from django.views.generic import View + +from .models import DummyModel + + +class DummyModelsView(View): + + def get(self, request): + instance_count = DummyModel.objects.count() + return HttpResponse(f"Instances: {instance_count}") diff --git a/netbox/extras/tests/test_plugins.py b/netbox/extras/tests/test_plugins.py new file mode 100644 index 000000000..dba6308b9 --- /dev/null +++ b/netbox/extras/tests/test_plugins.py @@ -0,0 +1,79 @@ +from unittest import skipIf + +from django.conf import settings +from django.test import Client, TestCase, override_settings +from django.urls import reverse + +from extras.registry import registry + + +@skipIf('extras.tests.dummy_plugin.DummyPluginConfig' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") +class PluginTest(TestCase): + + def test_config(self): + + self.assertIn('extras.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) + + def test_models(self): + from extras.tests.dummy_plugin.models import DummyModel + + # Test saving an instance + instance = DummyModel(name='Instance 1', number=100) + instance.save() + self.assertIsNotNone(instance.pk) + + # Test deleting an instance + instance.delete() + self.assertIsNone(instance.pk) + + def test_admin(self): + + # Test admin view URL resolution + url = reverse('admin:dummy_plugin_dummymodel_add') + self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/') + + def test_views(self): + + # Test URL resolution + url = reverse('plugins:dummy_plugin:dummy_models') + self.assertEqual(url, '/plugins/dummy-plugin/models/') + + # Test GET request + client = Client() + response = client.get(url) + self.assertEqual(response.status_code, 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_api_views(self): + + # Test URL resolution + url = reverse('plugins-api:dummy_plugin-api:dummymodel-list') + self.assertEqual(url, '/api/plugins/dummy-plugin/dummy-models/') + + # Test GET request + client = Client() + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_menu_items(self): + """ + Check that plugin MenuItems and MenuButtons are registered. + """ + self.assertIn('Dummy plugin', registry['plugin_menu_items']) + menu_items = registry['plugin_menu_items']['Dummy plugin'] + self.assertEqual(len(menu_items), 2) + self.assertEqual(len(menu_items[0].buttons), 2) + + def test_template_extensions(self): + """ + Check that plugin TemplateExtensions are registered. + """ + from extras.tests.dummy_plugin.template_content import SiteContent + + self.assertIn(SiteContent, registry['plugin_template_extensions']['dcim.site']) + + def test_middleware(self): + """ + Check that plugin middleware is registered. + """ + self.assertIn('extras.tests.dummy_plugin.middleware.DummyMiddleware', settings.MIDDLEWARE) diff --git a/netbox/netbox/configuration.testing.py b/netbox/netbox/configuration.testing.py new file mode 100644 index 000000000..09d5362ab --- /dev/null +++ b/netbox/netbox/configuration.testing.py @@ -0,0 +1,40 @@ +################################################################### +# This file serves as a base configuration for testing purposes # +# only. It is not intended for production use. # +################################################################### + +ALLOWED_HOSTS = ['*'] + +DATABASE = { + 'NAME': 'netbox', + 'USER': '', + 'PASSWORD': '', + 'HOST': 'localhost', + 'PORT': '', + 'CONN_MAX_AGE': 300, +} + +PLUGINS = [ + 'extras.tests.dummy_plugin', +] + +REDIS = { + 'tasks': { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + }, + 'caching': { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, + } +} + +SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' diff --git a/scripts/cibuild.sh b/scripts/cibuild.sh index 282000b0a..6a0422308 100755 --- a/scripts/cibuild.sh +++ b/scripts/cibuild.sh @@ -34,11 +34,8 @@ if [[ $RC != 0 ]]; then EXIT=$RC fi -# Prepare configuration file for use in CI -CONFIG="netbox/netbox/configuration.py" -cp netbox/netbox/configuration.example.py $CONFIG -sed -i -e "s/ALLOWED_HOSTS = \[\]/ALLOWED_HOSTS = \['*'\]/g" $CONFIG -sed -i -e "s/SECRET_KEY = ''/SECRET_KEY = 'netboxci'/g" $CONFIG +# Point to the testing configuration file for use in CI +ln -s configuration.testing.py netbox/netbox/configuration.py # Run NetBox tests coverage run --source="netbox/" netbox/manage.py test netbox/