Merge pull request #21932 from netbox-community/21782-config

21782 Enable optional config template override in URL
This commit is contained in:
bctiemann 2026-04-21 13:24:10 -04:00 committed by GitHub
commit 6a9c3dad17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 221 additions and 16 deletions

View File

@ -75,6 +75,39 @@ The configuration can be rendered as JSON or as plaintext by setting the `Accept
* `Accept: application/json`
* `Accept: text/plain`
### Overriding the Config Template
To render a specific config template against a device's context data - rather than the template resolved via the fallback chain above — include `config_template_id` in the request body:
```no-highlight
curl -X POST \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
http://netbox:8000/api/dcim/devices/123/render-config/ \
--data '{
"config_template_id": 42
}'
```
This is useful for rendering partial or alternative templates against a device's assembled context without changing any stored assignments. Any additional keys in the request body are passed into the template as context variables alongside the device's own config context data, as with standard rendering:
```no-highlight
--data '{
"config_template_id": 42,
"environment": "staging"
}'
```
!!! note "Permissions"
Overriding the config template requires the requesting user to have `view` permission for the "Extras > Config Template" object type in addition to the `render_config` permission on the device.
The same override is available in the UI by appending `config_template_id` as a query parameter to the device's render config URL:
```no-highlight
/dcim/devices/123/render-config/?config_template_id=42
```
### General Purpose Use
NetBox config templates can also be rendered without being tied to any specific device, using a separate general purpose REST API endpoint. Any data included with a POST request to this endpoint will be passed as context data for the template.

View File

@ -1634,6 +1634,41 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_render_config_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ device.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ device.name }}'
)
device = Device.objects.first()
device.config_template = default_template
device.save()
self.add_permissions('dcim.render_config_device', 'dcim.view_device', 'extras.view_configtemplate')
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
# Render with override template
response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Override config for {device.name}')
# Render with nonexistent config_template_id
response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Render with non-integer config_template_id
response = self.client.post(url, {'config_template_id': 'abc'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Without view_configtemplate permission, override template should not be accessible
self.remove_permissions('extras.view_configtemplate')
response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module

View File

@ -2384,6 +2384,43 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
self.remove_permissions('dcim.view_device')
self.assertHttpStatus(self.client.get(url), 403)
def test_device_renderconfig_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ device.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ device.name }}'
)
device = Device.objects.first()
device.config_template = default_template
device.save()
self.add_permissions('dcim.view_device', 'dcim.render_config_device', 'extras.view_configtemplate')
url = reverse('dcim:device_render-config', kwargs={'pk': device.pk})
# Render with override config_template_id
response = self.client.get(url, {'config_template_id': override_template.pk})
self.assertHttpStatus(response, 200)
self.assertIn(b'Override config for', response.content)
# Render with nonexistent config_template_id still returns 200 with error message
response = self.client.get(url, {'config_template_id': 999999})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
# Render with non-integer config_template_id still returns 200 with error message
response = self.client.get(url, {'config_template_id': 'abc'})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
# Without view_configtemplate permission, override template should not be accessible
self.remove_permissions('extras.view_configtemplate')
response = self.client.get(url, {'config_template_id': override_template.pk})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
def test_device_role_display_colored(self):
parent_role = DeviceRole.objects.create(name='Parent Role', slug='parent-role', color='111111')
child_role = DeviceRole.objects.create(name='Child Role', slug='child-role', parent=parent_role, color='aa00bb')

View File

@ -1,9 +1,11 @@
from django.utils.translation import gettext as _
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 extras.models import ConfigTemplate
from netbox.api.authentication import TokenWritePermission
from netbox.api.renderers import TextRenderer
@ -76,7 +78,7 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
@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 Device or Virtual Machine.
"""
# Override restrict() on the default queryset to enforce the render_config & view actions
self.queryset = self.queryset.model.objects.restrict(request.user, 'render_config').restrict(
@ -85,15 +87,25 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
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)
# Check for an optional config_template_id override in the request data
if config_template_id := request.data.get('config_template_id'):
try:
configtemplate = ConfigTemplate.objects.restrict(request.user, 'view').get(pk=config_template_id)
except (ConfigTemplate.DoesNotExist, ValueError):
return Response({
'error': _('Config template with ID {id} not found.').format(id=config_template_id)
}, status=HTTP_400_BAD_REQUEST)
else:
configtemplate = instance.get_config_template()
if not configtemplate:
return Response({
'error': _('No config template found for this {object_type}.').format(object_type=object_type)
}, status=HTTP_400_BAD_REQUEST)
# Compile context data
context_data = instance.get_config_context()
context_data.update(request.data)
context_data.update({k: v for k, v in request.data.items() if k != 'config_template_id'})
context_data.update({object_type: instance})
return self.render_configtemplate(request, configtemplate, context_data)

View File

@ -1268,10 +1268,20 @@ class ObjectRenderConfigView(generic.ObjectView):
context_data = instance.get_config_context()
context_data.update(self.get_extra_context_data(request, instance))
# Check for an optional config_template_id override in the query params
config_template = None
error_message = ''
if config_template_id := request.GET.get('config_template_id'):
try:
config_template = ConfigTemplate.objects.restrict(request.user, 'view').get(pk=config_template_id)
except (ConfigTemplate.DoesNotExist, ValueError):
error_message = _("Config template with ID {id} not found.").format(id=config_template_id)
else:
config_template = instance.get_config_template()
# Render the config template
rendered_config = None
error_message = ''
if config_template := instance.get_config_template():
if config_template:
try:
rendered_config = config_template.render(context=context_data)
except TemplateError as e:

View File

@ -49,13 +49,18 @@
</div>
<div class="row">
<div class="col">
{% if config_template %}
{% if error_message %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{{ error_message }}
</div>
{% elif config_template %}
{% if rendered_config %}
<div class="card">
<h2 class="card-header d-flex justify-content-between">
{% trans "Rendered Config" %}
<div class="card-actions">
<a href="?export=True" class="btn btn-sm btn-ghost-primary" role="button">
<a href="?export=True{% if request.GET.config_template_id %}&config_template_id={{ request.GET.config_template_id }}{% endif %}" class="btn btn-sm btn-ghost-primary" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
{% copy_content "rendered_config" %}
@ -63,11 +68,6 @@
</h2>
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
</div>
{% elif error_message %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
{% trans error_message %}
</div>
{% else %}
<div class="alert alert-warning">
<h4 class="alert-title mb-1">{% trans "Template output is empty" %}</h4>

View File

@ -343,6 +343,44 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_render_config_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ virtualmachine.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = default_template
vm.save()
self.add_permissions(
'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine',
'extras.view_configtemplate'
)
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
# Render with override template
response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Override config for {vm.name}')
# Render with nonexistent config_template_id
response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Render with non-integer config_template_id
response = self.client.post(url, {'config_template_id': 'abc'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# Without view_configtemplate permission, override template should not be accessible
self.remove_permissions('extras.view_configtemplate')
response = self.client.post(url, {'config_template_id': override_template.pk}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface

View File

@ -357,6 +357,46 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
self.remove_permissions('virtualization.view_virtualmachine')
self.assertHttpStatus(self.client.get(url), 403)
def test_virtualmachine_renderconfig_with_config_template_id(self):
default_template = ConfigTemplate.objects.create(
name='Default Template',
template_code='Default config for {{ virtualmachine.name }}'
)
override_template = ConfigTemplate.objects.create(
name='Override Template',
template_code='Override config for {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = default_template
vm.save()
self.add_permissions(
'virtualization.view_virtualmachine', 'virtualization.render_config_virtualmachine',
'extras.view_configtemplate'
)
url = reverse('virtualization:virtualmachine_render-config', kwargs={'pk': vm.pk})
# Render with override config_template_id
response = self.client.get(url, {'config_template_id': override_template.pk})
self.assertHttpStatus(response, 200)
self.assertIn(b'Override config for', response.content)
# Render with nonexistent config_template_id still returns 200 with error message
response = self.client.get(url, {'config_template_id': 999999})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
# Render with non-integer config_template_id still returns 200 with error message
response = self.client.get(url, {'config_template_id': 'abc'})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
# Without view_configtemplate permission, override template should not be accessible
self.remove_permissions('extras.view_configtemplate')
response = self.client.get(url, {'config_template_id': override_template.pk})
self.assertHttpStatus(response, 200)
self.assertIn(b'Error rendering template', response.content)
class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = VMInterface