Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/models/ipam/vlan.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

A Virtual LAN (VLAN) represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into [VLAN groups](./vlangroup.md) to define scope and to enforce uniqueness.

## Bulk Creation

Multiple VLANs can be created at once by selecting the "Bulk Create" tab on the VLAN creation form. Enter the desired VLAN IDs and/or ID ranges as a comma-separated list (e.g. `100,200-210,4000-4010`). The string `{vid}` may be embedded in the name field as a placeholder for each VLAN's ID; for example, `VLAN-{vid}` yields `VLAN-100`, `VLAN-200`, and so on. All other attributes (status, role, tenant, etc.) are applied to every new VLAN.

The operation is atomic: if any VLAN fails validation (for example, a VLAN ID falling outside the assigned group's permitted ranges), no VLANs are created.

## Fields

### ID
Expand Down
18 changes: 17 additions & 1 deletion netbox/ipam/forms/bulk_create.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django import forms
from django.utils.translation import gettext_lazy as _

from utilities.forms.fields import ExpandableIPNetworkField
from ipam.constants import VLAN_VID_MAX, VLAN_VID_MIN
from utilities.forms.fields import ExpandableIPNetworkField, NumericArrayField

__all__ = (
'IPNetworkBulkCreateForm',
'VLANIDBulkCreateForm',
)


Expand All @@ -15,3 +17,17 @@ class IPNetworkBulkCreateForm(forms.Form):
pattern = ExpandableIPNetworkField(
label=_('Pattern')
)


class VLANIDBulkCreateForm(forms.Form):
pattern = NumericArrayField(
base_field=forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX
),
label=_('VLAN IDs'),
help_text=_(
'Enter VLAN IDs and ranges separated by commas. '
'Example: 100,200-210,3100-3299'
)
)
21 changes: 21 additions & 0 deletions netbox/ipam/forms/model_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
'ServiceCreateForm',
'ServiceForm',
'ServiceTemplateForm',
'VLANBulkAddForm',
'VLANForm',
'VLANGroupForm',
'VLANTranslationPolicyForm',
Expand Down Expand Up @@ -727,6 +728,26 @@ class Meta:
]


class VLANBulkAddForm(VLANForm):
"""
Subclass of VLANForm for bulk creation.

The VID field is inherited but excluded from the visible fieldsets, as it is
populated programmatically by BulkCreateView from the expanded pattern.
"""
fieldsets = (
FieldSet('group', 'site', 'name', 'status', 'role', 'description', 'tags', name=_('VLAN')),
FieldSet('qinq_role', 'qinq_svlan', name=_('Q-in-Q/802.1ad')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name'].help_text = _(
'Use {vid} as a placeholder for the VLAN ID. Example: VLAN-{vid}.'
)


class VLANTranslationPolicyForm(PrimaryModelForm):

fieldsets = (
Expand Down
2 changes: 1 addition & 1 deletion netbox/ipam/models/vlans.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ def clean(self):
)

# Check that the VLAN ID is permitted in the assigned group (if any)
if self.group:
if self.group and self.vid is not None:
if not any([self.vid in r for r in self.group.vid_ranges]):
raise ValidationError({
'vid': _(
Expand Down
30 changes: 29 additions & 1 deletion netbox/ipam/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
from ipam.forms import PrefixForm
from ipam.forms import PrefixForm, VLANIDBulkCreateForm
from ipam.forms.bulk_import import IPAddressImportForm


Expand Down Expand Up @@ -96,3 +96,31 @@ def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):

self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")


class VLANFormTestCase(TestCase):

def test_bulk_create_valid_patterns(self):
"""Single values, ranges, and combinations expand to sorted, deduplicated VLAN IDs."""
cases = (
('100', [100]),
('5,10,20', [5, 10, 20]),
('10-20', list(range(10, 21))),
('1,10-20,300-305', [1, *range(10, 21), *range(300, 306)]),
(' 5 , 7 - 9 ', [5, 7, 8, 9]),
('5,5,4-6', [4, 5, 6]),
)
for pattern, expected in cases:
with self.subTest(pattern=pattern):
form = VLANIDBulkCreateForm({'pattern': pattern})
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data['pattern'], expected)

def test_bulk_create_invalid_patterns(self):
"""Malformed, descending, or out-of-range patterns are rejected with an error on the pattern field."""
cases = ('', 'abc', '10,abc', '20-10', '10-', '5,', '-5', '0', '4095')
for pattern in cases:
with self.subTest(pattern=pattern):
form = VLANIDBulkCreateForm({'pattern': pattern})
self.assertFalse(form.is_valid())
self.assertIn('pattern', form.errors)
7 changes: 7 additions & 0 deletions netbox/ipam/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,3 +926,10 @@ def test_vlan_group_site_validation(self):
vlan.group = vlangroups[2]
with self.assertRaises(ValidationError):
vlan.full_clean()

def test_vlan_group_vid_validation_with_null_vid(self):
"""A missing VID on a grouped VLAN raises a ValidationError, not a TypeError."""
group = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1')
vlan = VLAN(name='VLAN X', vid=None, group=group)
with self.assertRaises(ValidationError):
vlan.full_clean()
173 changes: 173 additions & 0 deletions netbox/ipam/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime

from django.contrib.contenttypes.models import ContentType
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import RequestFactory
from django.urls import reverse
from netaddr import IPNetwork
Expand Down Expand Up @@ -1501,6 +1502,178 @@ def setUpTestData(cls):
'description': 'New description',
}

def test_bulk_add_vlans(self):
self.add_permissions('ipam.add_vlan')

group = VLANGroup.objects.get(name='VLAN Group 1')
initial_count = VLAN.objects.count()
expected_vids = (110, 120, 121, 122)

form_data = {
'pattern': '110,120-122',
'group': group.pk,
'name': 'Pool-{vid}',
'status': VLANStatusChoices.STATUS_RESERVED,
}

response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)

