mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-16 22:19:53 +02:00
Compare commits
14 Commits
21688-redu
...
21782-conf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1d1d6001 | ||
|
|
bbd2796c17 | ||
|
|
3a30dc5dbc | ||
|
|
86e29cd3f6 | ||
|
|
a2845d190e | ||
|
|
7f14434162 | ||
|
|
ba9d060803 | ||
|
|
c28736e1d6 | ||
|
|
f0fc93d827 | ||
|
|
bf9de4721e | ||
|
|
bce667300a | ||
|
|
660ca42149 | ||
|
|
2fde9db66e | ||
|
|
46396d7667 |
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.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:
|
||||
|
||||
@@ -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>
|
||||
{{ 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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user