mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-20 10:16:42 -06:00
commit
cb83eb204b
18
CHANGELOG.md
18
CHANGELOG.md
@ -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)
|
v2.4.6 (2018-10-05)
|
||||||
|
|
||||||
## Enhancements
|
## Enhancements
|
||||||
|
@ -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.
|
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
|
## Requests
|
||||||
|
|
||||||
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):
|
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):
|
||||||
|
@ -58,7 +58,7 @@ A device is said to be full depth if its installation on one rack face prevents
|
|||||||
|
|
||||||
## Device Roles
|
## 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.
|
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.
|
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -71,7 +71,7 @@ Checking connectivity... done.
|
|||||||
|
|
||||||
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
|
`# 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.)
|
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
|
!!! 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`.
|
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:
|
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
|
# 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.
|
[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.
|
||||||
|
|
||||||
|
@ -4,8 +4,8 @@ from rest_framework import serializers
|
|||||||
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
|
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
|
||||||
|
|
||||||
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
||||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
|
||||||
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
from dcim.api.serializers import NestedInterfaceSerializer, NestedSiteSerializer
|
||||||
from extras.api.customfields import CustomFieldModelSerializer
|
from extras.api.customfields import CustomFieldModelSerializer
|
||||||
from tenancy.api.serializers import NestedTenantSerializer
|
from tenancy.api.serializers import NestedTenantSerializer
|
||||||
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
||||||
@ -87,7 +87,7 @@ class NestedCircuitSerializer(WritableNestedSerializer):
|
|||||||
class CircuitTerminationSerializer(ValidatedModelSerializer):
|
class CircuitTerminationSerializer(ValidatedModelSerializer):
|
||||||
circuit = NestedCircuitSerializer()
|
circuit = NestedCircuitSerializer()
|
||||||
site = NestedSiteSerializer()
|
site = NestedSiteSerializer()
|
||||||
interface = InterfaceSerializer(required=False, allow_null=True)
|
interface = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
|
@ -5,7 +5,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
|
||||||
@ -330,21 +330,44 @@ 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')
|
||||||
|
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')
|
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, interface=self.interface1, 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, interface=self.interface2, 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, 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):
|
def test_get_circuittermination(self):
|
||||||
@ -359,14 +382,15 @@ 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,
|
||||||
|
'interface': self.interface5.pk,
|
||||||
'port_speed': 1000000,
|
'port_speed': 1000000,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -374,31 +398,37 @@ 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'])
|
||||||
self.assertEqual(circuittermination4.site_id, data['site'])
|
self.assertEqual(circuittermination4.site_id, data['site'])
|
||||||
|
self.assertEqual(circuittermination4.interface_id, data['interface'])
|
||||||
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
|
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
|
||||||
|
|
||||||
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, interface=self.interface5, 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,
|
||||||
|
'interface': self.interface6.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.interface_id, data['interface'])
|
||||||
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
|
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
|
||||||
|
|
||||||
def test_delete_circuittermination(self):
|
def test_delete_circuittermination(self):
|
||||||
@ -407,4 +437,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)
|
||||||
|
@ -472,10 +472,14 @@ class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
|||||||
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
|
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||||
device = NestedDeviceSerializer(read_only=True)
|
device = NestedDeviceSerializer(read_only=True)
|
||||||
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConsoleServerPort
|
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):
|
class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||||
device = NestedDeviceSerializer(read_only=True)
|
device = NestedDeviceSerializer(read_only=True)
|
||||||
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConsolePort
|
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):
|
class NestedPowerOutletSerializer(WritableNestedSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||||
device = NestedDeviceSerializer(read_only=True)
|
device = NestedDeviceSerializer(read_only=True)
|
||||||
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PowerOutlet
|
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):
|
class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||||
device = NestedDeviceSerializer(read_only=True)
|
device = NestedDeviceSerializer(read_only=True)
|
||||||
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = PowerPort
|
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
|
# 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)
|
device = NestedDeviceSerializer(read_only=True)
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||||
|
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Interface
|
model = Interface
|
||||||
fields = ['id', 'url', 'device', 'name']
|
fields = ['id', 'url', 'device', 'name', 'is_connected']
|
||||||
|
|
||||||
|
|
||||||
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
|
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
|
||||||
@ -587,7 +619,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer):
|
|||||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||||
|
|
||||||
|
|
||||||
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
|
class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer):
|
||||||
device = NestedDeviceSerializer()
|
device = NestedDeviceSerializer()
|
||||||
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
|
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
|
||||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||||
@ -631,19 +663,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
|
|||||||
|
|
||||||
return super(InterfaceSerializer, self).validate(data)
|
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):
|
def get_interface_connection(self, obj):
|
||||||
if obj.connection:
|
if obj.connection:
|
||||||
context = {
|
context = {
|
||||||
|
@ -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
|
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):
|
||||||
@ -429,9 +429,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)
|
||||||
|
@ -2,6 +2,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
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
|
||||||
@ -456,6 +457,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',
|
||||||
|
name='pk',
|
||||||
|
label='Region (ID)',
|
||||||
|
)
|
||||||
|
region = django_filters.CharFilter(
|
||||||
|
method='filter_region',
|
||||||
|
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)',
|
||||||
@ -538,6 +549,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:
|
||||||
@ -635,6 +656,14 @@ class InterfaceFilter(django_filters.FilterSet):
|
|||||||
tag = django_filters.CharFilter(
|
tag = django_filters.CharFilter(
|
||||||
name='tags__slug',
|
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
|
||||||
@ -649,6 +678,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 {
|
||||||
@ -681,6 +728,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)',
|
||||||
|
@ -1108,6 +1108,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',
|
||||||
@ -1328,7 +1333,7 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF
|
|||||||
label='Port',
|
label='Port',
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
|
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',
|
label='Port',
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url='/api/dcim/console-ports/?device_id={{device}}',
|
api_url='/api/dcim/console-ports/?device_id={{device}}',
|
||||||
disabled_indicator='cs_port'
|
disabled_indicator='is_connected'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
connection_status = forms.BooleanField(
|
connection_status = forms.BooleanField(
|
||||||
@ -1597,7 +1602,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
|
|||||||
label='Outlet',
|
label='Outlet',
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
|
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',
|
label='Port',
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url='/api/dcim/power-ports/?device_id={{device}}',
|
api_url='/api/dcim/power-ports/?device_id={{device}}',
|
||||||
disabled_indicator='power_outlet'
|
disabled_indicator='is_connected'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
connection_status = forms.BooleanField(
|
connection_status = forms.BooleanField(
|
||||||
@ -2201,6 +2206,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',
|
||||||
|
@ -2035,11 +2035,14 @@ class InterfaceConnection(models.Model):
|
|||||||
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
|
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
try:
|
|
||||||
|
# An interface cannot be connected to itself
|
||||||
if self.interface_a == self.interface_b:
|
if self.interface_a == self.interface_b:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'interface_b': "Cannot connect an interface to itself."
|
'interface_b': "Cannot connect an interface to itself."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Only connectable interface types are permitted
|
||||||
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
|
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'interface_a': '{} is not a connectable interface type.'.format(
|
'interface_a': '{} is not a connectable interface type.'.format(
|
||||||
@ -2052,8 +2055,24 @@ class InterfaceConnection(models.Model):
|
|||||||
self.interface_b.get_form_factor_display()
|
self.interface_b.get_form_factor_display()
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
except ObjectDoesNotExist:
|
|
||||||
pass
|
# 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):
|
def to_csv(self):
|
||||||
return (
|
return (
|
||||||
|
@ -1955,7 +1955,7 @@ class ConsolePortTest(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(response.data['results'][0]),
|
sorted(response.data['results'][0]),
|
||||||
['device', 'id', 'name', 'url']
|
['device', 'id', 'is_connected', 'name', 'url']
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_consoleport(self):
|
def test_create_consoleport(self):
|
||||||
@ -2070,7 +2070,7 @@ class ConsoleServerPortTest(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(response.data['results'][0]),
|
sorted(response.data['results'][0]),
|
||||||
['device', 'id', 'name', 'url']
|
['device', 'id', 'is_connected', 'name', 'url']
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_consoleserverport(self):
|
def test_create_consoleserverport(self):
|
||||||
@ -2181,7 +2181,7 @@ class PowerPortTest(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(response.data['results'][0]),
|
sorted(response.data['results'][0]),
|
||||||
['device', 'id', 'name', 'url']
|
['device', 'id', 'is_connected', 'name', 'url']
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_powerport(self):
|
def test_create_powerport(self):
|
||||||
@ -2296,7 +2296,7 @@ class PowerOutletTest(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(response.data['results'][0]),
|
sorted(response.data['results'][0]),
|
||||||
['device', 'id', 'name', 'url']
|
['device', 'id', 'is_connected', 'name', 'url']
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_poweroutlet(self):
|
def test_create_poweroutlet(self):
|
||||||
@ -2432,7 +2432,7 @@ class InterfaceTest(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted(response.data['results'][0]),
|
sorted(response.data['results'][0]),
|
||||||
['device', 'id', 'name', 'url']
|
['device', 'id', 'is_connected', 'name', 'url']
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_interface(self):
|
def test_create_interface(self):
|
||||||
@ -3053,7 +3053,7 @@ class ConnectedDeviceTest(APITestCase):
|
|||||||
def test_get_connected_device(self):
|
def test_get_connected_device(self):
|
||||||
|
|
||||||
url = reverse('dcim-api:connected-device-list')
|
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.assertHttpStatus(response, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['name'], self.device1.name)
|
self.assertEqual(response.data['name'], self.device1.name)
|
||||||
|
@ -596,11 +596,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({
|
||||||
|
@ -23,6 +23,7 @@ admin_site.register(User, UserAdmin)
|
|||||||
admin_site.register(Tag, TagAdmin)
|
admin_site.register(Tag, TagAdmin)
|
||||||
|
|
||||||
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
|
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
|
||||||
|
if settings.WEBHOOKS_ENABLED:
|
||||||
try:
|
try:
|
||||||
import django_rq
|
import django_rq
|
||||||
admin_site.index_template = 'django_rq/index.html'
|
admin_site.index_template = 'django_rq/index.html'
|
||||||
|
@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
|
|||||||
DeprecationWarning
|
DeprecationWarning
|
||||||
)
|
)
|
||||||
|
|
||||||
VERSION = '2.4.6'
|
VERSION = '2.4.7'
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
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
|
||||||
@ -116,6 +117,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',
|
||||||
|
name='pk',
|
||||||
|
label='Region (ID)',
|
||||||
|
)
|
||||||
|
region = django_filters.CharFilter(
|
||||||
|
method='filter_region',
|
||||||
|
name='slug',
|
||||||
|
label='Region (slug)',
|
||||||
|
)
|
||||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
name='cluster__site',
|
name='cluster__site',
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
@ -173,6 +184,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(
|
||||||
|
@ -16,8 +16,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
|
||||||
@ -386,6 +386,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',
|
||||||
|
@ -14,10 +14,10 @@ Markdown==2.6.11
|
|||||||
natsort==5.3.3
|
natsort==5.3.3
|
||||||
ncclient==0.6.0
|
ncclient==0.6.0
|
||||||
netaddr==0.7.19
|
netaddr==0.7.19
|
||||||
paramiko==2.4.1
|
paramiko==2.4.2
|
||||||
Pillow==5.2.0
|
Pillow==5.2.0
|
||||||
psycopg2-binary==2.7.5
|
psycopg2-binary==2.7.5
|
||||||
py-gfm==0.1.3
|
py-gfm==0.1.3
|
||||||
pycryptodome==3.6.4
|
pycryptodome==3.6.6
|
||||||
xmltodict==0.11.0
|
xmltodict==0.11.0
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user