Browse Source

Closes #2658: Avalable VLANs API endpoint for VLAN groups

jeremystretch 4 years ago
parent
commit
e0cfd5e49b

+ 4 - 0
docs/release-notes/version-3.2.md

@@ -14,6 +14,10 @@
 
 ### New Features
 
+#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658))
+
+A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
+
 #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))
 
 Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. 

+ 34 - 0
netbox/ipam/api/serializers.py

@@ -213,6 +213,40 @@ class VLANSerializer(PrimaryModelSerializer):
         ]
 
 
+class AvailableVLANSerializer(serializers.Serializer):
+    """
+    Representation of a VLAN which does not exist in the database.
+    """
+    vid = serializers.IntegerField(read_only=True)
+    group = NestedVLANGroupSerializer(read_only=True)
+
+    def to_representation(self, instance):
+        return OrderedDict([
+            ('vid', instance),
+            ('group', NestedVLANGroupSerializer(
+                self.context['group'],
+                context={'request': self.context['request']}
+            ).data),
+        ])
+
+
+class CreateAvailableVLANSerializer(PrimaryModelSerializer):
+    site = NestedSiteSerializer(required=False, allow_null=True)
+    tenant = NestedTenantSerializer(required=False, allow_null=True)
+    status = ChoiceField(choices=VLANStatusChoices, required=False)
+    role = NestedRoleSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = VLAN
+        fields = [
+            'name', 'site', 'tenant', 'status', 'role', 'description', 'tags', 'custom_fields',
+        ]
+
+    def validate(self, data):
+        # Bypass model validation since we don't have a VID yet
+        return data
+
+
 #
 # Prefixes
 #

+ 5 - 0
netbox/ipam/api/urls.py

@@ -62,6 +62,11 @@ urlpatterns = [
         views.PrefixAvailableIPAddressesView.as_view(),
         name='prefix-available-ips'
     ),
+    path(
+        'vlan-groups/<int:pk>/available-vlans/',
+        views.AvailableVLANsView.as_view(),
+        name='vlangroup-available-vlans'
+    ),
 ]
 
 urlpatterns += router.urls

+ 72 - 0
netbox/ipam/api/views.py

@@ -327,3 +327,75 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
 
     def get_parent(self, request, pk):
         return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
+
+
+class AvailableVLANsView(ObjectValidationMixin, APIView):
+    queryset = VLAN.objects.all()
+
+    @swagger_auto_schema(responses={200: serializers.AvailableVLANSerializer(many=True)})
+    def get(self, request, pk):
+        vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
+        available_vlans = vlangroup.get_available_vids()
+
+        serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={
+            'request': request,
+            'group': vlangroup,
+        })
+
+        return Response(serializer.data)
+
+    @swagger_auto_schema(
+        request_body=serializers.CreateAvailableVLANSerializer,
+        responses={201: serializers.VLANSerializer(many=True)}
+    )
+    @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
+    def post(self, request, pk):
+        self.queryset = self.queryset.restrict(request.user, 'add')
+        vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
+        available_vlans = vlangroup.get_available_vids()
+        many = isinstance(request.data, list)
+
+        # Validate requested VLANs
+        serializer = serializers.CreateAvailableVLANSerializer(
+            data=request.data if many else [request.data],
+            many=True,
+            context={
+                'request': request,
+                'group': vlangroup,
+            }
+        )
+        if not serializer.is_valid():
+            return Response(
+                serializer.errors,
+                status=status.HTTP_400_BAD_REQUEST
+            )
+
+        requested_vlans = serializer.validated_data
+
+        for i, requested_vlan in enumerate(requested_vlans):
+            try:
+                requested_vlan['vid'] = available_vlans.pop(0)
+                requested_vlan['group'] = vlangroup.pk
+            except IndexError:
+                return Response({
+                    "detail": "The requested number of VLANs is not available"
+                }, status=status.HTTP_409_CONFLICT)
+
+        # Initialize the serializer with a list or a single object depending on what was requested
+        context = {'request': request}
+        if many:
+            serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context)
+        else:
+            serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context)
+
+        # Create the new VLAN(s)
+        if serializer.is_valid():
+            try:
+                with transaction.atomic():
+                    created = serializer.save()
+                    self._validate_objects(created)
+            except ObjectDoesNotExist:
+                raise PermissionDenied()
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 10 - 0
netbox/ipam/models/vlans.py

