Compare commits

...

14 Commits

Author SHA1 Message Date
Arthur
5c1d1d6001 documentation 2026-04-16 11:49:54 -07:00
Arthur
bbd2796c17 documentation 2026-04-16 11:29:59 -07:00
Arthur
3a30dc5dbc internationalize strings 2026-04-16 11:02:42 -07:00
Arthur
86e29cd3f6 cleanup 2026-04-16 09:27:15 -07:00
Arthur
a2845d190e cleanup 2026-04-16 09:20:47 -07:00
Arthur
7f14434162 cleanup 2026-04-16 09:10:45 -07:00
Arthur
ba9d060803 Merge branch 'main' into 21782-config 2026-04-15 16:33:56 -07:00
Martin Hauser
c28736e1d6 Fixes #21913: Restore plugin template extension support on declarative-layout detail views (#21928) 2026-04-15 14:29:17 -05:00
Jeremy Stretch
f0fc93d827 Fixes #21683: Fix support for importing port mappings on device/module types (#21921) 2026-04-15 19:45:26 +02:00
Jeremy Stretch
bf9de4721e Closes #20881: get_filterset_for_model() should reference application registry (#21922) 2026-04-15 19:36:33 +02:00
Jeremy Stretch
bce667300a Fixes #21737: Check that uploaded custom scripts are valid Python modules before saving (#21920) 2026-04-15 10:16:58 -07:00
Sergio López
660ca42149 Closes #21875: Allow subclasses of dict for API_TOKEN_PEPPERS 2026-04-14 16:59:49 -04:00
Arthur
2fde9db66e #21782 - Enable optional config template selection on Device 2026-04-13 15:41:42 -07:00
Arthur
46396d7667 #21782 - Enable optional config template selection on Device 2026-04-13 15:41:34 -07:00
17 changed files with 332 additions and 22 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

