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

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
 
+* [#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
 * [#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)
 * 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)
 * 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.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.models import Graph
 from utilities.testing import APITestCase
@@ -328,21 +328,24 @@ class CircuitTerminationTest(APITestCase):
 
         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')
         circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
         self.circuit1 = Circuit.objects.create(cid='TEST0001', 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.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(
             circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
         )
         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(
-            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):
@@ -357,14 +360,14 @@ class CircuitTerminationTest(APITestCase):
         url = reverse('circuits-api:circuittermination-list')
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['count'], 3)
+        self.assertEqual(response.data['count'], 4)
 
     def test_create_circuittermination(self):
 
         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,
         }
 
@@ -372,7 +375,7 @@ class CircuitTerminationTest(APITestCase):
         response = self.client.post(url, data, format='json', **self.header)
 
         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'])
         self.assertEqual(circuittermination4.circuit_id, data['circuit'])
         self.assertEqual(circuittermination4.term_side, data['term_side'])
@@ -381,20 +384,23 @@ class CircuitTerminationTest(APITestCase):
 
     def test_update_circuittermination(self):
 
+        circuittermination5 = CircuitTermination.objects.create(
+            circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
+        )
+
         data = {
-            'circuit': self.circuit1.pk,
+            'circuit': self.circuit3.pk,
             'term_side': TERM_SIDE_Z,
             'site': self.site2.pk,
             '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)
 
         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'])
-        self.assertEqual(circuittermination1.circuit_id, data['circuit'])
         self.assertEqual(circuittermination1.term_side, data['term_side'])
         self.assertEqual(circuittermination1.site_id, data['site'])
         self.assertEqual(circuittermination1.port_speed, data['port_speed'])
@@ -405,4 +411,4 @@ class CircuitTerminationTest(APITestCase):
         response = self.client.delete(url, **self.header)
 
         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
     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]
-    _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)
-    _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)
 
     def get_view_name(self):
@@ -541,9 +541,15 @@ class ConnectedDeviceViewSet(ViewSet):
     def list(self, request):
 
         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)
+        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:
-            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
         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
 from django.contrib.auth.models import User
+from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 from netaddr import EUI
 from netaddr.core import AddrFormatError
@@ -539,6 +540,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
     )
     name = 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(
         queryset=Site.objects.all(),
         label='Site (ID)',
@@ -633,6 +644,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
             Q(comments__icontains=value)
         ).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):
         value = value.strip()
         if not value:
@@ -757,6 +778,14 @@ class InterfaceFilter(django_filters.FilterSet):
     tag = django_filters.CharFilter(
         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:
         model = Interface
@@ -770,6 +799,24 @@ class InterfaceFilter(django_filters.FilterSet):
         except Device.DoesNotExist:
             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):
         value = value.strip().lower()
         return {
@@ -816,6 +863,15 @@ class InventoryItemFilter(DeviceComponentFilterSet):
         method='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(
         queryset=InventoryItem.objects.all(),
         label='Parent inventory item (ID)',

+ 6 - 0
netbox/dcim/forms.py

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

+ 2 - 2
netbox/ipam/models.py

@@ -587,11 +587,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         if self.address:
 
             # 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
             ) or (
                 self.vrf and self.vrf.enforce_unique
-            ):
+            )):
                 duplicate_ips = self.get_duplicates()
                 if duplicate_ips:
                     raise ValidationError({

+ 22 - 1
netbox/virtualization/filters.py

@@ -1,9 +1,10 @@
 import django_filters
+from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import Q
 from netaddr import EUI
 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 tenancy.models import Tenant
 from utilities.filters import NumericInFilter
@@ -120,6 +121,16 @@ class VirtualMachineFilter(CustomFieldFilterSet):
         queryset=Cluster.objects.all(),
         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(
         field_name='cluster__site',
         queryset=Site.objects.all(),
@@ -177,6 +188,16 @@ class VirtualMachineFilter(CustomFieldFilterSet):
             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):
     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 (
     AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     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 .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -384,6 +384,11 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
         label='Cluster'
     )
+    region = FilterTreeNodeMultipleChoiceField(
+        queryset=Region.objects.all(),
+        to_field_name='slug',
+        required=False,
+    )
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')),
         to_field_name='slug',