@@ -75,6 +75,16 @@ class VLANGroup(OrganizationalModel):
         if self.scope_id and not self.scope_type:
             raise ValidationError("Cannot set scope_id without scope_type.")
 
+    def get_available_vids(self):
+        """
+        Return all available VLANs within this group.
+        """
+        available_vlans = {vid for vid in range(VLAN_VID_MIN, VLAN_VID_MAX + 1)}
+        available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True))
+
+        # TODO: Check ordering
+        return list(available_vlans)
+
     def get_next_available_vid(self):
         """
         Return the first available VLAN ID (1-4094) in the group.

+ 76 - 0
netbox/ipam/tests/test_api.py

@@ -695,6 +695,82 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
         )
         VLANGroup.objects.bulk_create(vlan_groups)
 
+    def test_list_available_vlans(self):
+        """
+        Test retrieval of all available VLANs within a group.
+        """
+        self.add_permissions('ipam.view_vlan')
+        vlangroup = VLANGroup.objects.first()
+
+        vlans = (
+            VLAN(vid=10, name='VLAN 10', group=vlangroup),
+            VLAN(vid=20, name='VLAN 20', group=vlangroup),
+            VLAN(vid=30, name='VLAN 30', group=vlangroup),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        # Retrieve all available VLANs
+        url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(len(response.data), 4094 - len(vlans))
+        available_vlans = {vlan['vid'] for vlan in response.data}
+        for vlan in vlans:
+            self.assertNotIn(vlan.vid, available_vlans)
+
+    def test_create_single_available_vlan(self):
+        """
+        Test the creation of a single available VLAN.
+        """
+        self.add_permissions('ipam.view_vlan', 'ipam.add_vlan')
+        vlangroup = VLANGroup.objects.first()
+        VLAN.objects.create(vid=1, name='VLAN 1', group=vlangroup)
+
+        data = {
+            "name": "First VLAN",
+        }
+        url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['name'], data['name'])
+        self.assertEqual(response.data['group']['id'], vlangroup.pk)
+        self.assertEqual(response.data['vid'], 2)
+
+    def test_create_multiple_available_vlans(self):
+        """
+        Test the creation of multiple available VLANs.
+        """
+        self.add_permissions('ipam.view_vlan', 'ipam.add_vlan')
+        vlangroup = VLANGroup.objects.first()
+
+        vlans = (
+            VLAN(vid=1, name='VLAN 1', group=vlangroup),
+            VLAN(vid=3, name='VLAN 3', group=vlangroup),
+            VLAN(vid=5, name='VLAN 5', group=vlangroup),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        data = (
+            {"name": "First VLAN"},
+            {"name": "Second VLAN"},
+            {"name": "Third VLAN"},
+        )
+        url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(len(response.data), 3)
+        self.assertEqual(response.data[0]['name'], data[0]['name'])
+        self.assertEqual(response.data[0]['group']['id'], vlangroup.pk)
+        self.assertEqual(response.data[0]['vid'], 2)
+        self.assertEqual(response.data[1]['name'], data[1]['name'])
+        self.assertEqual(response.data[1]['group']['id'], vlangroup.pk)
+        self.assertEqual(response.data[1]['vid'], 4)
+        self.assertEqual(response.data[2]['name'], data[2]['name'])
+        self.assertEqual(response.data[2]['group']['id'], vlangroup.pk)
+        self.assertEqual(response.data[2]['vid'], 6)
+
 
 class VLANTest(APIViewTestCases.APIViewTestCase):
     model = VLAN

+ 1 - 0
netbox/utilities/constants.py

@@ -42,6 +42,7 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
 ADVISORY_LOCK_KEYS = {
     'available-prefixes': 100100,
     'available-ips': 100200,
+    'available-vlans': 100300,
 }
 
 #