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.
Files changed (39) hide show
  1. nautobot/core/api/utils.py +12 -2
  2. nautobot/core/forms/fields.py +5 -2
  3. nautobot/core/forms/utils.py +31 -6
  4. nautobot/core/models/fields.py +56 -0
  5. nautobot/core/tests/test_utils.py +83 -0
  6. nautobot/dcim/tests/test_api.py +4 -1
  7. nautobot/extras/factory.py +2 -1
  8. nautobot/extras/models/models.py +2 -0
  9. nautobot/extras/tests/test_api.py +3 -3
  10. nautobot/extras/tests/test_forms.py +2 -0
  11. nautobot/extras/tests/test_views.py +2 -2
  12. nautobot/ipam/api/serializers.py +30 -1
  13. nautobot/ipam/api/views.py +165 -3
  14. nautobot/ipam/filters.py +1 -1
  15. nautobot/ipam/forms.py +2 -0
  16. nautobot/ipam/migrations/0050_vlangroup_range.py +24 -0
  17. nautobot/ipam/models.py +51 -8
  18. nautobot/ipam/tables.py +4 -4
  19. nautobot/ipam/templates/ipam/vlangroup.html +4 -0
  20. nautobot/ipam/tests/test_api.py +174 -0
  21. nautobot/ipam/tests/test_models.py +35 -1
  22. nautobot/ipam/tests/test_utils.py +61 -0
  23. nautobot/ipam/tests/test_views.py +2 -0
  24. nautobot/ipam/utils/__init__.py +10 -17
  25. nautobot/ipam/views.py +1 -1
  26. nautobot/project-static/docs/code-reference/nautobot/apps/forms.html +2 -2
  27. nautobot/project-static/docs/development/apps/api/models/index.html +2 -2
  28. nautobot/project-static/docs/objects.inv +0 -0
  29. nautobot/project-static/docs/release-notes/version-2.3.html +218 -72
  30. nautobot/project-static/docs/search/search_index.json +1 -1
  31. nautobot/project-static/docs/sitemap.xml +269 -269
  32. nautobot/project-static/docs/sitemap.xml.gz +0 -0
  33. nautobot/project-static/docs/user-guide/core-data-model/ipam/vlangroup.html +305 -0
  34. {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/METADATA +1 -1
  35. {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/RECORD +39 -37
  36. {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/LICENSE.txt +0 -0
  37. {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/NOTICE +0 -0
  38. {nautobot-2.3.5.dist-info → nautobot-2.3.6.dist-info}/WHEEL +0 -0
  39. {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(OrganizationalModel):
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 (1-4094) in the group.
1355
+ Return the first available VLAN ID in the group's range.
1317
1356
  """
1318
- vlan_ids = VLAN.objects.filter(vlan_group=self).values_list("vid", flat=True)
1319
- for i in range(1, 4095):
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
  #
@@ -17,6 +17,10 @@
17
17
  <td>Location</td>
18
18
  <td>{% include 'dcim/inc/location_hierarchy.html' with location=object.location %}</td>
19
19
  </tr>
20
+ <tr>
21
+ <td>Range</td>
22
+ <td>{{ object.range }}</td>
23
+ </tr>
20
24
  <tr>
21
25
  <td>VLANs</td>
22
26
  <td>
@@ -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):
@@ -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
- if not vlans:
89
- return [{"vid": VLAN_VID_MIN, "available": VLAN_VID_MAX - VLAN_VID_MIN + 1}]
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
- prev_vid = VLAN_VID_MAX
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">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>
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">&para;</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' =&gt; [0, 1, 2, 3, 5]
13412
13412
  '2,8-b,d,f' =&gt; [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.</p>
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