Compare commits

..

4 Commits

Author SHA1 Message Date
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
Martin Hauser
9b734bac93 chore(ci): Update GitHub Actions to use commit SHA pinning
Bump actions/create-github-app-token from v1 to v3.1.1 and
EndBug/add-and-commit from v9.1.4 to v10.0.0, both pinned to full commit
SHAs for improved supply chain security.

Fixes #21896
2026-04-13 08:04:55 -04:00
Martin Hauser
0f277894b2 chore(ci): Update ruff-action to v4.0.0
Update ruff GitHub Action from v3.6.1 to v4.0.0 and bump ruff version
from 0.15.2 to 0.15.10 for latest linting improvements.

Fixes #21682
2026-04-13 08:03:58 -04:00
10 changed files with 120 additions and 48 deletions

View File

@@ -56,9 +56,9 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Python linting & PEP8 compliance
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
uses: astral-sh/ruff-action@0ce1b0bf8b818ef400413f810f8a11cdbda0034b # v4.0.0
with:
version: "0.15.2"
version: "0.15.10"
args: "check --output-format=github"
src: "netbox/"

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Create app token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
with:
app-id: 1076524
@@ -48,7 +48,7 @@ jobs:
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
- name: Commit changes
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0
with:
add: 'netbox/translations/'
default_author: github_actions

View File

@@ -6,7 +6,6 @@ from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
from ipam.models import Prefix
from netbox.signals import post_raw_create
from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN
@@ -167,27 +166,6 @@ def retrace_cable_paths(instance, **kwargs):
cablepath.retrace()
@receiver(post_raw_create, sender=Cable)
def retrace_cable_paths_after_raw_create(sender, pks, **kwargs):
"""
When Cables are created via a raw save, the normal Cable.save() path is bypassed,
so trace_paths is never sent. Retrace paths for all newly created cables.
Callers must only send this signal after all CableTerminations for the given cables
have been applied. If a cable has no terminations, update_connected_endpoints will
find empty termination lists and skip path creation — so this is safe to call even
if terminations are absent, but path tracing will have no effect.
Note: raw=False (the default) is intentional here — we explicitly want
update_connected_endpoints to run, unlike during fixture loading (raw=True).
"""
logger = logging.getLogger('netbox.dcim.cable')
for cable in Cable.objects.filter(pk__in=pks):
cable._terminations_modified = True
trace_paths.send(Cable, instance=cable, created=True)
logger.debug(f"Retraced cable paths for Cable {cable.pk}")
@receiver((post_delete, post_save), sender=PortMapping)
def update_passthrough_port_paths(instance, **kwargs):
"""

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

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.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:

View File

@@ -2,10 +2,3 @@ from django.dispatch import Signal
# Signals that a model has completed its clean() method
post_clean = Signal()
# Sent after objects of a given model are created via raw save.
# Expected call signature: post_raw_create.send(sender=MyModel, pks=[...])
# Provides: pks (list) - PKs of the newly created objects.
# Callers must ensure all related objects (e.g. M2M, dependent rows) are in place
# before sending, as receivers may query related data to perform post-create work.
post_raw_create = Signal()

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>
{% 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>

View File

@@ -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