@@ -192,6 +192,12 @@ class DataFileView(generic.ObjectView):
layout.Column(
panels.DataFilePanel(),
panels.DataFileContentPanel(),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)
@@ -253,6 +259,12 @@ class JobLogView(generic.ObjectView):
layout.Row(
layout.Column(
ContextTablePanel('table', title=_('Log Entries')),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)
@@ -393,6 +405,12 @@ class ConfigRevisionView(generic.ObjectView):
layout.Column(
TemplatePanel('core/panels/configrevision_data.html'),
TemplatePanel('core/panels/configrevision_comment.html'),
PluginContentPanel('left_page'),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)

View File

@@ -150,9 +150,25 @@ class PortTemplateMappingImportForm(forms.ModelForm):
class Meta:
model = PortTemplateMapping
fields = [
'front_port', 'front_port_position', 'rear_port', 'rear_port_position',
'device_type', 'module_type', 'front_port', 'front_port_position', 'rear_port', 'rear_port_position',
]
def clean_device_type(self):
if device_type := self.cleaned_data['device_type']:
front_port = self.fields['front_port']
rear_port = self.fields['rear_port']
front_port.queryset = front_port.queryset.filter(device_type=device_type)
rear_port.queryset = rear_port.queryset.filter(device_type=device_type)
return device_type
def clean_module_type(self):
if module_type := self.cleaned_data['module_type']:
front_port = self.fields['front_port']
rear_port = self.fields['rear_port']
front_port.queryset = front_port.queryset.filter(module_type=module_type)
rear_port.queryset = rear_port.queryset.filter(module_type=module_type)
return module_type
class ModuleBayTemplateImportForm(forms.ModelForm):

View File

@@ -1633,6 +1633,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

@@ -2362,6 +2362,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

@@ -9,6 +9,7 @@ from rest_framework import serializers
from core.api.serializers_.jobs import JobSerializer
from core.choices import ManagedFileRootPathChoices
from extras.models import Script, ScriptModule
from extras.utils import validate_script_content
from netbox.api.serializers import ValidatedModelSerializer
from utilities.datetime import local_now
@@ -39,6 +40,15 @@ class ScriptModuleSerializer(ValidatedModelSerializer):
data = super().validate(data)
data.pop('file_root', None)
if file is not None:
# Validate that the uploaded script can be loaded as a Python module
content = file.read()
file.seek(0)
try:
validate_script_content(content, file.name)
except Exception as e:
raise serializers.ValidationError(
_("Error loading script: {error}").format(error=e)
)
data['file'] = file
return data

View File

@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from core.choices import JobIntervalChoices
from core.forms import ManagedFileForm
from extras.utils import validate_script_content
from utilities.datetime import local_now
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
@@ -64,6 +65,22 @@ class ScriptFileForm(ManagedFileForm):
"""
ManagedFileForm with a custom save method to use django-storages.
"""
def clean(self):
super().clean()
if upload_file := self.cleaned_data.get('upload_file'):
# Validate that the uploaded script can be loaded as a Python module
content = upload_file.read()
upload_file.seek(0)
try:
validate_script_content(content, upload_file.name)
except Exception as e:
raise forms.ValidationError(
_("Error loading script: {error}").format(error=e)
)
return self.cleaned_data
def save(self, *args, **kwargs):
# If a file was uploaded, save it to disk
if self.cleaned_data['upload_file']:

View File

@@ -1450,6 +1450,21 @@ class ScriptModuleTest(APITestCase):
self.assertTrue(ScriptModule.objects.filter(file_path='test_upload.py').exists())
self.assertTrue(Script.objects.filter(module__file_path='test_upload.py', name='TestScript').exists())
def test_upload_faulty_script_module(self):
"""Uploading a script with an import error should return 400 and not create a DB record."""
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
# 'extras.script' is invalid; the correct module is 'extras.scripts'
script_content = b"from extras.script import Script\nclass TestScript(Script):\n pass\n"
upload_file = SimpleUploadedFile('test_faulty.py', script_content, content_type='text/plain')
response = self.client.post(
self.url,
{'file': upload_file},
format='multipart',
**self.header,
)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertFalse(ScriptModule.objects.filter(file_path='test_faulty.py').exists())
def test_upload_script_module_without_file_fails(self):
self.add_permissions('extras.add_scriptmodule', 'core.add_managedfile')
response = self.client.post(self.url, {}, format='json', **self.header)

View File

@@ -1,4 +1,5 @@
import importlib
import types
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
@@ -21,6 +22,7 @@ __all__ = (
'is_script',
'is_taggable',
'run_validators',
'validate_script_content',
)
@@ -134,6 +136,17 @@ def is_script(obj):
return False
def validate_script_content(content, filename):
"""
Validate that the given content can be loaded as a Python module by compiling
and executing it. Raises an exception if the script cannot be loaded.
"""
code = compile(content, filename, 'exec')
module_name = Path(filename).stem
module = types.ModuleType(module_name)
exec(code, module.__dict__)
def is_report(obj):
"""
Returns True if the given object is a Report.

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

@@ -16,6 +16,7 @@ from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
@@ -55,11 +56,13 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
layout.Column(
panels.VRFPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
RelatedObjectsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -70,6 +73,11 @@ class VRFView(GetRelatedModelsMixin, generic.ObjectView):
ContextTablePanel('export_targets_table', title=_('Export route targets')),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)
def get_extra_context(self, request, instance):
@@ -169,10 +177,12 @@ class RouteTargetView(generic.ObjectView):
layout.Column(
panels.RouteTargetPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
@@ -207,6 +217,11 @@ class RouteTargetView(generic.ObjectView):
),
),
),
layout.Row(
layout.Column(
PluginContentPanel('full_width_page'),
),
),
)

View File

@@ -45,6 +45,7 @@ from netbox.graphql.types import (
PrimaryObjectType,
)
from netbox.models import NestedGroupModel, NetBoxModel, OrganizationalModel, PrimaryModel
from netbox.registry import registry
from netbox.tables import (
NestedGroupModelTable,
NetBoxTable,
@@ -174,11 +175,10 @@ class FilterSetClassesTestCase(TestCase):
@staticmethod
def get_filterset_for_model(model):
"""
Import and return the filterset class for a given model.
Return the filterset class for a given model from the application registry.
"""
app_label = model._meta.app_label
model_name = model.__name__
return import_string(f'{app_label}.filtersets.{model_name}FilterSet')
label = f'{model._meta.app_label}.{model._meta.model_name}'
return registry['filtersets'].get(label)
@staticmethod
def get_model_filterset_base_class(model):
@@ -204,6 +204,7 @@ class FilterSetClassesTestCase(TestCase):
for model in apps.get_models():
if base_class := self.get_model_filterset_base_class(model):
filterset = self.get_filterset_for_model(model)
self.assertIsNotNone(filterset, f"No registered filterset found for model {model}")
self.assertTrue(
issubclass(filterset, base_class),
f"{filterset} does not inherit from {base_class}",

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

@@ -9,7 +9,7 @@ def validate_peppers(peppers):
"""
Validate the given dictionary of cryptographic peppers for type & sufficient length.
"""
if type(peppers) is not dict:
if not isinstance(peppers, dict):
raise ImproperlyConfigured("API_TOKEN_PEPPERS must be a dictionary.")
for key, pepper in peppers.items():
if type(key) is not int:

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