Merge pull request #2552 from digitalocean/develop

Release v2.4.7
This commit is contained in:
Jeremy Stretch 2018-11-06 10:55:29 -05:00 committed by GitHub
commit cb83eb204b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 281 additions and 92 deletions

View File

@ -1,3 +1,21 @@
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
---
v2.4.6 (2018-10-05)
## Enhancements

View File

@ -4,6 +4,14 @@ A webhook defines an HTTP request that is sent to an external application when c
An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
## Installation
If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below.
* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages)
* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration)
* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation)
## Requests
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):

View File

@ -58,7 +58,7 @@ A device is said to be full depth if its installation on one rack face prevents
## Device Roles
Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches.
Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches.
---
@ -101,7 +101,7 @@ Device bays represent the ability of a device to house child devices. For exampl
A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration.
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.

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

View File

@ -71,7 +71,7 @@ Checking connectivity... done.
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
## Install Python Packages
# Install Python Packages
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio
!!! note
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`.
### NAPALM Automation (Optional)
## NAPALM Automation (Optional)
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
@ -90,7 +90,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati
# pip3 install napalm
```
### Webhooks (Optional)
## Webhooks (Optional)
[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one.

View File

@ -4,8 +4,8 @@ from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.api.serializers import NestedInterfaceSerializer, NestedSiteSerializer
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
@ -87,7 +87,7 @@ class NestedCircuitSerializer(WritableNestedSerializer):
class CircuitTerminationSerializer(ValidatedModelSerializer):
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
interface = InterfaceSerializer(required=False, allow_null=True)
interface = NestedInterfaceSerializer(required=False, allow_null=True)
class Meta:
model = CircuitTermination

View File

@ -5,7 +5,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
@ -330,21 +330,44 @@ 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')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site1
)
device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site2
)
self.interface1 = Interface.objects.create(device=device1, name='Test Interface 1')
self.interface2 = Interface.objects.create(device=device2, name='Test Interface 2')
self.interface3 = Interface.objects.create(device=device1, name='Test Interface 3')
self.interface4 = Interface.objects.create(device=device2, name='Test Interface 4')
self.interface5 = Interface.objects.create(device=device1, name='Test Interface 5')
self.interface6 = Interface.objects.create(device=device2, name='Test Interface 6')
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
circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface1, 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, interface=self.interface2, 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, interface=self.interface3, port_speed=1000000
)
self.circuittermination4 = CircuitTermination.objects.create(
circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface4, port_speed=1000000
)
def test_get_circuittermination(self):
@ -359,14 +382,15 @@ 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,
'interface': self.interface5.pk,
'port_speed': 1000000,
}
@ -374,31 +398,37 @@ 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'])
self.assertEqual(circuittermination4.site_id, data['site'])
self.assertEqual(circuittermination4.interface_id, data['interface'])
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
def test_update_circuittermination(self):
circuittermination5 = CircuitTermination.objects.create(
circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface5, port_speed=1000000
)
data = {
'circuit': self.circuit1.pk,
'circuit': self.circuit3.pk,
'term_side': TERM_SIDE_Z,
'site': self.site2.pk,
'interface': self.interface6.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.interface_id, data['interface'])
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
def test_delete_circuittermination(self):
@ -407,4 +437,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)

View File

