Merge branch 'main' into feature
Some checks are pending
CI / build (20.x, 3.13) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Waiting to run

This commit is contained in:
Jeremy Stretch
2025-11-12 08:08:32 -05:00
76 changed files with 8920 additions and 7238 deletions

View File

@@ -1736,6 +1736,15 @@ class CableTypeChoices(ChoiceSet):
# Copper - Coaxial
TYPE_COAXIAL = 'coaxial'
TYPE_RG_6 = 'rg-6'
TYPE_RG_8 = 'rg-8'
TYPE_RG_11 = 'rg-11'
TYPE_RG_59 = 'rg-59'
TYPE_RG_62 = 'rg-62'
TYPE_RG_213 = 'rg-213'
TYPE_LMR_100 = 'lmr-100'
TYPE_LMR_200 = 'lmr-200'
TYPE_LMR_400 = 'lmr-400'
# Fiber Optic - Multimode
TYPE_MMF = 'mmf'
@@ -1785,6 +1794,15 @@ class CableTypeChoices(ChoiceSet):
_('Copper - Coaxial'),
(
(TYPE_COAXIAL, 'Coaxial'),
(TYPE_RG_6, 'RG-6'),
(TYPE_RG_8, 'RG-8'),
(TYPE_RG_11, 'RG-11'),
(TYPE_RG_59, 'RG-59'),
(TYPE_RG_62, 'RG-62'),
(TYPE_RG_213, 'RG-213'),
(TYPE_LMR_100, 'LMR-100'),
(TYPE_LMR_200, 'LMR-200'),
(TYPE_LMR_400, 'LMR-400'),
),
),
(

View File

@@ -9,7 +9,8 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import VRF, IPAddress
from ipam.choices import VLANQinQRoleChoices
from ipam.models import VLAN, VRF, IPAddress, VLANGroup
from netbox.choices import *
from netbox.forms import (
NestedGroupModelImportForm, NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin,
@@ -20,7 +21,7 @@ from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField,
SlugField,
)
from virtualization.models import Cluster, VMInterface, VirtualMachine
from virtualization.models import Cluster, VirtualMachine, VMInterface
from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm
@@ -941,7 +942,7 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
required=False,
to_field_name='name',
help_text=mark_safe(
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>"vdc1,vdc2,vdc3"</code>'
)
)
type = CSVChoiceField(
@@ -970,7 +971,41 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
label=_('Mode'),
choices=InterfaceModeChoices,
required=False,
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'),
)
vlan_group = CSVModelChoiceField(
label=_('VLAN group'),
queryset=VLANGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Filter VLANs available for assignment by group'),
)
untagged_vlan = CSVModelChoiceField(
label=_('Untagged VLAN'),
queryset=VLAN.objects.all(),
required=False,
to_field_name='vid',
help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'),
)
tagged_vlans = CSVModelMultipleChoiceField(
label=_('Tagged VLANs'),
queryset=VLAN.objects.all(),
required=False,
to_field_name='vid',
help_text=mark_safe(
_(
'Assigned tagged VLAN IDs separated by commas, encased with double quotes '
'(filtered by VLAN group). Example:'
)
+ ' <code>"100,200,300"</code>'
),
)
qinq_svlan = CSVModelChoiceField(
label=_('Q-in-Q Service VLAN'),
queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE),
required=False,
to_field_name='vid',
help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'),
)
vrf = CSVModelChoiceField(
label=_('VRF'),
@@ -991,7 +1026,8 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
)
def __init__(self, data=None, *args, **kwargs):
@@ -1008,6 +1044,13 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
# Limit choices for VLANs to the assigned VLAN group
if vlan_group := data.get('vlan_group'):
params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group}
self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params)
self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params)
self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params)
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data:

View File

@@ -453,6 +453,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
if instance.pk and self.cleaned_data['members']:
initial_position = self.cleaned_data.get('initial_position', 1)
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
member.snapshot()
member.virtual_chassis = instance
member.vc_position = i
member.save()

View File

@@ -1154,7 +1154,6 @@ class VirtualChassis(PrimaryModel):
})
def delete(self, *args, **kwargs):
# Check for LAG interfaces split across member chassis
interfaces = Interface.objects.filter(
device__in=self.members.all(),
@@ -1168,6 +1167,13 @@ class VirtualChassis(PrimaryModel):
"interfaces."
).format(self=self, interfaces=InterfaceSpeedChoices))
# Clear vc_position and vc_priority on member devices BEFORE calling super().delete()
# This must be done here because on_delete=SET_NULL executes before pre_delete signal
for device in self.members.all():
device.vc_position = None
device.vc_priority = None
device.save()
return super().delete(*args, **kwargs)

