From e13bf48a35a2f979ef4c8460cd6f447977029e89 Mon Sep 17 00:00:00 2001
From: Pavel Korovin
Date: Fri, 17 Nov 2023 16:32:58 +0300
Subject: [PATCH] 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
---
netbox/dcim/api/views.py | 24 ++---------------
netbox/dcim/tests/test_api.py | 17 ++++++++++++
netbox/extras/api/mixins.py | 35 ++++++++++++++++++++++++-
netbox/virtualization/api/views.py | 6 ++---
netbox/virtualization/tests/test_api.py | 17 ++++++++++++
5 files changed, 73 insertions(+), 26 deletions(-)
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index a3e532f0b..cd5a297c9 100644
--- a/netbox/dcim/api/views.py
+++ b/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.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 import filtersets
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
@@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
- ConfigTemplateRenderMixin,
+ RenderConfigMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
@@ -420,23 +417,6 @@ class DeviceViewSet(
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/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index d3211a75f..f36b11033 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -6,6 +6,7 @@ from rest_framework import status
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 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
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 b6be47bbb..1737ff9f8 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 @@ class ConfigContextQuerySetMixin:
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 @@ class ConfigTemplateRenderMixin:
'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 04e8f2167..2b28505ab 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -1,7 +1,7 @@
from rest_framework.routers import APIRootView
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 utilities.query_functions import CollateAsChar
from utilities.utils import count_related
@@ -53,9 +53,9 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines
#
-class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
+class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, 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
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 3fb46fbb9..b33f3afe9 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -3,6 +3,7 @@ from rest_framework import status
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 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
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