self.assertHttpStatus(response, 302)
self.assertEqual(VLAN.objects.count(), initial_count + len(expected_vids))

for vid in expected_vids:
self.assertTrue(
VLAN.objects.filter(
group=group,
vid=vid,
name=f'Pool-{vid}'
).exists()
)

def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
self.add_permissions('ipam.add_vlan')

group = VLANGroup.objects.get(name='VLAN Group 1')
initial_count = VLAN.objects.count()

form_data = {
'pattern': '110-112',
'group': group.pk,
'name': 'Duplicate name',
'status': VLANStatusChoices.STATUS_RESERVED,
}

response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)

self.assertHttpStatus(response, 200)
self.assertEqual(VLAN.objects.count(), initial_count)
self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())

def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
self.add_permissions('ipam.add_vlan')

group = VLANGroup.objects.create(
name='Restricted VLAN Group',
slug='restricted-vlan-group',
vid_ranges=[NumericRange(200, 204)] # Valid VIDs: 200-203
)
initial_count = VLAN.objects.count()

form_data = {
'pattern': '200-203,500',
'group': group.pk,
'name': 'Restricted-{vid}',
'status': VLANStatusChoices.STATUS_RESERVED,
}

response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)

self.assertHttpStatus(response, 200)
self.assertEqual(VLAN.objects.count(), initial_count)
self.assertFalse(VLAN.objects.filter(group=group, vid=200).exists())
self.assertFalse(VLAN.objects.filter(group=group, vid=203).exists())
self.assertFalse(VLAN.objects.filter(group=group, vid=500).exists())

def test_bulk_add_vlans_pattern_shapes(self):
"""Single values, multiple values, ranges, and combinations create the expected VLANs."""
self.add_permissions('ipam.add_vlan')
# The combination runs against a second group: subTests share one transaction, and VIDs
# 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
cases = (
('500', (500,), 'VLAN Group 1'),
('5,10,20', (5, 10, 20), 'VLAN Group 1'),
('600-605', tuple(range(600, 606)), 'VLAN Group 1'),
('1,10-20,300-305', (1, *range(10, 21), *range(300, 306)), 'VLAN Group 2'),
)
for pattern, expected_vids, group_name in cases:
with self.subTest(pattern=pattern):
group = VLANGroup.objects.get(name=group_name)
initial_count = VLAN.objects.count()
form_data = {
'pattern': pattern,
'group': group.pk,
'name': 'Pool-{vid}',
'status': VLANStatusChoices.STATUS_ACTIVE,
}
response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
self.assertHttpStatus(response, 302)
self.assertEqual(VLAN.objects.count(), initial_count + len(expected_vids))
for vid in expected_vids:
self.assertTrue(VLAN.objects.filter(group=group, vid=vid, name=f'Pool-{vid}').exists())

