From 777b721ccbc401f26f393b27c859b223425fc7f8 Mon Sep 17 00:00:00 2001
From: Pavel Korovin
Date: Thu, 16 Nov 2023 17:15:42 +0300
Subject: [PATCH 1/4] Add
/api/virtualization/virtual-machines/{id}/render-config/ endpoint
---
netbox/virtualization/api/views.py | 28 +++++++++++++++++++++++++---
1 file changed, 25 insertions(+), 3 deletions(-)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 5b9cf411733..d785c091759 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,7 +1,12 @@
+from rest_framework.decorators import action
+from rest_framework.renderers import JSONRenderer
+from rest_framework.response import Response
from rest_framework.routers import APIRootView
+from rest_framework.status import HTTP_400_BAD_REQUEST
from dcim.models import Device
-from extras.api.mixins import ConfigContextQuerySetMixin
+from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
+from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.utils import count_related
from virtualization import filtersets
@@ -52,9 +57,9 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines
#
-class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
- 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template', 'tags'
)
filterset_class = filtersets.VirtualMachineFilterSet
@@ -78,6 +83,23 @@ def get_serializer_class(self):
return serializers.VirtualMachineWithConfigContextSerializer
+ @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
+ def render_config(self, request, pk):
+ """
+ Resolve and render the preferred ConfigTemplate for this Device.
+ """
+ instance = self.get_object()
+ configtemplate = instance.get_config_template()
+ if not configtemplate:
+ return Response({'error': 'No config template found for this virtual machine.'}, status=HTTP_400_BAD_REQUEST)
+
+ # Compile context data
+ context_data = instance.get_config_context()
+ context_data.update(request.data)
+ context_data.update({'virtualmachine': instance})
+
+ return self.render_configtemplate(request, configtemplate, context_data)
+
class VMInterfaceViewSet(NetBoxModelViewSet):
queryset = VMInterface.objects.prefetch_related(
From 2b49d2ba60794c39e1de3f22db3b6097ea619987 Mon Sep 17 00:00:00 2001
From: Pavel Korovin
Date: Thu, 16 Nov 2023 15:04:58 +0000
Subject: [PATCH 2/4] Update Docstring "Device" -> "Virtual Machine"
Docstring should mention "..this Virtual Machine" instead of "...this Device", thanks @LuPo!
---
netbox/virtualization/api/views.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index d785c091759..af2bc42c2d6 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -86,7 +86,7 @@ def get_serializer_class(self):
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
def render_config(self, request, pk):
"""
- Resolve and render the preferred ConfigTemplate for this Device.
+ Resolve and render the preferred ConfigTemplate for this Virtual Machine.
"""
instance = self.get_object()
configtemplate = instance.get_config_template()
From 18597510cbe457b80582ffbc5f978e4ac2b6ad51 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 16 Nov 2023 13:39:35 -0500
Subject: [PATCH 3/4] Move config rendering logic to new RenderConfigMixin
---
netbox/dcim/api/views.py | 24 ++------------------
netbox/extras/api/mixins.py | 35 +++++++++++++++++++++++++++++-
netbox/virtualization/api/views.py | 26 ++--------------------
3 files changed, 38 insertions(+), 47 deletions(-)
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 80a99173653..44391dbcc86 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -3,10 +3,8 @@
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
-from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
-from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
@@ -14,12 +12,11 @@
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
-from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
+from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
-from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -389,7 +386,7 @@ class PlatformViewSet(NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
- ConfigTemplateRenderMixin,
+ RenderConfigMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
@@ -419,23 +416,6 @@ def get_serializer_class(self):
return serializers.DeviceWithConfigContextSerializer
- @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
- def render_config(self, request, pk):
- """
- Resolve and render the preferred ConfigTemplate for this Device.
- """
- device = self.get_object()
- configtemplate = device.get_config_template()
- if not configtemplate:
- return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
-
- # Compile context data
- context_data = device.get_config_context()
- context_data.update(request.data)
- context_data.update({'device': device})
-
- return self.render_configtemplate(request, configtemplate, context_data)
-
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related(
diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py
index b6be47bbbb1..f7f5842b9f1 100644
--- a/netbox/extras/api/mixins.py
+++ b/netbox/extras/api/mixins.py
@@ -1,10 +1,16 @@
from jinja2.exceptions import TemplateError
+from rest_framework.decorators import action
+from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
+from rest_framework.status import HTTP_400_BAD_REQUEST
+from netbox.api.renderers import TextRenderer
from .nested_serializers import NestedConfigTemplateSerializer
__all__ = (
'ConfigContextQuerySetMixin',
+ 'ConfigTemplateRenderMixin',
+ 'RenderConfigMixin',
)
@@ -31,7 +37,9 @@ def get_queryset(self):
class ConfigTemplateRenderMixin:
-
+ """
+ Provides a method to return a rendered ConfigTemplate as REST API data.
+ """
def render_configtemplate(self, request, configtemplate, context):
try:
output = configtemplate.render(context=context)
@@ -50,3 +58,28 @@ def render_configtemplate(self, request, configtemplate, context):
'configtemplate': template_serializer.data,
'content': output
})
+
+
+class RenderConfigMixin(ConfigTemplateRenderMixin):
+ """
+ Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
+ """
+ @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
+ def render_config(self, request, pk):
+ """
+ Resolve and render the preferred ConfigTemplate for this Device.
+ """
+ instance = self.get_object()
+ object_type = instance._meta._model_name
+ configtemplate = instance.get_config_template()
+ if not configtemplate:
+ return Response({
+ 'error': f'No config template found for this {object_type}.'
+ }, status=HTTP_400_BAD_REQUEST)
+
+ # Compile context data
+ context_data = instance.get_config_context()
+ context_data.update(request.data)
+ context_data.update({object_type: instance})
+
+ return self.render_configtemplate(request, configtemplate, context_data)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index af2bc42c2d6..e283a5aaa44 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,12 +1,7 @@
-from rest_framework.decorators import action
-from rest_framework.renderers import JSONRenderer
-from rest_framework.response import Response
from rest_framework.routers import APIRootView
-from rest_framework.status import HTTP_400_BAD_REQUEST
from dcim.models import Device
-from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
-from netbox.api.renderers import TextRenderer
+from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.utils import count_related
from virtualization import filtersets
@@ -57,7 +52,7 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines
#
-class VirtualMachineViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template', 'tags'
)
@@ -83,23 +78,6 @@ def get_serializer_class(self):
return serializers.VirtualMachineWithConfigContextSerializer
- @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
- def render_config(self, request, pk):
- """
- Resolve and render the preferred ConfigTemplate for this Virtual Machine.
- """
- instance = self.get_object()
- configtemplate = instance.get_config_template()
- if not configtemplate:
- return Response({'error': 'No config template found for this virtual machine.'}, status=HTTP_400_BAD_REQUEST)
-
- # Compile context data
- context_data = instance.get_config_context()
- context_data.update(request.data)
- context_data.update({'virtualmachine': instance})
-
- return self.render_configtemplate(request, configtemplate, context_data)
-
class VMInterfaceViewSet(NetBoxModelViewSet):
queryset = VMInterface.objects.prefetch_related(
From a212725d2e6c3d0f240d800823d6247eaa305360 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 17 Nov 2023 08:21:19 -0500
Subject: [PATCH 4/4] Add tests for render-config API endpoint
---
netbox/dcim/tests/test_api.py | 17 +++++++++++++++++
netbox/extras/api/mixins.py | 2 +-
netbox/virtualization/tests/test_api.py | 17 +++++++++++++++++
3 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index 1ce36296332..82b4b79178f 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -6,6 +6,7 @@
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
+from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
@@ -1265,6 +1266,22 @@ def test_rack_fit(self):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+ def test_render_config(self):
+ configtemplate = ConfigTemplate.objects.create(
+ name='Config Template 1',
+ template_code='Config for device {{ device.name }}'
+ )
+
+ device = Device.objects.first()
+ device.config_template = configtemplate
+ device.save()
+
+ self.add_permissions('dcim.add_device')
+ url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
+ response = self.client.post(url, {}, format='json', **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['content'], f'Config for device {device.name}')
+
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module
diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py
index f7f5842b9f1..1737ff9f830 100644
--- a/netbox/extras/api/mixins.py
+++ b/netbox/extras/api/mixins.py
@@ -70,7 +70,7 @@ def render_config(self, request, pk):
Resolve and render the preferred ConfigTemplate for this Device.
"""
instance = self.get_object()
- object_type = instance._meta._model_name
+ object_type = instance._meta.model_name
configtemplate = instance.get_config_template()
if not configtemplate:
return Response({
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index b2ae68860eb..237e406d8bc 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -3,6 +3,7 @@
from dcim.choices import InterfaceModeChoices
from dcim.models import Site
+from extras.models import ConfigTemplate
from ipam.models import VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.choices import *
@@ -228,6 +229,22 @@ def test_unique_name_per_cluster_constraint(self):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+ def test_render_config(self):
+ configtemplate = ConfigTemplate.objects.create(
+ name='Config Template 1',
+ template_code='Config for virtual machine {{ virtualmachine.name }}'
+ )
+
+ vm = VirtualMachine.objects.first()
+ vm.config_template = configtemplate
+ vm.save()
+
+ self.add_permissions('virtualization.add_virtualmachine')
+ url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
+ response = self.client.post(url, {}, format='json', **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
+
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface