Просмотр исходного кода

Add /api/virtualization/virtual-machines/{id}/render-config/ endpoint (#14287)

* Add /api/virtualization/virtual-machines/{id}/render-config/ endpoint

* Update Docstring "Device" -> "Virtual Machine"

Docstring should mention "..this Virtual Machine" instead of "...this Device", thanks @LuPo!

* Move config rendering logic to new RenderConfigMixin

* Add tests for render-config API endpoint

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Pavel Korovin 2 лет назад
Родитель
Сommit
e13bf48a35

+ 2 - 22
netbox/dcim/api/views.py

@@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema, OpenApiParameter
 from drf_spectacular.utils import extend_schema, OpenApiParameter
 from rest_framework.decorators import action
 from rest_framework.decorators import action
-from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
-from rest_framework.status import HTTP_400_BAD_REQUEST
 from rest_framework.viewsets import ViewSet
 from rest_framework.viewsets import ViewSet
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
@@ -14,12 +12,11 @@ from dcim import filtersets
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from dcim.models import *
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 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 ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import StripCountAnnotationsPaginator
 from netbox.api.pagination import StripCountAnnotationsPaginator
-from netbox.api.renderers import TextRenderer
 from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
 from netbox.constants import NESTED_SERIALIZER_PREFIX
 from netbox.constants import NESTED_SERIALIZER_PREFIX
@@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
 class DeviceViewSet(
 class DeviceViewSet(
     SequentialBulkCreatesMixin,
     SequentialBulkCreatesMixin,
     ConfigContextQuerySetMixin,
     ConfigContextQuerySetMixin,
-    ConfigTemplateRenderMixin,
+    RenderConfigMixin,
     NetBoxModelViewSet
     NetBoxModelViewSet
 ):
 ):
     queryset = Device.objects.prefetch_related(
     queryset = Device.objects.prefetch_related(
@@ -420,23 +417,6 @@ class DeviceViewSet(
 
 
         return serializers.DeviceWithConfigContextSerializer
         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):
 class VirtualDeviceContextViewSet(NetBoxModelViewSet):
     queryset = VirtualDeviceContext.objects.prefetch_related(
     queryset = VirtualDeviceContext.objects.prefetch_related(

+ 17 - 0
netbox/dcim/tests/test_api.py

@@ -6,6 +6,7 @@ from rest_framework import status
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
+from extras.models import ConfigTemplate
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.api.serializers import GenericObjectSerializer
 from netbox.api.serializers import GenericObjectSerializer
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
@@ -1265,6 +1266,22 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
 
 
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         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):
 class ModuleTest(APIViewTestCases.APIViewTestCase):
     model = Module
     model = Module

+ 34 - 1
netbox/extras/api/mixins.py

@@ -1,10 +1,16 @@
 from jinja2.exceptions import TemplateError
 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.response import Response
+from rest_framework.status import HTTP_400_BAD_REQUEST
 
 
+from netbox.api.renderers import TextRenderer
 from .nested_serializers import NestedConfigTemplateSerializer
 from .nested_serializers import NestedConfigTemplateSerializer
 
 
 __all__ = (
 __all__ = (
     'ConfigContextQuerySetMixin',
     'ConfigContextQuerySetMixin',
+    'ConfigTemplateRenderMixin',
+    'RenderConfigMixin',
 )
 )
 
 
 
 
@@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin:
 
 
 
 
 class ConfigTemplateRenderMixin:
 class ConfigTemplateRenderMixin:
-
+    """
+    Provides a method to return a rendered ConfigTemplate as REST API data.
+    """
     def render_configtemplate(self, request, configtemplate, context):
     def render_configtemplate(self, request, configtemplate, context):
         try:
         try:
             output = configtemplate.render(context=context)
             output = configtemplate.render(context=context)
@@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin:
             'configtemplate': template_serializer.data,
             'configtemplate': template_serializer.data,
             'content': output
             '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)

+ 3 - 3
netbox/virtualization/api/views.py

@@ -1,7 +1,7 @@
 from rest_framework.routers import APIRootView
 from rest_framework.routers import APIRootView
 
 
 from dcim.models import Device
 from dcim.models import Device
-from extras.api.mixins import ConfigContextQuerySetMixin
+from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
 from netbox.api.viewsets import NetBoxModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -53,9 +53,9 @@ class ClusterViewSet(NetBoxModelViewSet):
 # Virtual machines
 # Virtual machines
 #
 #
 
 
-class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
     queryset = VirtualMachine.objects.prefetch_related(
     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
     filterset_class = filtersets.VirtualMachineFilterSet
 
 

+ 17 - 0
netbox/virtualization/tests/test_api.py

@@ -3,6 +3,7 @@ from rest_framework import status
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.models import Site
 from dcim.models import Site
+from extras.models import ConfigTemplate
 from ipam.models import VLAN, VRF
 from ipam.models import VLAN, VRF
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from virtualization.choices import *
 from virtualization.choices import *
@@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         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):
 class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
     model = VMInterface
     model = VMInterface