1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge release v2.4.7 into develop-2.5

This commit is contained in:
Jeremy Stretch
2018-11-06 11:05:04 -05:00
9 changed files with 136 additions and 27 deletions

View File

@ -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
--- ---

View File

@ -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)

View File

@ -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, 'circuit': self.circuit3.pk,
'term_side': TERM_SIDE_Z, 'term_side': TERM_SIDE_A,
'site': self.site2.pk, '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)

View File

@ -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_device`: The name of the peer device
* `peer-interface`: The name of the peer interface * `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)

View File

@ -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)',

View File

@ -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',

View File

@ -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({

View File

@ -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(

View File

@ -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, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
add_blank_choice 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',