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
@@ -116,7 +116,7 @@ def get_serializer_for_model(model, prefix=""):
116
116
  return dynamic_import(serializer_name)
117
117
  except AttributeError as exc:
118
118
  raise exceptions.SerializerNotFound(
119
- f"Could not determine serializer for {app_label}.{model_name} with prefix '{prefix}'"
119
+ f"Serializer for {app_label}.{model_name} not found, expected it at {serializer_name}"
120
120
  ) from exc
121
121
 
122
122
 
@@ -129,16 +129,26 @@ def nested_serializers_for_models(models, prefix=""):
129
129
 
130
130
  Used exclusively in OpenAPI schema generation.
131
131
  """
132
+ from nautobot.core.api.serializers import BaseModelSerializer # avoid circular import
133
+
132
134
  serializer_classes = []
133
135
  for model in models:
134
136
  try:
135
137
  serializer_classes.append(get_serializer_for_model(model, prefix=prefix))
136
138
  except exceptions.SerializerNotFound as exc:
137
- logger.error("%s", exc)
139
+ logger.warning("%s", exc)
138
140
  continue
139
141
 
140
142
  nested_serializer_classes = []
141
143
  for serializer_class in serializer_classes:
144
+ if not issubclass(serializer_class, BaseModelSerializer):
145
+ logger.warning(
146
+ "Serializer class %s.%s does not inherit from nautobot.apps.api.BaseModelSerializer. "
147
+ "This should probably be corrected.",
148
+ serializer_class.__module__,
149
+ serializer_class.__name__,
150
+ )
151
+ continue
142
152
  nested_serializer_name = f"Nested{serializer_class.__name__}"
143
153
  if nested_serializer_name in NESTED_SERIALIZER_CACHE:
144
154
  nested_serializer_classes.append(NESTED_SERIALIZER_CACHE[nested_serializer_name])
@@ -719,8 +719,11 @@ class NumericArrayField(SimpleArrayField):
719
719
 
720
720
  def to_python(self, value):
721
721
  try:
722
- value = ",".join([str(n) for n in forms.parse_numeric_range(value)])
723
- except ValueError as error:
722
+ if not value:
723
+ value = ""
724
+ else:
725
+ value = ",".join([str(n) for n in forms.parse_numeric_range(value)])
726
+ except (TypeError, ValueError) as error:
724
727
  raise ValidationError(error)
725
728
  return super().to_python(value)
726
729
 
@@ -1,3 +1,4 @@
1
+ from itertools import groupby
1
2
  import re
2
3
 
3
4
  from django import forms as django_forms
@@ -18,24 +19,31 @@ __all__ = (
18
19
  )
19
20
 
20
21
 
21
- def parse_numeric_range(string, base=10):
22
+ def parse_numeric_range(input_string, base=10):
22
23
  """
23
- Expand a numeric range (continuous or not) into a decimal or
24
+ Expand a numeric range (continuous or not) into a sorted decimal or
24
25
  hexadecimal list, as specified by the base parameter
25
26
  '0-3,5' => [0, 1, 2, 3, 5]
26
27
  '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