View File

@@ -1,4 +1,4 @@
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.object_actions import ObjectAction

View File

@@ -1,6 +1,6 @@
import logging
from django.db.models.signals import post_save, post_delete, pre_delete
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from dcim.choices import CableEndChoices, LinkStatusChoices
@@ -85,18 +85,6 @@ def assign_virtualchassis_master(instance, created, **kwargs):
master.save()
@receiver(pre_delete, sender=VirtualChassis)
def clear_virtualchassis_members(instance, **kwargs):
"""
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
"""
devices = Device.objects.filter(virtual_chassis=instance.pk)
for device in devices:
device.vc_position = None
device.vc_priority = None
device.save()
#
# Cables
#

View File

@@ -1031,3 +1031,92 @@ class VirtualDeviceContextTestCase(TestCase):
vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
with self.assertRaises(ValidationError):
vdc2.full_clean()
class VirtualChassisTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
role = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
Device.objects.create(
device_type=devicetype, role=role, name='TestDevice1', site=site
)
Device.objects.create(
device_type=devicetype, role=role, name='TestDevice2', site=site
)
def test_virtualchassis_deletion_clears_vc_position(self):
"""
Test that when a VirtualChassis is deleted, member devices have their
vc_position and vc_priority fields set to None.
"""
devices = Device.objects.all()
device1 = devices[0]
device2 = devices[1]
# Create a VirtualChassis with two member devices
vc = VirtualChassis.objects.create(name='Test VC', master=device1)
device1.virtual_chassis = vc
device1.vc_position = 1
device1.vc_priority = 10
device1.save()
device2.virtual_chassis = vc
device2.vc_position = 2
device2.vc_priority = 20
device2.save()
# Verify devices are members of the VC with positions set
device1.refresh_from_db()
device2.refresh_from_db()
self.assertEqual(device1.virtual_chassis, vc)
self.assertEqual(device1.vc_position, 1)
self.assertEqual(device1.vc_priority, 10)
self.assertEqual(device2.virtual_chassis, vc)
self.assertEqual(device2.vc_position, 2)
self.assertEqual(device2.vc_priority, 20)
# Delete the VirtualChassis
vc.delete()
# Verify devices have vc_position and vc_priority set to None
device1.refresh_from_db()
device2.refresh_from_db()
self.assertIsNone(device1.virtual_chassis)
self.assertIsNone(device1.vc_position)
self.assertIsNone(device1.vc_priority)
self.assertIsNone(device2.virtual_chassis)
self.assertIsNone(device2.vc_position)
self.assertIsNone(device2.vc_priority)
def test_virtualchassis_duplicate_vc_position(self):
"""
Test that two devices cannot be assigned to the same vc_position
within the same VirtualChassis.
"""
devices = Device.objects.all()
device1 = devices[0]
device2 = devices[1]
# Create a VirtualChassis
vc = VirtualChassis.objects.create(name='Test VC')
# Assign first device to vc_position 1
device1.virtual_chassis = vc
device1.vc_position = 1
device1.full_clean()
device1.save()
# Try to assign second device to the same vc_position
device2.virtual_chassis = vc
device2.vc_position = 1
with self.assertRaises(ValidationError):
device2.full_clean()

View File