@ -472,10 +472,14 @@ class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ConsoleServerPort
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return hasattr(obj, 'connected_console') and obj.connected_console is not None
#
@ -495,10 +499,14 @@ class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ConsolePort
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return obj.cs_port is not None
#
@ -518,10 +526,14 @@ class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = PowerOutlet
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return hasattr(obj, 'connected_port') and obj.connected_port is not None
#
@ -541,23 +553,43 @@ class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = PowerPort
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return obj.power_outlet is not None
#
# Interfaces
#
class NestedInterfaceSerializer(WritableNestedSerializer):
class IsConnectedMixin(object):
"""
Provide a method for setting is_connected on Interface serializers.
"""
def get_is_connected(self, obj):
"""
Return True if the interface has a connected interface or circuit.
"""
if obj.connection:
return True
if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None:
return True
return False
class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Interface
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
@ -587,7 +619,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
@ -631,19 +663,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
return super(InterfaceSerializer, self).validate(data)
def get_is_connected(self, obj):
"""
Return True if the interface has a connected interface or circuit termination.
"""
if obj.connection:
return True
try:
circuit_termination = obj.circuit_termination
return True
except CircuitTermination.DoesNotExist:
pass
return False
def get_interface_connection(self, obj):
if obj.connection:
context = {

View File

@ -412,13 +412,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):
@ -429,9 +429,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)

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
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
@ -456,6 +457,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
name = NullableCharFieldFilter()
asset_tag = NullableCharFieldFilter()
region_id = django_filters.NumberFilter(
method='filter_region',
name='pk',
label='Region (ID)',
)
region = django_filters.CharFilter(
method='filter_region',
name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@ -538,6 +549,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:
@ -635,6 +656,14 @@ class InterfaceFilter(django_filters.FilterSet):
tag = django_filters.CharFilter(
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
@ -649,6 +678,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 {
@ -681,6 +728,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)',

View File

@ -1108,6 +1108,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',
@ -1328,7 +1333,7 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF
label='Port',
widget=APISelect(
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
disabled_indicator='connected_console',
disabled_indicator='is_connected',
)
)
@ -1419,7 +1424,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
label='Port',
widget=APISelect(
api_url='/api/dcim/console-ports/?device_id={{device}}',
disabled_indicator='cs_port'
disabled_indicator='is_connected'
)
)
connection_status = forms.BooleanField(
@ -1597,7 +1602,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
label='Outlet',
widget=APISelect(
api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
disabled_indicator='connected_port'
disabled_indicator='is_connected'
)
)
@ -1688,7 +1693,7 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
label='Port',
widget=APISelect(
api_url='/api/dcim/power-ports/?device_id={{device}}',
disabled_indicator='power_outlet'
disabled_indicator='is_connected'
)
)
connection_status = forms.BooleanField(
@ -2201,6 +2206,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',

View File

@ -2035,25 +2035,44 @@ class InterfaceConnection(models.Model):
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
def clean(self):
try:
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
except ObjectDoesNotExist:
pass
# An interface cannot be connected to itself
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
# Only connectable interface types are permitted
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
# Prevent the A side of one connection from being the B side of another
interface_a_connections = InterfaceConnection.objects.filter(
Q(interface_a=self.interface_a) |
Q(interface_b=self.interface_a)
).exclude(pk=self.pk)
if interface_a_connections.exists():
raise ValidationError({
'interface_a': "This interface is already connected."
})
interface_b_connections = InterfaceConnection.objects.filter(
Q(interface_a=self.interface_b) |
Q(interface_b=self.interface_b)
).exclude(pk=self.pk)
if interface_b_connections.exists():
raise ValidationError({
'interface_b': "This interface is already connected."
})
def to_csv(self):
return (

View File

@ -1955,7 +1955,7 @@ class ConsolePortTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'name', 'url']
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_consoleport(self):
@ -2070,7 +2070,7 @@ class ConsoleServerPortTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'name', 'url']
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_consoleserverport(self):
@ -2181,7 +2181,7 @@ class PowerPortTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'name', 'url']
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_powerport(self):
@ -2296,7 +2296,7 @@ class PowerOutletTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'name', 'url']
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_poweroutlet(self):
@ -2432,7 +2432,7 @@ class InterfaceTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'name', 'url']
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_interface(self):
@ -3053,7 +3053,7 @@ class ConnectedDeviceTest(APITestCase):
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header)
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name)

View File

@ -596,11 +596,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({

View File

@ -23,8 +23,9 @@ admin_site.register(User, UserAdmin)
admin_site.register(Tag, TagAdmin)
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass
if settings.WEBHOOKS_ENABLED:
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.4.6'
VERSION = '2.4.7'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -1,11 +1,12 @@
from __future__ import unicode_literals
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
@ -116,6 +117,16 @@ class VirtualMachineFilter(CustomFieldFilterSet):
queryset=Cluster.objects.all(),
label='Cluster (ID)',
)
region_id = django_filters.NumberFilter(
method='filter_region',
name='pk',
label='Region (ID)',
)
region = django_filters.CharFilter(
method='filter_region',
name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='cluster__site',
queryset=Site.objects.all(),
@ -173,6 +184,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(

View File

@ -16,8 +16,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
@ -386,6 +386,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',

View File

@ -14,10 +14,10 @@ Markdown==2.6.11
natsort==5.3.3
ncclient==0.6.0
netaddr==0.7.19
paramiko==2.4.1
paramiko==2.4.2
Pillow==5.2.0
psycopg2-binary==2.7.5
py-gfm==0.1.3
pycryptodome==3.6.4
pycryptodome==3.6.6
xmltodict==0.11.0