mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-16 05:59:55 +02:00
Compare commits
8 Commits
21751-opti
...
21782-conf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9d060803 | ||
|
|
c28736e1d6 | ||
|
|
f0fc93d827 | ||
|
|
bf9de4721e | ||
|
|
bce667300a | ||
|
|
660ca42149 | ||
|
|
2fde9db66e | ||
|
|
46396d7667 |
@@ -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'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -1633,6 +1633,32 @@ 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')
|
||||
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 invalid config_template_id
|
||||
response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Module
|
||||
|
||||
@@ -2362,6 +2362,32 @@ 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')
|
||||
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 invalid 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)
|
||||
|
||||
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')
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -85,15 +86,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.get(pk=config_template_id)
|
||||
except ConfigTemplate.DoesNotExist:
|
||||
return Response({
|
||||
'error': f'Config template with ID {config_template_id} not found.'
|
||||
}, status=HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
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({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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.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 and not error_message:
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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>
|
||||
{% trans 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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -343,6 +343,34 @@ 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'
|
||||
)
|
||||
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 invalid config_template_id
|
||||
response = self.client.post(url, {'config_template_id': 999999}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VMInterface
|
||||
|
||||
Reference in New Issue
Block a user