nautobot 2.3.5__py3-none-any.whl → 2.3.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nautobot/core/api/utils.py +12 -2
- nautobot/core/forms/fields.py +5 -2
- nautobot/core/forms/utils.py +31 -6
- nautobot/core/models/fields.py +56 -0
- nautobot/core/tests/test_utils.py +83 -0
- nautobot/dcim/tests/test_api.py +4 -1
- nautobot/extras/factory.py +2 -1
- nautobot/extras/models/models.py +2 -0
- nautobot/extras/tests/test_api.py +3 -3
- nautobot/extras/tests/test_forms.py +2 -0
- nautobot/extras/tests/test_views.py +2 -2
- nautobot/ipam/api/serializers.py +30 -1
- nautobot/ipam/api/views.py +165 -3
- nautobot/ipam/filters.py +1 -1
- nautobot/ipam/forms.py +2 -0
- nautobot/ipam/migrations/0050_vlangroup_range.py +24 -0
- nautobot/ipam/models.py +51 -8
- nautobot/ipam/tables.py +4 -4
- nautobot/ipam/templates/ipam/vlangroup.html +4 -0
- nautobot/ipam/tests/test_api.py +174 -0
- nautobot/ipam/tests/test_models.py +35 -1
- nautobot/ipam/tests/test_utils.py +61 -0
- nautobot/ipam/tests/test_views.py +2 -0
- nautobot/ipam/utils/__init__.py +10 -17
- nautobot/ipam/views.py +1 -1
- nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +2 -2
- nautobot/project-static/docs/development/apps/api/models/index.html +2 -2
- nautobot/project-static/docs/objects.inv +0 -0
- nautobot/project-static/docs/release-notes/version-2.3.html +218 -72
- nautobot/project-static/docs/search/search_index.json +1 -1
- nautobot/project-static/docs/sitemap.xml +269 -269
- nautobot/project-static/docs/sitemap.xml.gz +0 -0
- nautobot/project-static/docs/user-guide/core-data-model/ipam/vlangroup.html +305 -0
- {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/METADATA +1 -1
- {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/RECORD +39 -37
- {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/LICENSE.txt +0 -0
- {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/NOTICE +0 -0
- {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/WHEEL +0 -0
- {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/entry_points.txt +0 -0
nautobot/ipam/models.py
CHANGED
|
@@ -10,8 +10,9 @@ from django.utils.functional import cached_property
|
|
|
10
10
|
import netaddr
|
|
11
11
|
|
|
12
12
|
from nautobot.core.constants import CHARFIELD_MAX_LENGTH
|
|
13
|
+
from nautobot.core.forms.utils import parse_numeric_range
|
|
13
14
|
from nautobot.core.models import BaseManager, BaseModel
|
|
14
|
-
from nautobot.core.models.fields import JSONArrayField
|
|
15
|
+
from nautobot.core.models.fields import JSONArrayField, PositiveRangeNumberTextField
|
|
15
16
|
from nautobot.core.models.generics import OrganizationalModel, PrimaryModel
|
|
16
17
|
from nautobot.core.models.utils import array_to_string
|
|
17
18
|
from nautobot.core.utils.data import UtilizationData
|
|
@@ -1274,11 +1275,14 @@ class IPAddressToInterface(BaseModel):
|
|
|
1274
1275
|
|
|
1275
1276
|
|
|
1276
1277
|
@extras_features(
|
|
1278
|
+
"custom_links",
|
|
1277
1279
|
"custom_validators",
|
|
1280
|
+
"export_templates",
|
|
1278
1281
|
"graphql",
|
|
1279
1282
|
"locations",
|
|
1283
|
+
"webhooks",
|
|
1280
1284
|
)
|
|
1281
|
-
class VLANGroup(
|
|
1285
|
+
class VLANGroup(PrimaryModel):
|
|
1282
1286
|
"""
|
|
1283
1287
|
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
|
|
1284
1288
|
"""
|
|
@@ -1293,11 +1297,36 @@ class VLANGroup(OrganizationalModel):
|
|
|
1293
1297
|
)
|
|
1294
1298
|
description = models.CharField(max_length=CHARFIELD_MAX_LENGTH, blank=True)
|
|
1295
1299
|
|
|
1300
|
+
range = PositiveRangeNumberTextField(
|
|
1301
|
+
blank=False,
|
|
1302
|
+
default="1-4094",
|
|
1303
|
+
help_text="Permitted VID range(s) as comma-separated list, default '1-4094' if left blank.",
|
|
1304
|
+
min_boundary=constants.VLAN_VID_MIN,
|
|
1305
|
+
max_boundary=constants.VLAN_VID_MAX,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1296
1308
|
class Meta:
|
|
1297
1309
|
ordering = ("name",)
|
|
1298
1310
|
verbose_name = "VLAN group"
|
|
1299
1311
|
verbose_name_plural = "VLAN groups"
|
|
1300
1312
|
|
|
1313
|
+
@property
|
|
1314
|
+
def expanded_range(self):
|
|
1315
|
+
"""
|
|
1316
|
+
Expand VLAN's range into a list of integers (VLAN IDs).
|
|
1317
|
+
"""
|
|
1318
|
+
return parse_numeric_range(self.range)
|
|
1319
|
+
|
|
1320
|
+
@property
|
|
1321
|
+
def available_vids(self):
|
|
1322
|
+
"""
|
|
1323
|
+
Return all available VLAN IDs within this VLANGroup as a list.
|
|
1324
|
+
"""
|
|
1325
|
+
used_ids = self.vlans.all().values_list("vid", flat=True)
|
|
1326
|
+
available = sorted([vid for vid in self.expanded_range if vid not in used_ids])
|
|
1327
|
+
|
|
1328
|
+
return available
|
|
1329
|
+
|
|
1301
1330
|
def clean(self):
|
|
1302
1331
|
super().clean()
|
|
1303
1332
|
|
|
@@ -1308,18 +1337,25 @@ class VLANGroup(OrganizationalModel):
|
|
|
1308
1337
|
{"location": f'VLAN groups may not associate to locations of type "{self.location.location_type}".'}
|
|
1309
1338
|
)
|
|
1310
1339
|
|
|
1340
|
+
# Validate ranges for related VLANs.
|
|
1341
|
+
_expanded_range = self.expanded_range
|
|
1342
|
+
out_of_range_vids = [_vlan.vid for _vlan in self.vlans.all() if _vlan.vid not in _expanded_range]
|
|
1343
|
+
if out_of_range_vids:
|
|
1344
|
+
raise ValidationError(
|
|
1345
|
+
{
|
|
1346
|
+
"range": f"VLAN group range may not be re-sized due to existing VLANs (IDs: {','.join(map(str, out_of_range_vids))})."
|
|
1347
|
+
}
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1311
1350
|
def __str__(self):
|
|
1312
1351
|
return self.name
|
|
1313
1352
|
|
|
1314
1353
|
def get_next_available_vid(self):
|
|
1315
1354
|
"""
|
|
1316
|
-
Return the first available VLAN ID
|
|
1355
|
+
Return the first available VLAN ID in the group's range.
|
|
1317
1356
|
"""
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
if i not in vlan_ids:
|
|
1321
|
-
return i
|
|
1322
|
-
return None
|
|
1357
|
+
_available_vids = self.available_vids
|
|
1358
|
+
return _available_vids[0] if _available_vids else None
|
|
1323
1359
|
|
|
1324
1360
|
|
|
1325
1361
|
@extras_features(
|
|
@@ -1438,6 +1474,13 @@ class VLAN(PrimaryModel):
|
|
|
1438
1474
|
# Return all VM interfaces assigned to this VLAN
|
|
1439
1475
|
return VMInterface.objects.filter(Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk)).distinct()
|
|
1440
1476
|
|
|
1477
|
+
def clean(self):
|
|
1478
|
+
super().clean()
|
|
1479
|
+
|
|
1480
|
+
# Validate Vlan Group Range
|
|
1481
|
+
if self.vlan_group and self.vid not in self.vlan_group.expanded_range:
|
|
1482
|
+
raise ValidationError({"vid": f"VLAN ID is not contained in VLAN Group range ({self.vlan_group.range})"})
|
|
1483
|
+
|
|
1441
1484
|
|
|
1442
1485
|
@extras_features("graphql")
|
|
1443
1486
|
class VLANLocationAssignment(BaseModel):
|
nautobot/ipam/tables.py
CHANGED
|
@@ -161,9 +161,9 @@ VLAN_LINK = """
|
|
|
161
161
|
{% url 'ipam:vlan_add' %}\
|
|
162
162
|
?vid={{ record.vid }}&vlan_group={{ vlan_group.pk }}\
|
|
163
163
|
{% if vlan_group.location %}&location={{ vlan_group.location.pk }}{% endif %}\
|
|
164
|
-
" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>\
|
|
164
|
+
" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available ({{ record.range }})</a>\
|
|
165
165
|
{% else %}
|
|
166
|
-
{{ record.available }} VLAN{{ record.available|pluralize }} available
|
|
166
|
+
{{ record.available }} VLAN{{ record.available|pluralize }} available ({{ record.range }})
|
|
167
167
|
{% endif %}
|
|
168
168
|
"""
|
|
169
169
|
|
|
@@ -665,8 +665,8 @@ class VLANGroupTable(BaseTable):
|
|
|
665
665
|
|
|
666
666
|
class Meta(BaseTable.Meta):
|
|
667
667
|
model = VLANGroup
|
|
668
|
-
fields = ("pk", "name", "location", "vlan_count", "description", "actions")
|
|
669
|
-
default_columns = ("pk", "name", "location", "vlan_count", "description", "actions")
|
|
668
|
+
fields = ("pk", "name", "location", "range", "vlan_count", "description", "actions")
|
|
669
|
+
default_columns = ("pk", "name", "range", "location", "vlan_count", "description", "actions")
|
|
670
670
|
|
|
671
671
|
|
|
672
672
|
#
|
nautobot/ipam/tests/test_api.py
CHANGED
|
@@ -991,6 +991,15 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
|
|
991
991
|
"description": "New description",
|
|
992
992
|
}
|
|
993
993
|
|
|
994
|
+
@classmethod
|
|
995
|
+
def setUpTestData(cls):
|
|
996
|
+
cls.vlan_group = VLANGroup.objects.create(name="Test", range="5-10,15-20")
|
|
997
|
+
cls.default_status = Status.objects.first()
|
|
998
|
+
VLAN.objects.create(name="vlan_5", vid=5, status=cls.default_status, vlan_group=cls.vlan_group)
|
|
999
|
+
VLAN.objects.create(name="vlan_10", vid=10, status=cls.default_status, vlan_group=cls.vlan_group)
|
|
1000
|
+
VLAN.objects.create(name="vlan_17", vid=17, status=cls.default_status, vlan_group=cls.vlan_group)
|
|
1001
|
+
cls.unused_vids = [6, 7, 8, 9, 15, 16, 18, 19, 20]
|
|
1002
|
+
|
|
994
1003
|
def get_deletable_object(self):
|
|
995
1004
|
return VLANGroup.objects.create(name="DELETE ME")
|
|
996
1005
|
|
|
@@ -1002,6 +1011,171 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
|
|
1002
1011
|
]
|
|
1003
1012
|
return [vg.pk for vg in vlangroups]
|
|
1004
1013
|
|
|
1014
|
+
def test_list_available_vlans(self):
|
|
1015
|
+
"""
|
|
1016
|
+
Test retrieval of all available VLAN IDs within a VLANGroup.
|
|
1017
|
+
"""
|
|
1018
|
+
url = reverse("ipam-api:vlangroup-available-vlans", kwargs={"pk": self.vlan_group.pk})
|
|
1019
|
+
self.add_permissions("ipam.view_vlangroup")
|
|
1020
|
+
|
|
1021
|
+
# Retrieve all available VLAN IDs
|
|
1022
|
+
response = self.client.get(url, **self.header)
|
|
1023
|
+
|
|
1024
|
+
self.assertEqual(response.data["results"], self.unused_vids)
|
|
1025
|
+
self.assertEqual(response.data["count"], len(self.unused_vids))
|
|
1026
|
+
|
|
1027
|
+
def test_create_single_available_vlan(self):
|
|
1028
|
+
"""
|
|
1029
|
+
Test creation of the first available VLAN within a VLANGroup.
|
|
1030
|
+
"""
|
|
1031
|
+
cf = CustomField.objects.create(key="sor", label="Source of Record Field", type="text")
|
|
1032
|
+
cf.content_types.add(ContentType.objects.get_for_model(VLAN))
|
|
1033
|
+
url = reverse("ipam-api:vlangroup-available-vlans", kwargs={"pk": self.vlan_group.pk})
|
|
1034
|
+
self.add_permissions(
|
|
1035
|
+
"ipam.view_vlangroup",
|
|
1036
|
+
"ipam.add_vlan",
|
|
1037
|
+
)
|
|
1038
|
+
|
|
1039
|
+
# Create all nine available VLANs with individual requests
|
|
1040
|
+
for unused_vid in self.unused_vids:
|
|
1041
|
+
data = {
|
|
1042
|
+
"name": f"VLAN_{unused_vid}",
|
|
1043
|
+
"description": f"Test VLAN {unused_vid}",
|
|
1044
|
+
"status": self.default_status.pk,
|
|
1045
|
+
"custom_fields": {"sor": "Nautobot"},
|
|
1046
|
+
}
|
|
1047
|
+
response = self.client.post(url, data, format="json", **self.header)
|
|
1048
|
+
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
|
1049
|
+
self.assertEqual(response.data["results"]["name"], data["name"])
|
|
1050
|
+
self.assertEqual(response.data["results"]["vid"], unused_vid)
|
|
1051
|
+
self.assertEqual(response.data["results"]["description"], data["description"])
|
|
1052
|
+
self.assertEqual(response.data["results"]["vlan_group"]["id"], self.vlan_group.pk)
|
|
1053
|
+
self.assertIn("custom_fields", response.data["results"])
|
|
1054
|
+
self.assertIn("sor", response.data["results"]["custom_fields"])
|
|
1055
|
+
self.assertEqual("Nautobot", response.data["results"]["custom_fields"]["sor"])
|
|
1056
|
+
|
|
1057
|
+
# Try to create one more VLAN
|
|
1058
|
+
response = self.client.post(
|
|
1059
|
+
url, {"name": "UTILIZED_VLAN_GROUP", "status": self.default_status.pk}, format="json", **self.header
|
|
1060
|
+
)
|
|
1061
|
+
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
|
1062
|
+
self.assertIn("detail", response.data)
|
|
1063
|
+
self.assertIn(
|
|
1064
|
+
f"An insufficient number of VLANs are available within the VLANGroup {self.vlan_group}",
|
|
1065
|
+
response.data["detail"],
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
def test_create_multiple_available_vlans(self):
|
|
1069
|
+
"""
|
|
1070
|
+
Test the creation of available VLANS within a VLANGroup.
|
|
1071
|
+
"""
|
|
1072
|
+
cf = CustomField.objects.create(key="sor", label="Source of Record Field", type="text")
|
|
1073
|
+
cf.content_types.add(ContentType.objects.get_for_model(VLAN))
|
|
1074
|
+
url = reverse("ipam-api:vlangroup-available-vlans", kwargs={"pk": self.vlan_group.pk})
|
|
1075
|
+
self.add_permissions(
|
|
1076
|
+
"ipam.view_vlangroup",
|
|
1077
|
+
"ipam.add_vlan",
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
# Try to create ten VLANs (only nine are available)
|
|
1081
|
+
data = [ # First nine VLANs
|
|
1082
|
+
{
|
|
1083
|
+
"name": f"VLAN_{unused_vid}",
|
|
1084
|
+
"description": f"Test VLAN {unused_vid}",
|
|
1085
|
+
"status": self.default_status.pk,
|
|
1086
|
+
"custom_fields": {"sor": "Nautobot"},
|
|
1087
|
+
}
|
|
1088
|
+
for unused_vid in self.unused_vids
|
|
1089
|
+
]
|
|
1090
|
+
additional_vlan = [
|
|
1091
|
+
{
|
|
1092
|
+
"name": "VLAN_10", # Out of range VLAN
|
|
1093
|
+
"description": "Test VLAN 10",
|
|
1094
|
+
"status": self.default_status.pk,
|
|
1095
|
+
"custom_fields": {"sor": "Nautobot"},
|
|
1096
|
+
}
|
|
1097
|
+
]
|
|
1098
|
+
response = self.client.post(url, data + additional_vlan, format="json", **self.header)
|
|
1099
|
+
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
|
1100
|
+
self.assertIn("detail", response.data)
|
|
1101
|
+
|
|
1102
|
+
# Create all nine available VLANs in a single request
|
|
1103
|
+
response = self.client.post(url, data, format="json", **self.header)
|
|
1104
|
+
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
|
1105
|
+
self.assertEqual(len(response.data["results"]), 9)
|
|
1106
|
+
|
|
1107
|
+
for i, vlan_data in enumerate(data):
|
|
1108
|
+
self.assertEqual(response.data["results"][i]["name"], vlan_data["name"])
|
|
1109
|
+
self.assertEqual(response.data["results"][i]["vid"], int(vlan_data["name"].replace("VLAN_", "")))
|
|
1110
|
+
self.assertEqual(response.data["results"][i]["description"], vlan_data["description"])
|
|
1111
|
+
self.assertEqual(response.data["results"][i]["vlan_group"]["id"], self.vlan_group.pk)
|
|
1112
|
+
self.assertIn("custom_fields", response.data["results"][i])
|
|
1113
|
+
self.assertIn("sor", response.data["results"][i]["custom_fields"])
|
|
1114
|
+
self.assertEqual("Nautobot", response.data["results"][i]["custom_fields"]["sor"])
|
|
1115
|
+
|
|
1116
|
+
def test_create_multiple_explicit_vlans(self):
|
|
1117
|
+
"""
|
|
1118
|
+
Test the creation of available VLANS within a VLANGroup requesting explicit VLAN IDs.
|
|
1119
|
+
"""
|
|
1120
|
+
url = reverse("ipam-api:vlangroup-available-vlans", kwargs={"pk": self.vlan_group.pk})
|
|
1121
|
+
self.add_permissions(
|
|
1122
|
+
"ipam.view_vlangroup",
|
|
1123
|
+
"ipam.add_vlan",
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
# Try to create VLANs with specified VLAN IDs. Also, explicitly (and redundantly) specify a VLAN Group.
|
|
1127
|
+
data = [
|
|
1128
|
+
{"name": "VLAN_6", "status": self.default_status.pk, "vid": 6},
|
|
1129
|
+
{"name": "VLAN_7", "status": self.default_status.pk, "vid": 7},
|
|
1130
|
+
{"name": "VLAN_8", "status": self.default_status.pk},
|
|
1131
|
+
{"name": "VLAN_9", "status": self.default_status.pk, "vid": 9, "vlan_group": self.vlan_group.pk},
|
|
1132
|
+
{"name": "VLAN_15", "status": self.default_status.pk},
|
|
1133
|
+
{"name": "VLAN_16", "status": self.default_status.pk, "vid": 16, "vlan_group": self.vlan_group.pk},
|
|
1134
|
+
]
|
|
1135
|
+
|
|
1136
|
+
response = self.client.post(url, data, format="json", **self.header)
|
|
1137
|
+
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
|
1138
|
+
self.assertEqual(len(response.data["results"]), 6)
|
|
1139
|
+
|
|
1140
|
+
for i, vlan_data in enumerate(data):
|
|
1141
|
+
self.assertEqual(response.data["results"][i]["name"], vlan_data["name"])
|
|
1142
|
+
self.assertEqual(response.data["results"][i]["vid"], int(vlan_data["name"].replace("VLAN_", "")))
|
|
1143
|
+
self.assertEqual(response.data["results"][i]["vlan_group"]["id"], self.vlan_group.pk)
|
|
1144
|
+
|
|
1145
|
+
def test_create_invalid_vlans(self):
|
|
1146
|
+
"""
|
|
1147
|
+
Test the creation of VLANs using invalid requests.
|
|
1148
|
+
"""
|
|
1149
|
+
url = reverse("ipam-api:vlangroup-available-vlans", kwargs={"pk": self.vlan_group.pk})
|
|
1150
|
+
self.add_permissions(
|
|
1151
|
+
"ipam.view_vlangroup",
|
|
1152
|
+
"ipam.add_vlan",
|
|
1153
|
+
)
|
|
1154
|
+
|
|
1155
|
+
# Try to create VLANs using same vid
|
|
1156
|
+
data = [
|
|
1157
|
+
{"name": "VLAN_6", "status": self.default_status.pk, "vid": 6},
|
|
1158
|
+
{"name": "VLAN_7", "status": self.default_status.pk, "vid": 6},
|
|
1159
|
+
{"name": "VLAN_8", "status": self.default_status.pk},
|
|
1160
|
+
]
|
|
1161
|
+
|
|
1162
|
+
response = self.client.post(url, data, format="json", **self.header)
|
|
1163
|
+
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
|
1164
|
+
self.assertIn("detail", response.data)
|
|
1165
|
+
self.assertEqual("VLAN 6 is not available within the VLANGroup.", response.data["detail"])
|
|
1166
|
+
|
|
1167
|
+
# Try to create VLANs specifying other VLAN Group
|
|
1168
|
+
some_other_vlan_group = VLANGroup.objects.create(name="VLAN Group 100-200", range="100-200")
|
|
1169
|
+
data = [{"name": "VLAN_7", "status": self.default_status.pk, "vlan_group": some_other_vlan_group.pk}]
|
|
1170
|
+
|
|
1171
|
+
response = self.client.post(url, data, format="json", **self.header)
|
|
1172
|
+
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
|
1173
|
+
self.assertIn("detail", response.data)
|
|
1174
|
+
self.assertEqual(
|
|
1175
|
+
f"Invalid VLAN Group requested: {some_other_vlan_group}. Only VLAN Group {self.vlan_group} is permitted.",
|
|
1176
|
+
response.data["detail"],
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1005
1179
|
|
|
1006
1180
|
class VLANTest(APIViewTestCases.APIViewTestCase):
|
|
1007
1181
|
model = VLAN
|
|
@@ -1302,7 +1302,7 @@ class TestVLANGroup(ModelTestCases.BaseModelTestCase):
|
|
|
1302
1302
|
self.assertIn(f'VLAN groups may not associate to locations of type "{location_type.name}"', str(cm.exception))
|
|
1303
1303
|
|
|
1304
1304
|
def test_get_next_available_vid(self):
|
|
1305
|
-
vlangroup = VLANGroup.objects.create(name="VLAN Group 1")
|
|
1305
|
+
vlangroup = VLANGroup.objects.create(name="VLAN Group 1", range="1-6")
|
|
1306
1306
|
status = Status.objects.get_for_model(VLAN).first()
|
|
1307
1307
|
VLAN.objects.bulk_create(
|
|
1308
1308
|
(
|
|
@@ -1316,6 +1316,40 @@ class TestVLANGroup(ModelTestCases.BaseModelTestCase):
|
|
|
1316
1316
|
|
|
1317
1317
|
VLAN.objects.bulk_create((VLAN(name="VLAN 4", vid=4, vlan_group=vlangroup, status=status),))
|
|
1318
1318
|
self.assertEqual(vlangroup.get_next_available_vid(), 6)
|
|
1319
|
+
# Next out of range.
|
|
1320
|
+
VLAN.objects.bulk_create((VLAN(name="VLAN 6", vid=6, vlan_group=vlangroup, status=status),))
|
|
1321
|
+
self.assertEqual(vlangroup.get_next_available_vid(), None)
|
|
1322
|
+
|
|
1323
|
+
def test_range_resize(self):
|
|
1324
|
+
vlangroup = VLANGroup.objects.create(name="VLAN Group 1", range="1-3")
|
|
1325
|
+
status = Status.objects.get_for_model(VLAN).first()
|
|
1326
|
+
VLAN.objects.bulk_create(
|
|
1327
|
+
(
|
|
1328
|
+
VLAN(name="VLAN 1", vid=1, vlan_group=vlangroup, status=status),
|
|
1329
|
+
VLAN(name="VLAN 2", vid=2, vlan_group=vlangroup, status=status),
|
|
1330
|
+
VLAN(name="VLAN 3", vid=3, vlan_group=vlangroup, status=status),
|
|
1331
|
+
)
|
|
1332
|
+
)
|
|
1333
|
+
with self.assertRaises(ValidationError) as exc:
|
|
1334
|
+
vlangroup.range = "1-2"
|
|
1335
|
+
vlangroup.validated_save()
|
|
1336
|
+
self.assertEqual(
|
|
1337
|
+
str(exc.exception), "{'range': ['VLAN group range may not be re-sized due to existing VLANs (IDs: 3).']}"
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
def test_assign_vlan_out_of_range(self):
|
|
1341
|
+
vlangroup = VLANGroup.objects.create(name="VLAN Group 1", range="1-2")
|
|
1342
|
+
status = Status.objects.get_for_model(VLAN).first()
|
|
1343
|
+
VLAN.objects.bulk_create(
|
|
1344
|
+
(
|
|
1345
|
+
VLAN(name="VLAN 1", vid=1, vlan_group=vlangroup, status=status),
|
|
1346
|
+
VLAN(name="VLAN 2", vid=2, vlan_group=vlangroup, status=status),
|
|
1347
|
+
)
|
|
1348
|
+
)
|
|
1349
|
+
with self.assertRaises(ValidationError) as exc:
|
|
1350
|
+
vlan = VLAN(name="VLAN 3", vid=3, vlan_group=vlangroup, status=status)
|
|
1351
|
+
vlan.validated_save()
|
|
1352
|
+
self.assertEqual(str(exc.exception), "{'vid': ['VLAN ID is not contained in VLAN Group range (1-2)']}")
|
|
1319
1353
|
|
|
1320
1354
|
|
|
1321
1355
|
class TestVLAN(ModelTestCases.BaseModelTestCase):
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from django.test import TestCase
|
|
2
|
+
|
|
3
|
+
from nautobot.core.forms.utils import parse_numeric_range
|
|
4
|
+
from nautobot.extras.models import Status
|
|
5
|
+
from nautobot.ipam.models import VLAN, VLANGroup
|
|
6
|
+
from nautobot.ipam.utils import add_available_vlans
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AddAvailableVlansTest(TestCase):
|
|
10
|
+
"""Tests for add_available_vlans()."""
|
|
11
|
+
|
|
12
|
+
def test_add_available_vlans(self):
|
|
13
|
+
vlan_group = VLANGroup.objects.create(name="VLAN Group 1", range="100-105,110-112,115")
|
|
14
|
+
status = Status.objects.get_for_model(VLAN).first()
|
|
15
|
+
vlan_100 = {"vid": 100, "available": 2, "range": "100-101"}
|
|
16
|
+
vlan_102 = VLAN.objects.create(name="VLAN 102", vid=102, vlan_group=vlan_group, status=status)
|
|
17
|
+
vlan_103 = VLAN.objects.create(name="VLAN 103", vid=103, vlan_group=vlan_group, status=status)
|
|
18
|
+
vlan_104 = {"vid": 104, "available": 2, "range": "104-105"}
|
|
19
|
+
vlan_110 = VLAN.objects.create(name="VLAN 110", vid=110, vlan_group=vlan_group, status=status)
|
|
20
|
+
vlan_111 = VLAN.objects.create(name="VLAN 111", vid=111, vlan_group=vlan_group, status=status)
|
|
21
|
+
vlan_112 = {"vid": 112, "available": 1, "range": "112"}
|
|
22
|
+
vlan_115 = VLAN.objects.create(name="VLAN 115", vid=115, vlan_group=vlan_group, status=status)
|
|
23
|
+
|
|
24
|
+
self.assertEqual(
|
|
25
|
+
list(add_available_vlans(vlan_group=vlan_group, vlans=vlan_group.vlans.all())),
|
|
26
|
+
[vlan_100, vlan_102, vlan_103, vlan_104, vlan_110, vlan_111, vlan_112, vlan_115],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ParseNumericRangeTest(TestCase):
|
|
31
|
+
"""Tests for add_available_vlans()."""
|
|
32
|
+
|
|
33
|
+
def test_parse(self):
|
|
34
|
+
self.assertEqual(parse_numeric_range(input_string="5"), [5])
|
|
35
|
+
self.assertEqual(parse_numeric_range(input_string="5-5"), [5])
|
|
36
|
+
self.assertEqual(parse_numeric_range(input_string="5-5,5,5"), [5])
|
|
37
|
+
self.assertEqual(parse_numeric_range(input_string="1-5"), [1, 2, 3, 4, 5])
|
|
38
|
+
self.assertEqual(parse_numeric_range(input_string="1,2,3,4,5"), [1, 2, 3, 4, 5])
|
|
39
|
+
self.assertEqual(parse_numeric_range(input_string="5,4,3,1,2"), [1, 2, 3, 4, 5])
|
|
40
|
+
self.assertEqual(parse_numeric_range(input_string="1-5,10"), [1, 2, 3, 4, 5, 10])
|
|
41
|
+
self.assertEqual(parse_numeric_range(input_string="1,5,10-11"), [1, 5, 10, 11])
|
|
42
|
+
self.assertEqual(parse_numeric_range(input_string="10-11,1,5"), [1, 5, 10, 11])
|
|
43
|
+
self.assertEqual(parse_numeric_range(input_string="a", base=16), [10])
|
|
44
|
+
self.assertEqual(parse_numeric_range(input_string="a,b", base=16), [10, 11])
|
|
45
|
+
self.assertEqual(parse_numeric_range(input_string="9-c,f", base=16), [9, 10, 11, 12, 15])
|
|
46
|
+
self.assertEqual(parse_numeric_range(input_string="15-19", base=16), [21, 22, 23, 24, 25])
|
|
47
|
+
self.assertEqual(parse_numeric_range(input_string="fa-ff", base=16), [250, 251, 252, 253, 254, 255])
|
|
48
|
+
|
|
49
|
+
def test_invalid_input(self):
|
|
50
|
+
invalid_inputs = [
|
|
51
|
+
[1, 2, 3],
|
|
52
|
+
None,
|
|
53
|
+
1,
|
|
54
|
+
"",
|
|
55
|
+
"3-",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
for x in invalid_inputs:
|
|
59
|
+
with self.assertRaises(TypeError) as exc:
|
|
60
|
+
parse_numeric_range(input_string=x)
|
|
61
|
+
self.assertEqual(str(exc.exception), "Input value must be a string using a range format.")
|
|
@@ -1031,6 +1031,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
|
|
1031
1031
|
"name": "VLAN Group X",
|
|
1032
1032
|
"location": location.pk,
|
|
1033
1033
|
"description": "A new VLAN group",
|
|
1034
|
+
"range": "1-4094",
|
|
1035
|
+
"tags": [t.pk for t in Tag.objects.get_for_model(VLANGroup)],
|
|
1034
1036
|
}
|
|
1035
1037
|
|
|
1036
1038
|
def get_deletable_object(self):
|
nautobot/ipam/utils/__init__.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from django.core.exceptions import ValidationError
|
|
2
2
|
import netaddr
|
|
3
3
|
|
|
4
|
+
from nautobot.core.forms.utils import compress_range
|
|
4
5
|
from nautobot.dcim.models import Interface
|
|
5
6
|
from nautobot.extras.models import RelationshipAssociation
|
|
6
|
-
from nautobot.ipam.constants import VLAN_VID_MAX, VLAN_VID_MIN
|
|
7
7
|
from nautobot.ipam.models import Prefix, VLAN
|
|
8
8
|
from nautobot.ipam.querysets import IPAddressQuerySet
|
|
9
9
|
from nautobot.virtualization.models import VMInterface
|
|
@@ -85,24 +85,17 @@ def add_available_vlans(vlan_group, vlans):
|
|
|
85
85
|
"""
|
|
86
86
|
Create fake records for all gaps between used VLANs
|
|
87
87
|
"""
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
fake_vlans = [
|
|
89
|
+
{
|
|
90
|
+
"vid": t[0],
|
|
91
|
+
"available": t[1] - t[0] + 1,
|
|
92
|
+
"range": f"{t[0]}" if t[0] == t[1] else f"{t[0]}-{t[1]}",
|
|
93
|
+
}
|
|
94
|
+
for t in compress_range(vlan_group.available_vids)
|
|
95
|
+
]
|
|
90
96
|
|
|
91
|
-
|
|
92
|
-
new_vlans = []
|
|
93
|
-
for vlan in vlans:
|
|
94
|
-
if vlan.vid - prev_vid > 1:
|
|
95
|
-
new_vlans.append({"vid": prev_vid + 1, "available": vlan.vid - prev_vid - 1})
|
|
96
|
-
prev_vid = vlan.vid
|
|
97
|
-
|
|
98
|
-
if vlans[0].vid > VLAN_VID_MIN:
|
|
99
|
-
new_vlans.append({"vid": VLAN_VID_MIN, "available": vlans[0].vid - VLAN_VID_MIN})
|
|
100
|
-
if prev_vid < VLAN_VID_MAX:
|
|
101
|
-
new_vlans.append({"vid": prev_vid + 1, "available": VLAN_VID_MAX - prev_vid})
|
|
102
|
-
|
|
103
|
-
vlans = list(vlans) + new_vlans
|
|
97
|
+
vlans = list(vlans) + fake_vlans
|
|
104
98
|
vlans.sort(key=lambda v: v.vid if isinstance(v, VLAN) else v["vid"])
|
|
105
|
-
|
|
106
99
|
return vlans
|
|
107
100
|
|
|
108
101
|
|
nautobot/ipam/views.py
CHANGED
|
@@ -1233,7 +1233,7 @@ class VLANGroupView(generic.ObjectView):
|
|
|
1233
1233
|
.prefetch_related(Prefetch("prefixes", queryset=Prefix.objects.restrict(request.user)))
|
|
1234
1234
|
)
|
|
1235
1235
|
vlans_count = vlans.count()
|
|
1236
|
-
vlans = add_available_vlans(instance, vlans)
|
|
1236
|
+
vlans = add_available_vlans(vlan_group=instance, vlans=vlans)
|
|
1237
1237
|
|
|
1238
1238
|
vlan_table = tables.VLANDetailTable(vlans)
|
|
1239
1239
|
if request.user.has_perm("ipam.change_vlan") or request.user.has_perm("ipam.delete_vlan"):
|
|
@@ -13399,14 +13399,14 @@ are marked as not required.</p>
|
|
|
13399
13399
|
|
|
13400
13400
|
|
|
13401
13401
|
<h2 id="nautobot.apps.forms.parse_numeric_range" class="doc doc-heading">
|
|
13402
|
-
<code class="highlight language-python"><span class="n">nautobot</span><span class="o">.</span><span class="n">apps</span><span class="o">.</span><span class="n">forms</span><span class="o">.</span><span class="n">parse_numeric_range</span><span class="p">(</span><span class="n">
|
|
13402
|
+
<code class="highlight language-python"><span class="n">nautobot</span><span class="o">.</span><span class="n">apps</span><span class="o">.</span><span class="n">forms</span><span class="o">.</span><span class="n">parse_numeric_range</span><span class="p">(</span><span class="n">input_string</span><span class="p">,</span> <span class="n">base</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span></code>
|
|
13403
13403
|
|
|
13404
13404
|
<a href="#nautobot.apps.forms.parse_numeric_range" class="headerlink" title="Permanent link">¶</a></h2>
|
|
13405
13405
|
|
|
13406
13406
|
|
|
13407
13407
|
<div class="doc doc-contents ">
|
|
13408
13408
|
|
|
13409
|
-
<p>Expand a numeric range (continuous or not) into a decimal or
|
|
13409
|
+
<p>Expand a numeric range (continuous or not) into a sorted decimal or
|
|
13410
13410
|
hexadecimal list, as specified by the base parameter
|
|
13411
13411
|
'0-3,5' => [0, 1, 2, 3, 5]
|
|
13412
13412
|
'2,8-b,d,f' => [2, 8, 9, a, b, d, f]</p>
|
|
@@ -8689,11 +8689,11 @@
|
|
|
8689
8689
|
</details>
|
|
8690
8690
|
<details class="version-changed">
|
|
8691
8691
|
<summary>Changed in version 2.3.0 — Support for Contact and Team assignment on OrganizationalModel and PrimaryModel only</summary>
|
|
8692
|
-
<p>Default support for Contact and Team assignment was removed from <code>django.db.models.Model</code> and <code>BaseModel</code>. The mixin class <code>ContactMixin</code> has been added to be used by <code>BaseModel</code> subclasses that want to be assignable to Contacts and Teams. All subclasses of <code>OrganizationalModel</code> and <code>PrimaryModel</code> include this mixin and therefore default to supporting Contact and Team assignment
|
|
8692
|
+
<p>Default support for Contact and Team assignment was removed from <code>django.db.models.Model</code> and <code>BaseModel</code>. The mixin class <code>ContactMixin</code> has been added to be used by <code>BaseModel</code> subclasses that want to be assignable to Contacts and Teams. All subclasses of <code>OrganizationalModel</code> and <code>PrimaryModel</code> include this mixin and therefore default to supporting Contact and Team assignment. Models can opt out of this feature by declaring the class attribute <code>is_contact_associable_model = False</code>.</p>
|
|
8693
8693
|
</details>
|
|
8694
8694
|
<details class="version-added">
|
|
8695
8695
|
<summary>Added in version 2.3.0 — Support for Dynamic Groups and Saved Views on OrganizationalModel and PrimaryModel</summary>
|
|
8696
|
-
<p>Support for Dynamic Groups and Saved Views was added to <code>OrganizationalModel</code> and <code>PrimaryModel</code>. The mixin classes <code>DynamicGroupsModelMixin</code> and <code>SavedViewMixin</code> (included in both of those base classes) have been added to be used by <code>BaseModel</code> subclasses that want to be assignable to Dynamic Groups and/or to be Saved View capable.</p>
|
|
8696
|
+
<p>Support for Dynamic Groups and Saved Views was added to <code>OrganizationalModel</code> and <code>PrimaryModel</code>. The mixin classes <code>DynamicGroupsModelMixin</code> and <code>SavedViewMixin</code> (included in both of those base classes) have been added to be used by <code>BaseModel</code> subclasses that want to be assignable to Dynamic Groups and/or to be Saved View capable. Models can opt out of either of these features by declaring <code>is_dynamic_group_associable_model = False</code> and/or <code>is_saved_view_model = False</code> as applicable.</p>
|
|
8697
8697
|
</details>
|
|
8698
8698
|
<details class="version-changed">
|
|
8699
8699
|
<summary>Changed in version 2.3.0 — Replacement of DynamicGroupMixin with DynamicGroupsModelMixin</summary>
|
|
Binary file
|