Просмотр исходного кода

Merge release v2.4.7 into develop-2.5

Jeremy Stretch 7 лет назад
Родитель
Сommit
ce00226bc6

+ 10 - 1
CHANGELOG.md

@@ -54,12 +54,21 @@ NetBox now supports modeling physical cables for console, power, and interface c
 
 
 ---
 ---
 
 
-v2.4.7 (FUTURE)
+v2.4.7 (2018-11-06)
+
+## Enhancements
+
+* [#2388](https://github.com/digitalocean/netbox/issues/2388) - Enable filtering of devices/VMs by region
+* [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID
+* [#2512](https://github.com/digitalocean/netbox/issues/2512) - Add device field to inventory item filter form
 
 
 ## Bug Fixes
 ## Bug Fixes
 
 
+* [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF
 * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces
 * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces
 * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled
 * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled
+* [#2528](https://github.com/digitalocean/netbox/issues/2528) - Enable creating circuit terminations with interface assignment via API
+* [#2549](https://github.com/digitalocean/netbox/issues/2549) - Changed naming of `peer_device` and `peer_interface` on API /dcim/connected-device/ endpoint to use underscores
 
 
 ---
 ---
 
 

+ 1 - 1
docs/core-functionality/ipam.md

@@ -4,7 +4,7 @@ The first step to documenting your IP space is to define its scope by creating a
 
 
 * 10.0.0.0/8 (RFC 1918)
 * 10.0.0.0/8 (RFC 1918)
 * 100.64.0.0/10 (RFC 6598)
 * 100.64.0.0/10 (RFC 6598)
-* 172.16.0.0/20 (RFC 1918)
+* 172.16.0.0/12 (RFC 1918)
 * 192.168.0.0/16 (RFC 1918)
 * 192.168.0.0/16 (RFC 1918)
 * One or more /48s within fd00::/8 (IPv6 unique local addressing)
 * One or more /48s within fd00::/8 (IPv6 unique local addressing)
 
 

+ 21 - 15
netbox/circuits/tests/test_api.py

@@ -3,7 +3,7 @@ from rest_framework import status
 
 
 from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z
 from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
 from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
-from dcim.models import Site
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from extras.constants import GRAPH_TYPE_PROVIDER
 from extras.constants import GRAPH_TYPE_PROVIDER
 from extras.models import Graph
 from extras.models import Graph
 from utilities.testing import APITestCase
 from utilities.testing import APITestCase
@@ -328,21 +328,24 @@ class CircuitTerminationTest(APITestCase):
 
 
         super(CircuitTerminationTest, self).setUp()
         super(CircuitTerminationTest, self).setUp()
 
 
+        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
+        self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
         provider = Provider.objects.create(name='Test Provider', slug='test-provider')
         provider = Provider.objects.create(name='Test Provider', slug='test-provider')
         circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
         circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
         self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
         self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
         self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
         self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
         self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
         self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
-        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
-        self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
         self.circuittermination1 = CircuitTermination.objects.create(
         self.circuittermination1 = CircuitTermination.objects.create(
             circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
             circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
         )
         )
         self.circuittermination2 = CircuitTermination.objects.create(
         self.circuittermination2 = CircuitTermination.objects.create(
-            circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+            circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000
         )
         )
         self.circuittermination3 = CircuitTermination.objects.create(
         self.circuittermination3 = CircuitTermination.objects.create(
-            circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+            circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+        )
+        self.circuittermination4 = CircuitTermination.objects.create(
+            circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, port_speed=1000000
         )
         )
 
 
     def test_get_circuittermination(self):
     def test_get_circuittermination(self):
@@ -357,14 +360,14 @@ class CircuitTerminationTest(APITestCase):
         url = reverse('circuits-api:circuittermination-list')
         url = reverse('circuits-api:circuittermination-list')
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
 
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 4)
 
 
     def test_create_circuittermination(self):
     def test_create_circuittermination(self):
 
 
         data = {
         data = {
-            'circuit': self.circuit1.pk,
-            'term_side': TERM_SIDE_Z,
-            'site': self.site2.pk,
+            'circuit': self.circuit3.pk,
+            'term_side': TERM_SIDE_A,
+            'site': self.site1.pk,
             'port_speed': 1000000,
             'port_speed': 1000000,
         }
         }
 
 
@@ -372,7 +375,7 @@ class CircuitTerminationTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(CircuitTermination.objects.count(), 4)
+        self.assertEqual(CircuitTermination.objects.count(), 5)
         circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
         circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
         self.assertEqual(circuittermination4.circuit_id, data['circuit'])
         self.assertEqual(circuittermination4.circuit_id, data['circuit'])
         self.assertEqual(circuittermination4.term_side, data['term_side'])
         self.assertEqual(circuittermination4.term_side, data['term_side'])
@@ -381,20 +384,23 @@ class CircuitTerminationTest(APITestCase):
 
 
     def test_update_circuittermination(self):
     def test_update_circuittermination(self):
 
 
+        circuittermination5 = CircuitTermination.objects.create(
+            circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+        )
+
         data = {
         data = {
-            'circuit': self.circuit1.pk,
+            'circuit': self.circuit3.pk,
             'term_side': TERM_SIDE_Z,
             'term_side': TERM_SIDE_Z,
             'site': self.site2.pk,
             'site': self.site2.pk,
             'port_speed': 1000000,
             'port_speed': 1000000,
         }
         }
 
 
-        url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
+        url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk})
         response = self.client.put(url, data, format='json', **self.header)
         response = self.client.put(url, data, format='json', **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(CircuitTermination.objects.count(), 3)
+        self.assertEqual(CircuitTermination.objects.count(), 5)
         circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
         circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
-        self.assertEqual(circuittermination1.circuit_id, data['circuit'])
         self.assertEqual(circuittermination1.term_side, data['term_side'])
         self.assertEqual(circuittermination1.term_side, data['term_side'])
         self.assertEqual(circuittermination1.site_id, data['site'])
         self.assertEqual(circuittermination1.site_id, data['site'])
         self.assertEqual(circuittermination1.port_speed, data['port_speed'])
         self.assertEqual(circuittermination1.port_speed, data['port_speed'])
@@ -405,4 +411,4 @@ class CircuitTerminationTest(APITestCase):
         response = self.client.delete(url, **self.header)
         response = self.client.delete(url, **self.header)
 
 
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(CircuitTermination.objects.count(), 2)
+        self.assertEqual(CircuitTermination.objects.count(), 3)

+ 11 - 5
netbox/dcim/api/views.py

@@ -524,13 +524,13 @@ class ConnectedDeviceViewSet(ViewSet):
     interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors
     interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors
     via a protocol such as LLDP. Two query parameters must be included in the request:
     via a protocol such as LLDP. Two query parameters must be included in the request:
 
 
-    * `peer-device`: The name of the peer device
-    * `peer-interface`: The name of the peer interface
+    * `peer_device`: The name of the peer device
+    * `peer_interface`: The name of the peer interface
     """
     """
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
-    _device_param = Parameter('peer-device', 'query',
+    _device_param = Parameter('peer_device', 'query',
                               description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
                               description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
-    _interface_param = Parameter('peer-interface', 'query',
+    _interface_param = Parameter('peer_interface', 'query',
                                  description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
                                  description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
 
 
     def get_view_name(self):
     def get_view_name(self):
@@ -541,9 +541,15 @@ class ConnectedDeviceViewSet(ViewSet):
     def list(self, request):
     def list(self, request):
 
 
         peer_device_name = request.query_params.get(self._device_param.name)
         peer_device_name = request.query_params.get(self._device_param.name)
+        if not peer_device_name:
+            # TODO: remove this after 2.4 as the switch to using underscores is a breaking change
+            peer_device_name = request.query_params.get('peer-device')
         peer_interface_name = request.query_params.get(self._interface_param.name)
         peer_interface_name = request.query_params.get(self._interface_param.name)
+        if not peer_interface_name:
+            # TODO: remove this after 2.4 as the switch to using underscores is a breaking change
+            peer_interface_name = request.query_params.get('peer-interface')
         if not peer_device_name or not peer_interface_name:
         if not peer_device_name or not peer_interface_name:
-            raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
+            raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
 
 
         # Determine local interface from peer interface's connection
         # Determine local interface from peer interface's connection
         peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
         peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)

+ 56 - 0
netbox/dcim/filters.py

@@ -1,5 +1,6 @@
 import django_filters
 import django_filters
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
+from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 from django.db.models import Q
 from netaddr import EUI
 from netaddr import EUI
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
@@ -539,6 +540,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
     )
     )
     name = NullableCharFieldFilter()
     name = NullableCharFieldFilter()
     asset_tag = NullableCharFieldFilter()
     asset_tag = NullableCharFieldFilter()
+    region_id = django_filters.NumberFilter(
+        method='filter_region',
+        field_name='pk',
+        label='Region (ID)',
+    )
+    region = django_filters.CharFilter(
+        method='filter_region',
+        field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
@@ -633,6 +644,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         ).distinct()
         ).distinct()
 
 
+    def filter_region(self, queryset, name, value):
+        try:
+            region = Region.objects.get(**{name: value})
+        except ObjectDoesNotExist:
+            return queryset.none()
+        return queryset.filter(
+            Q(site__region=region) |
+            Q(site__region__in=region.get_descendants())
+        )
+
     def _mac_address(self, queryset, name, value):
     def _mac_address(self, queryset, name, value):
         value = value.strip()
         value = value.strip()
         if not value:
         if not value:
@@ -757,6 +778,14 @@ class InterfaceFilter(django_filters.FilterSet):
     tag = django_filters.CharFilter(
     tag = django_filters.CharFilter(
         field_name='tags__slug',
         field_name='tags__slug',
     )
     )
+    vlan_id = django_filters.CharFilter(
+        method='filter_vlan_id',
+        label='Assigned VLAN'
+    )
+    vlan = django_filters.CharFilter(
+        method='filter_vlan',
+        label='Assigned VID'
+    )
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
@@ -770,6 +799,24 @@ class InterfaceFilter(django_filters.FilterSet):
         except Device.DoesNotExist:
         except Device.DoesNotExist:
             return queryset.none()
             return queryset.none()
 
 
+    def filter_vlan_id(self, queryset, name, value):
+        value = value.strip()
+        if not value:
+            return queryset
+        return queryset.filter(
+            Q(untagged_vlan_id=value) |
+            Q(tagged_vlans=value)
+        )
+
+    def filter_vlan(self, queryset, name, value):
+        value = value.strip()
+        if not value:
+            return queryset
+        return queryset.filter(
+            Q(untagged_vlan_id__vid=value) |
+            Q(tagged_vlans__vid=value)
+        )
+
     def filter_type(self, queryset, name, value):
     def filter_type(self, queryset, name, value):
         value = value.strip().lower()
         value = value.strip().lower()
         return {
         return {
@@ -816,6 +863,15 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         method='search',
         method='search',
         label='Search',
         label='Search',
     )
     )
+    device_id = django_filters.ModelChoiceFilter(
+        queryset=Device.objects.all(),
+        label='Device (ID)',
+    )
+    device = django_filters.ModelChoiceFilter(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        label='Device (name)',
+    )
     parent_id = django_filters.ModelMultipleChoiceFilter(
     parent_id = django_filters.ModelMultipleChoiceFilter(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',
         label='Parent inventory item (ID)',

+ 6 - 0
netbox/dcim/forms.py

@@ -1269,6 +1269,11 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     model = Device
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
+    region = FilterTreeNodeMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('devices')),
         queryset=Site.objects.annotate(filter_count=Count('devices')),
         to_field_name='slug',
         to_field_name='slug',
@@ -2141,6 +2146,7 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
 class InventoryItemFilterForm(BootstrapMixin, forms.Form):
 class InventoryItemFilterForm(BootstrapMixin, forms.Form):
     model = InventoryItem
     model = InventoryItem
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
+    device = forms.CharField(required=False, label='Device name')
     manufacturer = FilterChoiceField(
     manufacturer = FilterChoiceField(
         queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')),
         queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')),
         to_field_name='slug',
         to_field_name='slug',

+ 2 - 2
netbox/ipam/models.py

@@ -587,11 +587,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         if self.address:
         if self.address:
 
 
             # Enforce unique IP space (if applicable)
             # Enforce unique IP space (if applicable)
-            if self.role not in IPADDRESS_ROLES_NONUNIQUE and (
+            if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
                 self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
                 self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
             ) or (
             ) or (
                 self.vrf and self.vrf.enforce_unique
                 self.vrf and self.vrf.enforce_unique
-            ):
+            )):
                 duplicate_ips = self.get_duplicates()
                 duplicate_ips = self.get_duplicates()
                 if duplicate_ips:
                 if duplicate_ips:
                     raise ValidationError({
                     raise ValidationError({

+ 22 - 1
netbox/virtualization/filters.py

@@ -1,9 +1,10 @@
 import django_filters
 import django_filters
+from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 from django.db.models import Q
 from netaddr import EUI
 from netaddr import EUI
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
-from dcim.models import DeviceRole, Interface, Platform, Site
+from dcim.models import DeviceRole, Interface, Platform, Region, Site
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import NumericInFilter
 from utilities.filters import NumericInFilter
@@ -120,6 +121,16 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         label='Cluster (ID)',
         label='Cluster (ID)',
     )
     )
+    region_id = django_filters.NumberFilter(
+        method='filter_region',
+        field_name='pk',
+        label='Region (ID)',
+    )
+    region = django_filters.CharFilter(
+        method='filter_region',
+        field_name='slug',
+        label='Region (slug)',
+    )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         field_name='cluster__site',
         field_name='cluster__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -177,6 +188,16 @@ class VirtualMachineFilter(CustomFieldFilterSet):
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
 
 
+    def filter_region(self, queryset, name, value):
+        try:
+            region = Region.objects.get(**{name: value})
+        except ObjectDoesNotExist:
+            return queryset.none()
+        return queryset.filter(
+            Q(cluster__site__region=region) |
+            Q(cluster__site__region__in=region.get_descendants())
+        )
+
 
 
 class InterfaceFilter(django_filters.FilterSet):
 class InterfaceFilter(django_filters.FilterSet):
     virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
     virtual_machine_id = django_filters.ModelMultipleChoiceFilter(

+ 7 - 2
netbox/virtualization/forms.py

@@ -14,8 +14,8 @@ from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
     ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
-    ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea,
-    add_blank_choice
+    ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
+    JSONField, SlugField, SmallTextarea, add_blank_choice,
 )
 )
 from .constants import VM_STATUS_CHOICES
 from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -384,6 +384,11 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
         queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
         label='Cluster'
         label='Cluster'
     )
     )
+    region = FilterTreeNodeMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+    )
     site = FilterChoiceField(
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')),
         queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')),
         to_field_name='slug',
         to_field_name='slug',