27
28
  """
29
+ if base not in [10, 16]:
30
+ raise TypeError("Invalid base value.")
31
+
32
+ if not isinstance(input_string, str) or not input_string:
33
+ raise TypeError("Input value must be a string using a range format.")
34
+
28
35
  values = []
29
- if not string:
30
- return values
31
- for dash_range in string.split(","):
36
+
37
+ for dash_range in input_string.split(","):
32
38
  try:
33
39
  begin, end = dash_range.split("-")
40
+ if begin == "" or end == "":
41
+ raise TypeError("Input value must be a string using a range format.")
34
42
  except ValueError:
35
43
  begin, end = dash_range, dash_range
36
44
  begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
37
45
  values.extend(range(begin, end))
38
- return list(set(values))
46
+ return sorted(list(set(values)))
39
47
 
40
48
 
41
49
  def parse_alphanumeric_range(string):
@@ -150,3 +158,20 @@ def add_field_to_filter_form_class(form_class, field_name, field_obj):
150
158
  f"There was a conflict with filter form field `{field_name}`, the custom filter form field was ignored."
151
159
  )
152
160
  form_class.base_fields[field_name] = field_obj
161
+
162
+
163
+ def compress_range(iterable):
164
+ """
165
+ Generates compressed range from an un-sorted expanded range.
166
+ For example:
167
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 101, 102, 103, 104, 105, 1000, 1100, 1101, 1102, 1103, 1104, 1105, 1106]
168
+ =>
169
+ iter1: (1, 10)
170
+ iter2: (100, 105)
171
+ iter3: (1000, 1000)
172
+ iter4: (1100, 1106)
173
+ """
174
+ iterable = sorted(set(iterable))
175
+ for _, grp in groupby(enumerate(iterable), lambda t: t[1] - t[0]):
176
+ grp = list(grp)
177
+ yield grp[0][1], grp[-1][1]
@@ -4,6 +4,7 @@ import re
4
4
  from django.core import exceptions
5
5
  from django.core.validators import MaxLengthValidator, RegexValidator
6
6
  from django.db import models
7
+ from django.forms import TextInput
7
8
  from django.utils.text import slugify
8
9
  from django_extensions.db.fields import AutoSlugField as _AutoSlugField
9
10
  from netaddr import AddrFormatError, EUI, mac_unix_expanded
@@ -11,6 +12,7 @@ from taggit.managers import TaggableManager
11
12
 
12
13
  from nautobot.core.constants import CHARFIELD_MAX_LENGTH
13
14
  from nautobot.core.forms import fields, widgets
15
+ from nautobot.core.forms.utils import compress_range, parse_numeric_range
14
16
  from nautobot.core.models import ordering
15
17
  from nautobot.core.models.managers import TagsManager
16
18
  from nautobot.core.models.validators import EnhancedURLValidator
@@ -415,3 +417,57 @@ class TagsField(TaggableManager):
415
417
  kwargs.setdefault("required", False)
416
418
  kwargs.setdefault("query_params", {"content_types": self.model._meta.label_lower})
417
419
  return super().formfield(form_class=form_class, **kwargs)
420
+
421
+
422
+ class PositiveRangeNumberTextField(models.TextField):
423
+ default_error_messages = {
424
+ "invalid": "Invalid value. Specify a value using non-negative integers in a range format (i.e. '10-20').",
425
+ }
426
+
427
+ description = "A text based representation of positive number range."
428
+
429
+ def __init__(self, min_boundary=0, max_boundary=None, *args, **kwargs):
430
+ super().__init__(*args, **kwargs)
431
+ self.min_boundary = min_boundary
432
+ self.max_boundary = max_boundary
433
+
434
+ def to_python(self, value):
435
+ if value is None:
436
+ return None
437
+
438
+ try:
439
+ self.expanded = sorted(parse_numeric_range(value))
440
+ except (ValueError, AttributeError):
441
+ raise exceptions.ValidationError(
442
+ self.error_messages["invalid"],
443
+ code="invalid",
444
+ params={"value": value},
445
+ )
446
+
447
+ converted_ranges = compress_range(self.expanded)
448
+ normalized_range = ",".join([f"{x[0]}" if x[0] == x[1] else f"{x[0]}-{x[1]}" for x in converted_ranges])
449
+
450
+ return normalized_range
451
+
452
+ def validate(self, value, model_instance):
453
+ """
454
+ Validate `value` and raise ValidationError if necessary.
455
+ """
456
+ super().validate(value, model_instance)
457
+
458
+ if (self.min_boundary is not None and self.expanded[0] < self.min_boundary) or (
459
+ self.max_boundary is not None and self.expanded[-1] > self.max_boundary
460
+ ):
461
+ raise exceptions.ValidationError(
462
+ message=f"Invalid value. Specify a range value between {self.min_boundary}-{self.max_boundary or 'unlimited'}",
463
+ code="outofrange",
464
+ params={"value": value},
465
+ )
466
+
467
+ def formfield(self, **kwargs):
468
+ return super().formfield(
469
+ **{
470
+ "widget": TextInput,
471
+ **kwargs,
472
+ }
473
+ )
@@ -11,6 +11,7 @@ from django.http import QueryDict
11
11
  from nautobot.circuits import models as circuits_models
12
12
  from nautobot.core import exceptions, forms, settings_funcs
13
13
  from nautobot.core.api import utils as api_utils
14
+ from nautobot.core.forms.utils import compress_range
14
15
  from nautobot.core.models import fields as core_fields, utils as models_utils, validators
15
16
  from nautobot.core.testing import TestCase
16
17
  from nautobot.core.utils import data as data_utils, filtering, lookup, requests
@@ -365,6 +366,88 @@ class PrettyPrintQueryTest(TestCase):
365
366
  self.assertEqual(models_utils.pretty_print_query(query), expected)
366
367
 
367
368
 
369
+ class CompressRangeTest(TestCase):
370
+ """Tests for compress_range()."""
371
+
372
+ def test_compress_range_sparse(self):
373
+ values = [1500, 200, 10, 2222, 3000, 4096]
374
+ self.assertEqual(
375
+ list(compress_range(values)),
376
+ [
377
+ (10, 10),
378
+ (200, 200),
379
+ (1500, 1500),
380
+ (2222, 2222),
381
+ (3000, 3000),
382
+ (4096, 4096),
383
+ ],
384
+ )
385
+
386
+ def test_compress_range_dense(self):
387
+ values = [
388
+ 1,
389
+ 2,
390
+ 3,
391
+ 4,
392
+ 5,
393
+ 6,
394
+ 7,
395
+ 8,
396
+ 9,
397
+ 10,
398
+ 100,
399
+ 101,
400
+ 102,
401
+ 103,
402
+ 104,
403
+ 105,
404
+ 1100,
405
+ 1101,
406
+ 1102,
407
+ 1103,
408
+ 1104,
409
+ 1105,
410
+ 1106,
411
+ ]
412
+ self.assertEqual(
413
+ list(compress_range(values)),
414
+ [(1, 10), (100, 105), (1100, 1106)],
415
+ )
416
+
417
+ def test_compress_range_complex(self):
418
+ values = [
419
+ 10,
420
+ 11,
421
+ 12,
422
+ 13,
423
+ 14,
424
+ 15,
425
+ 100,
426
+ 200,
427
+ 210,
428
+ 211,
429
+ 212,
430
+ 222,
431
+ 500,
432
+ 501,
433
+ 502,
434
+ 503,
435
+ 600,
436
+ ]
437
+ self.assertEqual(
438
+ list(compress_range(values)),
439
+ [
440
+ (10, 15),
441
+ (100, 100),
442
+ (200, 200),
443
+ (210, 212),
444
+ (222, 222),
445
+ (500, 503),
446
+ (600, 600),
447
+ ],
448
+ )
449
+
450
+
368
451
  class SlugifyFunctionsTest(TestCase):
369
452
  """Test custom slugify functions."""
370
453
 
@@ -646,6 +646,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
646
646
  locations = Location.objects.filter(devices__isnull=False)[:2]
647
647
  for location in locations:
648
648
  location.location_type.content_types.add(ContentType.objects.get_for_model(RackGroup))
649
+ location.location_type.content_types.add(ContentType.objects.get_for_model(Rack))
649
650
 
650
651
  rack_groups = (
651
652
  RackGroup.objects.create(location=locations[0], name="Rack Group 1"),
@@ -685,7 +686,9 @@ class RackTest(APIViewTestCases.APIViewTestCase):
685
686
  status=statuses[0],
686
687
  )
687
688
  # Place a device in Rack 4
688
- device = Device.objects.filter(location=populated_rack.location, rack=None).first()
689
+ device = Device.objects.filter(
690
+ location=populated_rack.location, rack__isnull=True, device_type__u_height__gt=0
691
+ ).first()
689
692
  device.rack = populated_rack
690
693
  device.face = "front"
691
694
  device.position = 10
@@ -246,7 +246,8 @@ class MetadataTypeFactory(PrimaryModelFactory):
246
246
  lambda: ContentType.objects.filter(
247
247
  FeatureQuery("metadata").get_query(), pk__in=existing_content_type_pks
248
248
  ),
249
- minimum=1,
249
+ minimum=3,
250
+ maximum=5,
250
251
  )
251
252
  )
252
253
 
@@ -575,6 +575,8 @@ class FileAttachment(BaseModel):
575
575
  filename = models.CharField(max_length=CHARFIELD_MAX_LENGTH)
576
576
  mimetype = models.CharField(max_length=CHARFIELD_MAX_LENGTH)
577
577
 
578
+ is_metadata_associable_model = False
579
+
578
580
  natural_key_field_names = ["pk"]
579
581
 
580
582
  def __str__(self):
@@ -4080,14 +4080,14 @@ class TagTest(APIViewTestCases.APIViewTestCase):
4080
4080
  def test_create_tags_with_invalid_content_types(self):
4081
4081
  self.add_permissions("extras.add_tag")
4082
4082
 
4083
- # VLANGroup is an OrganizationalModel, not a PrimaryModel, and therefore does not support tags
4084
- data = {**self.create_data[0], "content_types": [VLANGroup._meta.label_lower]}
4083
+ # Manufacturer is an OrganizationalModel, not a PrimaryModel, and therefore does not support tags
4084
+ data = {**self.create_data[0], "content_types": [Manufacturer._meta.label_lower]}
4085
4085
  response = self.client.post(self._get_list_url(), data, format="json", **self.header)
4086
4086
 
4087
4087
  tag = Tag.objects.filter(name=data["name"])
4088
4088
  self.assertHttpStatus(response, 400)
4089
4089
  self.assertFalse(tag.exists())
4090
- self.assertIn(f"Invalid content type: {VLANGroup._meta.label_lower}", response.data["content_types"])
4090
+ self.assertIn(f"Invalid content type: {Manufacturer._meta.label_lower}", response.data["content_types"])
4091
4091
 
4092
4092
  def test_create_tags_without_content_types(self):
4093
4093
  self.add_permissions("extras.add_tag")
@@ -388,6 +388,7 @@ class RelationshipModelFormTestCase(TestCase):
388
388
  cls.vlangroup_form_base_data = {
389
389
  "location": cls.location.pk,
390
390
  "name": "New VLAN Group",
391
+ "range": "1-4094",
391
392
  }
392
393
 
393
394
  def test_create_relationship_associations_valid_1(self):
@@ -665,6 +666,7 @@ class RelationshipModelFormTestCase(TestCase):
665
666
  data={
666
667
  "name": self.vlangroup_1.name,
667
668
  "location": self.location,
669
+ "range": "1-4094",
668
670
  f"cr_{self.relationship_2.key}__source": self.device_2.pk,
669
671
  },
670
672
  )
@@ -3595,11 +3595,11 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
3595
3595
 
3596
3596
  def test_create_tags_with_invalid_content_types(self):
3597
3597
  self.add_permissions("extras.add_tag")
3598
- vlangroup_content_type = ContentType.objects.get_for_model(VLANGroup)
3598
+ manufacturer_content_type = ContentType.objects.get_for_model(Manufacturer)
3599
3599
 
3600
3600
  form_data = {
3601
3601
  **self.form_data,
3602
- "content_types": [vlangroup_content_type.id],
3602
+ "content_types": [manufacturer_content_type.id],
3603
3603
  }
3604
3604
 
3605
3605
  request = {
@@ -118,7 +118,7 @@ class RIRSerializer(NautobotModelSerializer):
118
118
  #
119
119
 
120
120
 
121
- class VLANGroupSerializer(NautobotModelSerializer):
121
+ class VLANGroupSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
122
122
  vlan_count = serializers.IntegerField(read_only=True)
123
123
 
124
124
  class Meta:
@@ -481,6 +481,35 @@ class IPAllocationSerializer(NautobotModelSerializer, TaggedModelSerializerMixin
481
481
  return super().validate(data)
482
482
 
483
483
 
484
+ class VLANAllocationSerializer(NautobotModelSerializer, TaggedModelSerializerMixin):
485
+ """
486
+ Input serializer for POST to /api/ipam/vlan-groups/<id>/available-vlans/, i.e. allocating VLAN from VLANGroup.
487
+ """
488
+
489
+ vid = serializers.IntegerField(required=False, min_value=constants.VLAN_VID_MIN, max_value=constants.VLAN_VID_MAX)
490
+
491
+ def validate(self, data):
492
+ """Skip `ValidatedModel` validation.
493
+ This allows to skip `vid` attribute of `VLAN` model, while validate name and status.
494
+ """
495
+ return data
496
+
497
+ class Meta(VLANSerializer.Meta):
498
+ model = VLAN
499
+ fields = (
500
+ # permit "vid" and "vlan_group" for `VLAN` consistency.
501
+ # validate them under `VLANGroupViewSet`
502
+ "vid",
503
+ "vlan_group",
504
+ "name",
505
+ "status",
506
+ "role",
507
+ "tenant",
508
+ "description",
509
+ "custom_fields",
510
+ )
511
+
512
+
484
513
  #
485
514
  # IP address to interface
486
515
  #
@@ -6,12 +6,15 @@ from rest_framework import status
6
6
  from rest_framework.decorators import action
7
7
  from rest_framework.exceptions import APIException
8
8
  from rest_framework.response import Response
9
+ from rest_framework.serializers import IntegerField, ListSerializer
9
10
 
11
+ from nautobot.core.api.authentication import TokenPermissions
10
12
  from nautobot.core.models.querysets import count_related
11
13
  from nautobot.core.utils.config import get_settings_or_config
12
14
  from nautobot.dcim.models import Location
13
15
  from nautobot.extras.api.views import NautobotModelViewSet
14
16
  from nautobot.ipam import filters
17
+ from nautobot.ipam.api import serializers
15
18
  from nautobot.ipam.models import (
16
19
  IPAddress,
17
20
  IPAddressToInterface,
@@ -29,8 +32,6 @@ from nautobot.ipam.models import (
29
32
  VRFPrefixAssignment,
30
33
  )
31
34
 
32
- from . import serializers
33
-
34
35
  #
35
36
  # Namespace
36
37
  #
@@ -387,10 +388,171 @@ class IPAddressToInterfaceViewSet(NautobotModelViewSet):
387
388
 
388
389
 
389
390
  class VLANGroupViewSet(NautobotModelViewSet):
390
- queryset = VLANGroup.objects.select_related("location").annotate(vlan_count=count_related(VLAN, "vlan_group"))
391
+ queryset = (
392
+ VLANGroup.objects.select_related("location")
393
+ .prefetch_related("tags")
394
+ .annotate(vlan_count=count_related(VLAN, "vlan_group"))
395
+ )
391
396
  serializer_class = serializers.VLANGroupSerializer
392
397
  filterset_class = filters.VLANGroupFilterSet
393
398
 
399
+ def restrict_queryset(self, request, *args, **kwargs):
400
+ """
401
+ Apply "view" permissions on the POST /available-vlans/ endpoint, otherwise as ModelViewSetMixin.
402
+ """
403
+ if request.user.is_authenticated and self.action == "available_vlans":
404
+ self.queryset = self.queryset.restrict(request.user, "view")
405
+ else:
406
+ super().restrict_queryset(request, *args, **kwargs)
407
+
408
+ class AvailableVLANPermissions(TokenPermissions):
409
+ """As nautobot.core.api.authentication.TokenPermissions, but enforcing add_vlan permission."""
410
+
411
+ perms_map = {
412
+ "GET": ["ipam.view_vlangroup"],
413
+ "POST": ["ipam.view_vlangroup", "ipam.add_vlan"],
414
+ }
415
+
416
+ @extend_schema(methods=["get"], responses={200: ListSerializer(child=IntegerField())})
417
+ @extend_schema(
418
+ methods=["post"],
419
+ responses={201: serializers.VLANSerializer(many=True)},
420
+ request=serializers.VLANAllocationSerializer(many=True),
421
+ )
422
+ @action(
423
+ detail=True,
424
+ name="Available VLAN IDs",
425
+ url_path="available-vlans",
426
+ methods=["get", "post"],
427
+ permission_classes=[AvailableVLANPermissions],
428
+ filterset_class=None,
429
+ )
430
+ def available_vlans(self, request, pk=None):
431
+ """
432
+ A convenience method for listing available VLAN IDs within a VLANGroup.
433
+ By default, the number of VIDs returned will be equivalent to PAGINATE_COUNT.
434
+ An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed, however results will not be paginated.
435
+ """
436
+ vlan_group = get_object_or_404(self.queryset, pk=pk)
437
+
438
+ if request.method == "POST":
439
+ with cache.lock(
440
+ "nautobot.ipam.api.views.available_vlans", blocking_timeout=5, timeout=settings.REDIS_LOCK_TIMEOUT
441
+ ):
442
+ # Normalize to a list of objects
443
+ serializer = serializers.VLANAllocationSerializer(
444
+ data=request.data if isinstance(request.data, list) else [request.data],
445
+ many=True,
446
+ context={
447
+ "request": request,
448
+ "vlan_group": vlan_group,
449
+ },
450
+ )
451
+ serializer.is_valid(raise_exception=True)
452
+ requested_vlans = serializer.validated_data
453
+
454
+ # Determine if the requested number of VLANs is available
455
+ available_vids = vlan_group.available_vids
456
+ if len(available_vids) < len(requested_vlans):
457
+ return Response(
458
+ {
459
+ "detail": (
460
+ f"An insufficient number of VLANs are available within the VLANGroup {vlan_group} "
461
+ f"({len(requested_vlans)} requested, {len(available_vids)} available)"
462
+ )
463
+ },
464
+ status=status.HTTP_204_NO_CONTENT,
465
+ )
466
+
467
+ # Prioritise and check for explicitly requested VIDs. Remove them from available_vids
468
+ for requested_vlan in requested_vlans:
469
+ # Check requested `vid` for availability.
470
+ # This will also catch if same `vid` was requested multiple times in a request.
471
+ if "vid" in requested_vlan and requested_vlan["vid"] not in available_vids:
472
+ return Response(
473
+ {"detail": f"VLAN {requested_vlan['vid']} is not available within the VLANGroup."},
474
+ status=status.HTTP_204_NO_CONTENT,
475
+ )
476
+ elif "vid" in requested_vlan and requested_vlan["vid"] in available_vids:
477
+ available_vids.remove(requested_vlan["vid"])
478
+
479
+ # Assign VLAN IDs from the list of VLANGroup's available VLAN IDs.
480
+ # Available_vids now does not contain explicitly requested vids.
481
+ _available_vids = iter(available_vids)
482
+
483
+ for requested_vlan in requested_vlans:
484
+ if "vid" not in requested_vlan:
485
+ requested_vlan["vid"] = next(_available_vids)
486
+
487
+ # Check requested `vlan_group`
488
+ if "vlan_group" in requested_vlan and requested_vlan["vlan_group"] != vlan_group:
489
+ return Response(
490
+ {
491
+ "detail": f"Invalid VLAN Group requested: {requested_vlan['vlan_group']}. "
492
+ f"Only VLAN Group {vlan_group} is permitted."
493
+ },
494
+ status=status.HTTP_204_NO_CONTENT,
495
+ )
496
+ else:
497
+ requested_vlan["vlan_group"] = vlan_group.pk
498
+
499
+ # Rewrite custom field data
500
+ requested_vlan["custom_fields"] = requested_vlan.pop("_custom_field_data", {})
501
+
502
+ # Initialize the serializer with a list or a single object depending on what was requested
503
+ context = {"request": request, "depth": 0}
504
+
505
+ if isinstance(request.data, list):
506
+ serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context)
507
+ else:
508
+ serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context)
509
+
510
+ # Create the new VLANs
511
+ serializer.is_valid(raise_exception=True)
512
+ serializer.save()
513
+
514
+ data = serializer.data
515
+
516
+ return Response(
517
+ data={
518
+ "count": len(data),
519
+ "next": None,
520
+ "previous": None,
521
+ "results": data,
522
+ },
523
+ status=status.HTTP_201_CREATED,
524
+ )
525
+
526
+ else:
527
+ try:
528
+ limit = int(request.query_params.get("limit", get_settings_or_config("PAGINATE_COUNT")))
529
+ except ValueError:
530
+ limit = get_settings_or_config("PAGINATE_COUNT")
531
+
532
+ if get_settings_or_config("MAX_PAGE_SIZE"):
533
+ limit = min(limit, get_settings_or_config("MAX_PAGE_SIZE"))
534
+
535
+ if isinstance(limit, int) and limit >= 0:
536
+ vids = vlan_group.available_vids[0:limit]
537
+ else:
538
+ vids = vlan_group.available_vids
539
+
540
+ serializer = ListSerializer(
541
+ child=IntegerField(),
542
+ data=vids,
543
+ )
544
+ serializer.is_valid(raise_exception=True)
545
+ data = serializer.validated_data
546
+
547
+ return Response(
548
+ {
549
+ "count": len(data),
550
+ "next": None,
551
+ "previous": None,
552
+ "results": data,
553
+ }
554
+ )
555
+
394
556
 
395
557
  #
396
558
  # VLANs
nautobot/ipam/filters.py CHANGED
@@ -539,7 +539,7 @@ class IPAddressToInterfaceFilterSet(NautobotFilterSet):
539
539
  class VLANGroupFilterSet(NautobotFilterSet, LocatableModelFilterSetMixin, NameSearchFilterSet):
540
540
  class Meta:
541
541
  model = VLANGroup
542
- fields = ["id", "name", "description"]
542
+ fields = ["id", "name", "description", "tags"]
543
543
 
544
544
 
545
545
  class VLANFilterSet(
nautobot/ipam/forms.py CHANGED
@@ -727,7 +727,9 @@ class VLANGroupForm(LocatableModelFormMixin, NautobotModelForm):
727
727
  fields = [
728
728
  "location",
729
729
  "name",
730
+ "range",
730
731
  "description",
732
+ "tags",
731
733
  ]
732
734
 
733
735
 
@@ -0,0 +1,24 @@
1
+ # Generated by Django 4.2.16 on 2024-10-02 17:14
2
+
3
+ from django.db import migrations
4
+
5
+ import nautobot.core.models.fields
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("ipam", "0049_vrf_data_migration"),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="vlangroup",
16
+ name="range",
17
+ field=nautobot.core.models.fields.PositiveRangeNumberTextField(default="1-4094"),
18
+ ),
19
+ migrations.AddField(
20
+ model_name="vlangroup",
21
+ name="tags",
22
+ field=nautobot.core.models.fields.TagsField(through="extras.TaggedItem", to="extras.Tag"),
23
+ ),
24
+ ]