@@ -986,6 +986,131 @@ inventory-items:
ii1 = InventoryItemTemplate.objects.first()
self.assertEqual(ii1.name, 'Inventory Item 1')
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_error_numbering(self):
# Add all required permissions to the test user
self.add_permissions(
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
'dcim.add_devicebaytemplate',
'dcim.add_inventoryitemtemplate',
)
import_data = '''
---
manufacturer: Manufacturer 1
model: TEST-2001
slug: test-2001
u_height: 1
module-bays:
- name: Module Bay 1-1
- name: Module Bay 1-2
---
- manufacturer: Manufacturer 1
model: TEST-2002
slug: test-2002
u_height: 1
module-bays:
- name: Module Bay 2-1
- name: Module Bay 2-2
- not_name: Module Bay 2-3
- manufacturer: Manufacturer 1
model: TEST-2003
slug: test-2003
u_height: 1
module-bays:
- name: Module Bay 3-1
'''
form_data = {
'data': import_data,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
self.assertContains(response, "Record 2 module-bays[3].name: This field is required.")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_nolist(self):
# Add all required permissions to the test user
self.add_permissions(
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
'dcim.add_devicebaytemplate',
'dcim.add_inventoryitemtemplate',
)
for value in ('', 'null', '3', '"My console port"', '{name: "My other console port"}'):
with self.subTest(value=value):
import_data = f'''
manufacturer: Manufacturer 1
model: TEST-3000
slug: test-3000
u_height: 1
console-ports: {value}
'''
form_data = {
'data': import_data,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
self.assertContains(response, "Record 1 console-ports: Must be a list.")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_nodict(self):
# Add all required permissions to the test user
self.add_permissions(
'dcim.view_devicetype',
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
'dcim.add_powerporttemplate',
'dcim.add_poweroutlettemplate',
'dcim.add_interfacetemplate',
'dcim.add_frontporttemplate',
'dcim.add_rearporttemplate',
'dcim.add_modulebaytemplate',
'dcim.add_devicebaytemplate',
'dcim.add_inventoryitemtemplate',
)
for value in ('', 'null', '3', '"My console port"', '["My other console port"]'):
with self.subTest(value=value):
import_data = f'''
manufacturer: Manufacturer 1
model: TEST-4000
slug: test-4000
u_height: 1
console-ports:
- {value}
'''
form_data = {
'data': import_data,
'format': 'yaml'
}
response = self.client.post(reverse('dcim:devicetype_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
self.assertContains(response, "Record 1 console-ports[1]: Must be a dictionary.")
def test_export_objects(self):
url = reverse('dcim:devicetype_list')
self.add_permissions('dcim.view_devicetype')
@@ -2834,10 +2959,19 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
}
cls.csv_data = (
"device,name,type,vrf.pk,poe_mode,poe_type",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
"device,name,type,vrf.pk,poe_mode,poe_type,mode,untagged_vlan,tagged_vlans",
(
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
),
(
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
),
(
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af,"
f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'"
),
)
cls.csv_update_data = (

View File

@@ -4044,6 +4044,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
def post(self, request, pk):
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
virtual_chassis.snapshot()
VCMemberFormSet = modelformset_factory(
model=Device,
form=forms.DeviceVCMembershipForm,
@@ -4096,9 +4097,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
return 'dcim.change_virtualchassis'
def get(self, request, pk):
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
initial_data = {k: request.GET[k] for k in request.GET}
member_select_form = forms.VCMemberSelectForm(initial=initial_data)
membership_form = forms.DeviceVCMembershipForm(initial=initial_data)
@@ -4111,20 +4110,20 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
})
def post(self, request, pk):
virtual_chassis = get_object_or_404(self.queryset, pk=pk)
member_select_form = forms.VCMemberSelectForm(request.POST)
if member_select_form.is_valid():
device = member_select_form.cleaned_data['device']
device.snapshot()
device.virtual_chassis = virtual_chassis
data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
data = {
'vc_position': request.POST['vc_position'],
'vc_priority': request.POST['vc_priority'],
}
membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
if membership_form.is_valid():
membership_form.save()
messages.success(request, mark_safe(
_('Added member <a href="{url}">{device}</a>').format(
@@ -4134,11 +4133,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
if '_addanother' in request.POST and safe_for_redirect(request.get_full_path()):
return redirect(request.get_full_path())
return redirect(self.get_return_url(request, device))
else:
membership_form = forms.DeviceVCMembershipForm(data=request.POST)
return render(request, 'dcim/virtualchassis_add_member.html', {
@@ -4156,7 +4153,6 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
return 'dcim.change_device'
def get(self, request, pk):
device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
form = ConfirmationForm(initial=request.GET)
@@ -4167,7 +4163,6 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
})
def post(self, request, pk):
device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
form = ConfirmationForm(request.POST)
@@ -4181,13 +4176,11 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
return redirect(device.get_absolute_url())
if form.is_valid():
devices = Device.objects.filter(pk=device.pk)
for device in devices:
device.virtual_chassis = None
device.vc_position = None
device.vc_priority = None
device.save()
device.snapshot()
device.virtual_chassis = None
device.vc_position = None
device.vc_priority = None
device.save()
msg = _('Removed {device} from virtual chassis {chassis}').format(
device=device,