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/core/api/utils.py
CHANGED
|
@@ -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"
|
|
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.
|
|
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])
|
nautobot/core/forms/fields.py
CHANGED
|
@@ -719,8 +719,11 @@ class NumericArrayField(SimpleArrayField):
|
|
|
719
719
|
|
|
720
720
|
def to_python(self, value):
|
|
721
721
|
try:
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
nautobot/core/forms/utils.py
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
30
|
-
|
|
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]
|
nautobot/core/models/fields.py
CHANGED
|
@@ -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
|
|
nautobot/dcim/tests/test_api.py
CHANGED
|
@@ -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(
|
|
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
|
nautobot/extras/factory.py
CHANGED
nautobot/extras/models/models.py
CHANGED
|
@@ -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
|
-
#
|
|
4084
|
-
data = {**self.create_data[0], "content_types": [
|
|
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: {
|
|
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
|
-
|
|
3598
|
+
manufacturer_content_type = ContentType.objects.get_for_model(Manufacturer)
|
|
3599
3599
|
|
|
3600
3600
|
form_data = {
|
|
3601
3601
|
**self.form_data,
|
|
3602
|
-
"content_types": [
|
|
3602
|
+
"content_types": [manufacturer_content_type.id],
|
|
3603
3603
|
}
|
|
3604
3604
|
|
|
3605
3605
|
request = {
|
nautobot/ipam/api/serializers.py
CHANGED
|
@@ -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
|
#
|
nautobot/ipam/api/views.py
CHANGED
|
@@ -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 =
|
|
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
|
@@ -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
|
+
]
|