def test_bulk_add_vlans_invalid_pattern(self):
"""An invalid pattern re-renders the form with a pattern error and creates nothing."""
self.add_permissions('ipam.add_vlan')
initial_count = VLAN.objects.count()

for pattern in ('abc', '20-10', '0', '4095', '10-'):
with self.subTest(pattern=pattern):
form_data = {
'pattern': pattern,
'name': 'Pool-{vid}',
'status': VLANStatusChoices.STATUS_ACTIVE,
}
response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)
self.assertHttpStatus(response, 200)
self.assertIn('pattern', response.context['form'].errors)
self.assertEqual(VLAN.objects.count(), initial_count)

def test_bulk_add_vlans_static_name_without_group(self):
"""A static name (no {vid} placeholder) is permitted across VLANs not assigned to a group."""
self.add_permissions('ipam.add_vlan')
initial_count = VLAN.objects.count()

form_data = {
'pattern': '710-712',
'name': 'Same name',
'status': VLANStatusChoices.STATUS_ACTIVE,
}
response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)

self.assertHttpStatus(response, 302)
self.assertEqual(VLAN.objects.count(), initial_count + 3)
self.assertEqual(VLAN.objects.filter(name='Same name').count(), 3)

def test_bulk_add_vlans_rolls_back_on_constrained_permission(self):
"""Bulk creation rolls back when a generated VLAN falls outside the user's add constraints."""
obj_perm = ObjectPermission(
name='Test permission',
actions=['add'],
constraints={'vid__lt': 120}
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(VLAN))

initial_count = VLAN.objects.count()
form_data = {
'pattern': '110,120-122',
'name': 'Pool-{vid}',
'status': VLANStatusChoices.STATUS_ACTIVE,
}
response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)

self.assertHttpStatus(response, 200)
self.assertEqual(VLAN.objects.count(), initial_count)
self.assertTrue(response.context['form'].non_field_errors())

def test_bulk_add_vlans_propagates_field_errors(self):
"""A per-object validation error on a non-pattern field is reported on the bulk-create form."""
self.add_permissions('ipam.add_vlan')
initial_count = VLAN.objects.count()

form_data = {
'pattern': '800',
'name': 'Pool-{vid}',
'status': VLANStatusChoices.STATUS_ACTIVE,
'qinq_role': VLANQinQRoleChoices.ROLE_CUSTOMER, # Requires an SVLAN
}
response = self.client.post(reverse('ipam:vlan_bulk_add'), form_data)

self.assertHttpStatus(response, 200)
self.assertEqual(VLAN.objects.count(), initial_count)
self.assertTrue(response.context['form'].non_field_errors())


class VLANTranslationPolicyTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VLANTranslationPolicy
Expand Down
10 changes: 10 additions & 0 deletions netbox/ipam/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1793,6 +1793,16 @@ class VLANDeleteView(generic.ObjectDeleteView):
queryset = VLAN.objects.all()


@register_model_view(VLAN, 'bulk_add', path='bulk-add', detail=False)
class VLANBulkCreateView(generic.BulkCreateView):
queryset = VLAN.objects.all()
form = forms.VLANIDBulkCreateForm
model_form = forms.VLANBulkAddForm
pattern_target = 'vid'
pattern_template_fields = ('name',)
template_name = 'ipam/vlan_bulk_add.html'


@register_model_view(VLAN, 'bulk_import', path='import', detail=False)
class VLANBulkImportView(generic.BulkImportView):
queryset = VLAN.objects.all()
Expand Down
Loading