diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec56bbb5..bb7a0f269 100644 --- a/CHANGELOG.md +++ b/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 --- diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 27bec8e8e..05b613da2 100644 --- a/docs/core-functionality/ipam.md +++ b/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) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index c8739c151..3dd9c7b84 100644 --- a/netbox/circuits/tests/test_api.py +++ b/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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index b615a83c0..435810f14 100644 --- a/netbox/dcim/api/views.py +++ b/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) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 7ad91979d..bed641ff7 100644 --- a/netbox/dcim/filters.py +++ b/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)', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e5c97c4a2..04e2120e6 100644 --- a/netbox/dcim/forms.py +++ b/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', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 4ef4cb3e4..8da751f50 100644 --- a/netbox/ipam/models.py +++ b/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({ diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index cc61569f5..3854fb132 100644 --- a/netbox/virtualization/filters.py +++ b/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( diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 2323f53b2..7a76f62c8 100644 --- a/netbox/virtualization/forms.py +++ b/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',