Merge branch 'develop' into 2921-tags-select2

This commit is contained in:
hSaria 2020-01-24 20:56:36 +00:00 committed by GitHub
commit ca035a72bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1209 additions and 182 deletions

View File

@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field.
Stored a numeric integer. Options include: Stored a numeric integer. Options include:
* `min_value:` - Minimum value * `min_value` - Minimum value
* `max_value` - Maximum value * `max_value` - Maximum value
### BooleanVar ### BooleanVar
@ -158,9 +158,20 @@ A NetBox object. The list of available objects is defined by the queryset parame
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use. An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
### IPAddressVar
An IPv4 or IPv6 address, without a mask. Returns a `netaddr.IPAddress` object.
### IPAddressWithMaskVar
An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which includes the mask.
### IPNetworkVar ### IPNetworkVar
An IPv4 or IPv6 network with a mask. An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask:
* `min_prefix_length` - Minimum length of the mask (default: none)
* `max_prefix_length` - Maximum length of the mask (default: none)
### Default Options ### Default Options

View File

@ -3,6 +3,18 @@
## Enhancements ## Enhancements
* [#2921](https://github.com/netbox-community/netbox/issues/2921) - Replace tags filter with Select2 widget * [#2921](https://github.com/netbox-community/netbox/issues/2921) - Replace tags filter with Select2 widget
* [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable
* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts
* [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps
## Bug Fixes
* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Automatically select parent manufacturer when specifying initial device type during device creation
* [#3982](https://github.com/netbox-community/netbox/issues/3982) - Restore tooltip for reservations on rack elevations
* [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices
* [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks
* [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank
* [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings
--- ---

View File

@ -4,17 +4,30 @@ from .choices import InterfaceTypeChoices
# #
# Rack elevation rendering # Racks
# #
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
# #
# Interface type groups # RearPorts
# #
REARPORT_POSITIONS_MIN = 1
REARPORT_POSITIONS_MAX = 64
#
# Interfaces
#
INTERFACE_MTU_MIN = 1
INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer
VIRTUAL_IFACE_TYPES = [ VIRTUAL_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG, InterfaceTypeChoices.TYPE_LAG,
@ -31,6 +44,17 @@ WIRELESS_IFACE_TYPES = [
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
# PowerFeeds
#
POWERFEED_VOLTAGE_DEFAULT = 120
POWERFEED_AMPERAGE_DEFAULT = 20
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
# #
# Cabling and connections # Cabling and connections
# #

View File

@ -66,6 +66,14 @@
"slug": "servertech" "slug": "servertech"
} }
}, },
{
"model": "dcim.manufacturer",
"pk": 4,
"fields": {
"name": "Dell",
"slug": "dell"
}
},
{ {
"model": "dcim.devicetype", "model": "dcim.devicetype",
"pk": 1, "pk": 1,
@ -144,6 +152,19 @@
"is_full_depth": false "is_full_depth": false
} }
}, },
{
"model": "dcim.devicetype",
"pk": 7,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"manufacturer": 4,
"model": "PowerEdge R640",
"slug": "poweredge-r640",
"u_height": 1,
"is_full_depth": false
}
},
{ {
"model": "dcim.consoleporttemplate", "model": "dcim.consoleporttemplate",
"pk": 1, "pk": 1,
@ -1880,6 +1901,15 @@
"color": "yellow" "color": "yellow"
} }
}, },
{
"model": "dcim.devicerole",
"pk": 7,
"fields": {
"name": "Server",
"slug": "server",
"color": "grey"
}
},
{ {
"model": "dcim.platform", "model": "dcim.platform",
"pk": 1, "pk": 1,
@ -2127,6 +2157,34 @@
"comments": "" "comments": ""
} }
}, },
{
"model": "dcim.device",
"pk": 13,
"fields": {
"local_context_data": null,
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"device_type": 7,
"device_role": 6,
"tenant": null,
"platform": null,
"name": "test1-server1",
"serial": "",
"asset_tag": null,
"site": 1,
"rack": 2,
"position": null,
"face": "",
"status": true,
"primary_ip4": null,
"primary_ip6": null,
"cluster": 4,
"virtual_chassis": null,
"vc_position": null,
"vc_priority": null,
"comments": ""
}
},
{ {
"model": "dcim.consoleport", "model": "dcim.consoleport",
"pk": 1, "pk": 1,

View File

@ -5,7 +5,6 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from mptt.forms import TreeNodeChoiceField from mptt.forms import TreeNodeChoiceField
from netaddr import EUI from netaddr import EUI
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@ -1305,8 +1304,8 @@ class RearPortTemplateCreateForm(ComponentForm):
widget=StaticSelect2(), widget=StaticSelect2(),
) )
positions = forms.IntegerField( positions = forms.IntegerField(
min_value=1, min_value=REARPORT_POSITIONS_MIN,
max_value=64, max_value=REARPORT_POSITIONS_MAX,
initial=1, initial=1,
help_text='The number of front ports which may be mapped to each rear port' help_text='The number of front ports which may be mapped to each rear port'
) )
@ -1646,6 +1645,16 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
if instance and instance.cluster is not None: if instance and instance.cluster is not None:
kwargs['initial']['cluster_group'] = instance.cluster.group kwargs['initial']['cluster_group'] = instance.cluster.group
if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']:
device_type_id = kwargs['initial']['device_type']
manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first()
kwargs['initial']['manufacturer'] = manufacturer_id
if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']:
cluster_id = kwargs['initial']['cluster']
cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first()
kwargs['initial']['cluster_group'] = cluster_group_id
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.pk: if self.instance.pk:
@ -2128,8 +2137,8 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm):
) )
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=INTERFACE_MTU_MIN,
max_value=32767, max_value=INTERFACE_MTU_MAX,
label='MTU' label='MTU'
) )
mgmt_only = forms.BooleanField( mgmt_only = forms.BooleanField(
@ -2615,8 +2624,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
) )
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=INTERFACE_MTU_MIN,
max_value=32767, max_value=INTERFACE_MTU_MAX,
label='MTU' label='MTU'
) )
mac_address = forms.CharField( mac_address = forms.CharField(
@ -2770,8 +2779,8 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
) )
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=INTERFACE_MTU_MIN,
max_value=32767, max_value=INTERFACE_MTU_MAX,
label='MTU' label='MTU'
) )
mgmt_only = forms.NullBooleanField( mgmt_only = forms.NullBooleanField(
@ -3050,8 +3059,8 @@ class RearPortCreateForm(ComponentForm):
widget=StaticSelect2(), widget=StaticSelect2(),
) )
positions = forms.IntegerField( positions = forms.IntegerField(
min_value=1, min_value=REARPORT_POSITIONS_MIN,
max_value=64, max_value=REARPORT_POSITIONS_MAX,
initial=1, initial=1,
help_text='The number of front ports which may be mapped to each rear port' help_text='The number of front ports which may be mapped to each rear port'
) )
@ -3173,6 +3182,11 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo
'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
'label', 'color', 'length', 'length_unit', 'label', 'color', 'length', 'length_unit',
] ]
widgets = {
'status': StaticSelect2,
'type': StaticSelect2,
'length_unit': StaticSelect2,
}
class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): class ConnectCableToConsolePortForm(ConnectCableToDeviceForm):
@ -3368,6 +3382,11 @@ class CableForm(BootstrapMixin, forms.ModelForm):
fields = [ fields = [
'type', 'status', 'label', 'color', 'length', 'length_unit', 'type', 'status', 'label', 'color', 'length', 'length_unit',
] ]
widgets = {
'status': StaticSelect2,
'type': StaticSelect2,
'length_unit': StaticSelect2,
}
class CableCSVForm(forms.ModelForm): class CableCSVForm(forms.ModelForm):
@ -3518,7 +3537,7 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
required=False required=False
) )
color = forms.CharField( color = forms.CharField(
max_length=6, max_length=6, # RGB color code
required=False, required=False,
widget=ColorSelect() widget=ColorSelect()
) )
@ -3597,7 +3616,7 @@ class CableFilterForm(BootstrapMixin, forms.Form):
widget=StaticSelect2() widget=StaticSelect2()
) )
color = forms.CharField( color = forms.CharField(
max_length=6, max_length=6, # RGB color code
required=False, required=False,
widget=ColorSelect() widget=ColorSelect()
) )

View File

@ -414,7 +414,7 @@ class RackElevationHelperMixin:
drawing.add(drawing.text(str(device), insert=text)) drawing.add(drawing.text(str(device), insert=text))
@staticmethod @staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_): def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link = drawing.add( link = drawing.add(
drawing.a( drawing.a(
href='{}?{}'.format( href='{}?{}'.format(
@ -424,6 +424,10 @@ class RackElevationHelperMixin:
target='_top' target='_top'
) )
) )
if reservation:
link.set_desc('{}{} · {}'.format(
reservation.description, reservation.user, reservation.created
))
link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device')) link.add(drawing.text("add device", insert=text, class_='add-device'))
@ -453,12 +457,13 @@ class RackElevationHelperMixin:
else: else:
# Draw shallow devices, reservations, or empty units # Draw shallow devices, reservations, or empty units
class_ = 'slot' class_ = 'slot'
reservation = reserved_units.get(unit["id"])
if device: if device:
class_ += ' occupied' class_ += ' occupied'
if unit["id"] in reserved_units: if reservation:
class_ += ' reserved' class_ += ' reserved'
self._draw_empty( self._draw_empty(
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_ drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
) )
unit_cursor += height unit_cursor += height
@ -483,7 +488,12 @@ class RackElevationHelperMixin:
return elevation return elevation
def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20): def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
):
""" """
Return an SVG of the rack elevation Return an SVG of the rack elevation
@ -493,7 +503,7 @@ class RackElevationHelperMixin:
height of the elevation height of the elevation
""" """
elevation = self.merge_elevations(face) elevation = self.merge_elevations(face)
reserved_units = self.get_reserved_units().keys() reserved_units = self.get_reserved_units()
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height)
@ -569,7 +579,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
help_text='Rail-to-rail width' help_text='Rail-to-rail width'
) )
u_height = models.PositiveSmallIntegerField( u_height = models.PositiveSmallIntegerField(
default=42, default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)', verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)] validators=[MinValueValidator(1), MaxValueValidator(100)]
) )
@ -1445,10 +1455,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention. # of the uniqueness constraint without manual intervention.
if self.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True): if self.name and self.tenant is None:
raise ValidationError({ if Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True):
'name': 'A device with this name already exists.' raise ValidationError({
}) 'name': 'A device with this name already exists.'
})
super().validate_unique(exclude) super().validate_unique(exclude)
@ -1858,15 +1869,15 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
) )
voltage = models.PositiveSmallIntegerField( voltage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
default=120 default=POWERFEED_VOLTAGE_DEFAULT
) )
amperage = models.PositiveSmallIntegerField( amperage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)], validators=[MinValueValidator(1)],
default=20 default=POWERFEED_AMPERAGE_DEFAULT
) )
max_utilization = models.PositiveSmallIntegerField( max_utilization = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)], validators=[MinValueValidator(1), MaxValueValidator(100)],
default=80, default=POWERFEED_MAX_UTILIZATION_DEFAULT,
help_text="Maximum permissible draw (percentage)" help_text="Maximum permissible draw (percentage)"
) )
available_power = models.PositiveIntegerField( available_power = models.PositiveIntegerField(

View File

@ -4,6 +4,7 @@ from netaddr import IPNetwork
from rest_framework import status from rest_framework import status
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.api import serializers
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import ( from dcim.models import (
@ -595,6 +596,21 @@ class RackTest(APITestCase):
self.assertEqual(response.data['count'], 42) self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation(self):
url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation_svg(self):
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
def test_list_racks(self): def test_list_racks(self):
url = reverse('dcim-api:rack-list') url = reverse('dcim-api:rack-list')
@ -1900,6 +1916,31 @@ class DeviceTest(APITestCase):
self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk) self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk)
self.assertEqual(response.data['cluster']['id'], self.cluster1.pk) self.assertEqual(response.data['cluster']['id'], self.cluster1.pk)
def test_get_device_graphs(self):
device_ct = ContentType.objects.get_for_model(Device)
self.graph1 = Graph.objects.create(
type=device_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=device_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=device_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'
)
url = reverse('dcim-api:device-graphs', kwargs={'pk': self.device1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Test Device 1&foo=1')
def test_list_devices(self): def test_list_devices(self):
url = reverse('dcim-api:device-list') url = reverse('dcim-api:device-list')
@ -2134,6 +2175,31 @@ class ConsolePortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConsolePort.objects.count(), 2) self.assertEqual(ConsolePort.objects.count(), 2)
def test_trace_consoleport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
console_server_port = ConsoleServerPort.objects.create(
device=peer_device,
name='Console Server Port 1'
)
cable = Cable(termination_a=self.consoleport1, termination_b=console_server_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleport-trace', kwargs={'pk': self.consoleport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.consoleport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], console_server_port.name)
class ConsoleServerPortTest(APITestCase): class ConsoleServerPortTest(APITestCase):
@ -2245,6 +2311,31 @@ class ConsoleServerPortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConsoleServerPort.objects.count(), 2) self.assertEqual(ConsoleServerPort.objects.count(), 2)
def test_trace_consoleserverport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
console_port = ConsolePort.objects.create(
device=peer_device,
name='Console Port 1'
)
cable = Cable(termination_a=self.consoleserverport1, termination_b=console_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': self.consoleserverport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.consoleserverport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], console_port.name)
class PowerPortTest(APITestCase): class PowerPortTest(APITestCase):
@ -2358,6 +2449,31 @@ class PowerPortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerPort.objects.count(), 2) self.assertEqual(PowerPort.objects.count(), 2)
def test_trace_powerport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
power_outlet = PowerOutlet.objects.create(
device=peer_device,
name='Power Outlet 1'
)
cable = Cable(termination_a=self.powerport1, termination_b=power_outlet, label='Cable 1')
cable.save()
url = reverse('dcim-api:powerport-trace', kwargs={'pk': self.powerport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.powerport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], power_outlet.name)
class PowerOutletTest(APITestCase): class PowerOutletTest(APITestCase):
@ -2469,6 +2585,31 @@ class PowerOutletTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerOutlet.objects.count(), 2) self.assertEqual(PowerOutlet.objects.count(), 2)
def test_trace_poweroutlet(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
power_port = PowerPort.objects.create(
device=peer_device,
name='Power Port 1'
)
cable = Cable(termination_a=self.poweroutlet1, termination_b=power_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': self.poweroutlet1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.poweroutlet1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], power_port.name)
class InterfaceTest(APITestCase): class InterfaceTest(APITestCase):
@ -2673,6 +2814,262 @@ class InterfaceTest(APITestCase):
self.assertEqual(Interface.objects.count(), 2) self.assertEqual(Interface.objects.count(), 2)
class FrontPortTest(APITestCase):
def setUp(self):
super().setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
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'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
rear_ports = RearPort.objects.bulk_create((
RearPort(device=self.device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
))
self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0])
self.frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1])
self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2])
def test_get_frontport(self):
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.frontport1.name)
def test_list_frontports(self):
url = reverse('dcim-api:frontport-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_frontports_brief(self):
url = reverse('dcim-api:frontport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cable', 'device', 'id', 'name', 'url']
)
def test_create_frontport(self):
rear_port = RearPort.objects.get(name='Rear Port 4')
data = {
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port.pk,
'rear_port_position': 1,
}
url = reverse('dcim-api:frontport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(FrontPort.objects.count(), 4)
frontport4 = FrontPort.objects.get(pk=response.data['id'])
self.assertEqual(frontport4.device_id, data['device'])
self.assertEqual(frontport4.name, data['name'])
def test_create_frontport_bulk(self):
rear_ports = RearPort.objects.filter(frontports__isnull=True)
data = [
{
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[0].pk,
'rear_port_position': 1,
},
{
'device': self.device.pk,
'name': 'Front Port 5',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[1].pk,
'rear_port_position': 1,
},
{
'device': self.device.pk,
'name': 'Front Port 6',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[2].pk,
'rear_port_position': 1,
},
]
url = reverse('dcim-api:frontport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(FrontPort.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_frontport(self):
rear_port = RearPort.objects.get(name='Rear Port 4')
data = {
'device': self.device.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_110_PUNCH,
'rear_port': rear_port.pk,
'rear_port_position': 1,
}
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(FrontPort.objects.count(), 3)
frontport1 = FrontPort.objects.get(pk=response.data['id'])
self.assertEqual(frontport1.name, data['name'])
self.assertEqual(frontport1.type, data['type'])
self.assertEqual(frontport1.rear_port, rear_port)
def test_delete_frontport(self):
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(FrontPort.objects.count(), 2)
class RearPortTest(APITestCase):
def setUp(self):
super().setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
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'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 1')
self.rearport3 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 2')
self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 3')
def test_get_rearport(self):
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.rearport1.name)
def test_list_rearports(self):
url = reverse('dcim-api:rearport-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_rearports_brief(self):
url = reverse('dcim-api:rearport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cable', 'device', 'id', 'name', 'url']
)
def test_create_rearport(self):
data = {
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
}
url = reverse('dcim-api:rearport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RearPort.objects.count(), 4)
rearport4 = RearPort.objects.get(pk=response.data['id'])
self.assertEqual(rearport4.device_id, data['device'])
self.assertEqual(rearport4.name, data['name'])
def test_create_rearport_bulk(self):
data = [
{
'device': self.device.pk,
'name': 'Rear Port 4',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device': self.device.pk,
'name': 'Rear Port 5',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device': self.device.pk,
'name': 'Rear Port 6',
'type': PortTypeChoices.TYPE_8P8C,
},
]
url = reverse('dcim-api:rearport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RearPort.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_rearport(self):
data = {
'device': self.device.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_110_PUNCH
}
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(RearPort.objects.count(), 3)
rearport1 = RearPort.objects.get(pk=response.data['id'])
self.assertEqual(rearport1.name, data['name'])
self.assertEqual(rearport1.type, data['type'])
def test_delete_rearport(self):
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(RearPort.objects.count(), 2)
class DeviceBayTest(APITestCase): class DeviceBayTest(APITestCase):
def setUp(self): def setUp(self):

View File

@ -10,7 +10,7 @@ def get_id(model, slug):
class DeviceTestCase(TestCase): class DeviceTestCase(TestCase):
fixtures = ['dcim', 'ipam'] fixtures = ['dcim', 'ipam', 'virtualization']
def test_racked_device(self): def test_racked_device(self):
test = DeviceForm(data={ test = DeviceForm(data={
@ -78,3 +78,15 @@ class DeviceTestCase(TestCase):
}) })
self.assertTrue(test.is_valid()) self.assertTrue(test.is_valid())
self.assertTrue(test.save()) self.assertTrue(test.save())
def test_cloned_cluster_device_initial_data(self):
test = DeviceForm(initial={
'device_type': get_id(DeviceType, 'poweredge-r640'),
'device_role': get_id(DeviceRole, 'server'),
'status': DeviceStatusChoices.STATUS_ACTIVE,
'site': get_id(Site, 'test1'),
"cluster": Cluster.objects.get(id=4).id,
})
self.assertEqual(test.initial['manufacturer'], get_id(Manufacturer, 'dell'))
self.assertIn('cluster_group', test.initial)
self.assertEqual(test.initial['cluster_group'], get_id(ClusterGroup, 'vm-host'))

View File

@ -285,7 +285,28 @@ class DeviceTestCase(TestCase):
name='Device Bay 1' name='Device Bay 1'
) )
def test_device_duplicate_name_per_site(self): def test_multiple_unnamed_devices(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name=''
)
device1.save()
device2 = Device(
site=device1.site,
device_type=device1.device_type,
device_role=device1.device_role,
name=''
)
device2.full_clean()
device2.save()
self.assertEqual(Device.objects.filter(name='').count(), 2)
def test_device_duplicate_names(self):
device1 = Device( device1 = Device(
site=self.site, site=self.site,

View File

@ -30,6 +30,7 @@ from utilities.views import (
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
from .choices import DeviceFaceChoices
from .models import ( from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@ -376,16 +377,15 @@ class RackElevationListView(PermissionRequiredMixin, View):
page = paginator.page(paginator.num_pages) page = paginator.page(paginator.num_pages)
# Determine rack face # Determine rack face
if request.GET.get('face') == '1': rack_face = request.GET.get('face', DeviceFaceChoices.FACE_FRONT)
face_id = 1 if rack_face not in DeviceFaceChoices.values():
else: rack_face = DeviceFaceChoices.FACE_FRONT
face_id = 0
return render(request, 'dcim/rack_elevation_list.html', { return render(request, 'dcim/rack_elevation_list.html', {
'paginator': paginator, 'paginator': paginator,
'page': page, 'page': page,
'total_count': total_count, 'total_count': total_count,
'face_id': face_id, 'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET), 'filter_form': forms.RackElevationFilterForm(request.GET),
}) })
@ -1945,6 +1945,12 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
# Parse initial data manually to avoid setting field values as lists # Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET} initial_data = {k: request.GET[k] for k in request.GET}
# Set initial site and rack based on side A termination (if not already set)
if 'termination_b_site' not in initial_data:
initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None)
if 'termination_b_rack' not in initial_data:
initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None)
form = self.form_class(instance=self.obj, initial=initial_data) form = self.form_class(instance=self.obj, initial=initial_data)
return render(request, self.template_name, { return render(request, self.template_name, {

View File

@ -14,10 +14,10 @@ from django.db import transaction
from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
from mptt.models import MPTTModel from mptt.models import MPTTModel
from ipam.formfields import IPFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from utilities.exceptions import AbortTransaction from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from utilities.exceptions import AbortTransaction
from .forms import ScriptForm from .forms import ScriptForm
from .signals import purge_changelog from .signals import purge_changelog
@ -27,6 +27,8 @@ __all__ = [
'ChoiceVar', 'ChoiceVar',
'FileVar', 'FileVar',
'IntegerVar', 'IntegerVar',
'IPAddressVar',
'IPAddressWithMaskVar',
'IPNetworkVar', 'IPNetworkVar',
'MultiObjectVar', 'MultiObjectVar',
'ObjectVar', 'ObjectVar',
@ -48,15 +50,19 @@ class ScriptVariable:
def __init__(self, label='', description='', default=None, required=True): def __init__(self, label='', description='', default=None, required=True):
# Default field attributes # Initialize field attributes
self.field_attrs = { if not hasattr(self, 'field_attrs'):
'help_text': description, self.field_attrs = {}
'required': required if description:
} self.field_attrs['help_text'] = description
if label: if label:
self.field_attrs['label'] = label self.field_attrs['label'] = label
if default: if default:
self.field_attrs['initial'] = default self.field_attrs['initial'] = default
if required:
self.field_attrs['required'] = True
if 'validators' not in self.field_attrs:
self.field_attrs['validators'] = []
def as_field(self): def as_field(self):
""" """
@ -196,17 +202,32 @@ class FileVar(ScriptVariable):
form_field = forms.FileField form_field = forms.FileField
class IPAddressVar(ScriptVariable):
"""
An IPv4 or IPv6 address without a mask.
"""
form_field = IPAddressFormField
class IPAddressWithMaskVar(ScriptVariable):
"""
An IPv4 or IPv6 address with a mask.
"""
form_field = IPNetworkFormField
class IPNetworkVar(ScriptVariable): class IPNetworkVar(ScriptVariable):
""" """
An IPv4 or IPv6 prefix. An IPv4 or IPv6 prefix.
""" """
form_field = IPFormField form_field = IPNetworkFormField
field_attrs = {
'validators': [prefix_validator]
}
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs): def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.field_attrs['validators'] = list()
# Optional minimum/maximum prefix lengths # Optional minimum/maximum prefix lengths
if min_prefix_length is not None: if min_prefix_length is not None:
self.field_attrs['validators'].append( self.field_attrs['validators'].append(

View File

@ -1,6 +1,6 @@
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import TestCase
from netaddr import IPNetwork from netaddr import IPAddress, IPNetwork
from dcim.models import DeviceRole from dcim.models import DeviceRole
from extras.scripts import * from extras.scripts import *
@ -186,6 +186,54 @@ class ScriptVariablesTest(TestCase):
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], testfile) self.assertEqual(form.cleaned_data['var1'], testfile)
def test_ipaddressvar(self):
class TestScript(Script):
var1 = IPAddressVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate IP mask exclusion
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.1'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPAddress(data['var1']))
def test_ipaddresswithmaskvar(self):
class TestScript(Script):
var1 = IPAddressWithMaskVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate IP mask requirement
data = {'var1': '192.0.2.0'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
def test_ipnetworkvar(self): def test_ipnetworkvar(self):
class TestScript(Script): class TestScript(Script):
@ -198,6 +246,12 @@ class ScriptVariablesTest(TestCase):
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors) self.assertIn('var1', form.errors)
# Validate host IP check
data = {'var1': '192.0.2.1/24'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data # Validate valid data
data = {'var1': '192.0.2.0/24'} data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None) form = TestScript().as_form(data, None)

View File

@ -1,11 +1,19 @@
import json
import uuid
from unittest.mock import patch
import django_rq import django_rq
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.urls import reverse from django.urls import reverse
from requests import Session
from rest_framework import status from rest_framework import status
from dcim.models import Site from dcim.models import Site
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.models import Webhook from extras.models import Webhook
from extras.webhooks import enqueue_webhooks, generate_signature
from extras.webhooks_worker import process_webhook
from utilities.testing import APITestCase from utilities.testing import APITestCase
@ -22,11 +30,13 @@ class WebhookTest(APITestCase):
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
PAYLOAD_URL = "http://localhost/" DUMMY_URL = "http://localhost/"
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
webhooks = Webhook.objects.bulk_create(( webhooks = Webhook.objects.bulk_create((
Webhook(name='Site Create Webhook', type_create=True, payload_url=PAYLOAD_URL), Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}),
Webhook(name='Site Update Webhook', type_update=True, payload_url=PAYLOAD_URL), Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=PAYLOAD_URL), Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
)) ))
for webhook in webhooks: for webhook in webhooks:
webhook.obj_type.set([site_ct]) webhook.obj_type.set([site_ct])
@ -87,3 +97,47 @@ class WebhookTest(APITestCase):
self.assertEqual(job.args[1]['id'], site.pk) self.assertEqual(job.args[1]['id'], site.pk)
self.assertEqual(job.args[2], 'site') self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE) self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE)
def test_webhooks_worker(self):
request_id = uuid.uuid4()
def dummy_send(_, request):
"""
A dummy implementation of Session.send() to be used for testing.
Always returns a 200 HTTP response.
"""
webhook = Webhook.objects.get(type_create=True)
signature = generate_signature(request.body, webhook.secret)
# Validate the outgoing request headers
self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
self.assertEqual(request.headers['X-Hook-Signature'], signature)
self.assertEqual(request.headers['X-Foo'], 'Bar')
# Validate the outgoing request body
body = json.loads(request.body)
self.assertEqual(body['event'], 'created')
self.assertEqual(body['timestamp'], job.args[4])
self.assertEqual(body['model'], 'site')
self.assertEqual(body['username'], 'testuser')
self.assertEqual(body['request_id'], str(request_id))
self.assertEqual(body['data']['name'], 'Site 1')
return HttpResponse()
# Enqueue a webhook for processing
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_webhooks(
instance=site,
user=self.user,
request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE
)
# Retrieve the job from queue
job = self.queue.jobs[0]
# Patch the Session object with our dummy_send() method, then process the webhook for sending
with patch.object(Session, 'send', dummy_send) as mock_send:
process_webhook(*job.args)

View File

@ -1,6 +1,9 @@
import datetime import datetime
import hashlib
import hmac
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from extras.models import Webhook from extras.models import Webhook
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
@ -8,6 +11,18 @@ from .choices import *
from .constants import * from .constants import *
def generate_signature(request_body, secret):
"""
Return a cryptographic signature that can be used to verify the authenticity of webhook data.
"""
hmac_prep = hmac.new(
key=secret.encode('utf8'),
msg=request_body.encode('utf8'),
digestmod=hashlib.sha512
)
return hmac_prep.hexdigest()
def enqueue_webhooks(instance, user, request_id, action): def enqueue_webhooks(instance, user, request_id, action):
""" """
Find Webhook(s) assigned to this instance + action and enqueue them Find Webhook(s) assigned to this instance + action and enqueue them
@ -48,7 +63,7 @@ def enqueue_webhooks(instance, user, request_id, action):
serializer.data, serializer.data,
instance._meta.model_name, instance._meta.model_name,
action, action,
str(datetime.datetime.now()), str(timezone.now()),
user.username, user.username,
request_id request_id
) )

View File

@ -1,5 +1,3 @@
import hashlib
import hmac
import json import json
import requests import requests
@ -7,6 +5,7 @@ from django_rq import job
from rest_framework.utils.encoders import JSONEncoder from rest_framework.utils.encoders import JSONEncoder
from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
from .webhooks import generate_signature
@job('default') @job('default')
@ -23,7 +22,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'data': data 'data': data
} }
headers = { headers = {
'Content-Type': webhook.get_http_content_type_display(), 'Content-Type': webhook.http_content_type,
} }
if webhook.additional_headers: if webhook.additional_headers:
headers.update(webhook.additional_headers) headers.update(webhook.additional_headers)
@ -43,12 +42,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
if webhook.secret != '': if webhook.secret != '':
# Sign the request with a hash of the secret key and its content. # Sign the request with a hash of the secret key and its content.
hmac_prep = hmac.new( prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
key=webhook.secret.encode('utf8'),
msg=prepared_request.body.encode('utf8'),
digestmod=hashlib.sha512
)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
with requests.Session() as session: with requests.Session() as session:
session.verify = webhook.ssl_verification session.verify = webhook.ssl_verification
@ -56,7 +50,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
session.verify = webhook.ca_file_path session.verify = webhook.ca_file_path
response = session.send(prepared_request) response = session.send(prepared_request)
if response.status_code >= 200 and response.status_code <= 299: if 200 <= response.status_code <= 299:
return 'Status {} returned, webhook successfully processed.'.format(response.status_code) return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else: else:
raise requests.exceptions.RequestException( raise requests.exceptions.RequestException(

View File

@ -4,10 +4,34 @@ from .choices import IPAddressRoleChoices
BGP_ASN_MIN = 1 BGP_ASN_MIN = 1
BGP_ASN_MAX = 2**32 - 1 BGP_ASN_MAX = 2**32 - 1
# #
# IP addresses # VRFs
# #
# Per RFC 4364 section 4.2, a route distinguisher may be encoded as one of the following:
# * Type 0 (16-bit AS number : 32-bit integer)
# * Type 1 (32-bit IPv4 address : 16-bit integer)
# * Type 2 (32-bit AS number : 16-bit integer)
# 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535)
VRF_RD_MAX_LENGTH = 21
#
# Prefixes
#
PREFIX_LENGTH_MIN = 1
PREFIX_LENGTH_MAX = 127 # IPv6
#
# IPAddresses
#
IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6
IPADDRESS_ROLES_NONUNIQUE = ( IPADDRESS_ROLES_NONUNIQUE = (
# IPAddress roles which are exempt from unique address enforcement # IPAddress roles which are exempt from unique address enforcement
IPAddressRoleChoices.ROLE_ANYCAST, IPAddressRoleChoices.ROLE_ANYCAST,
@ -17,3 +41,21 @@ IPADDRESS_ROLES_NONUNIQUE = (
IPAddressRoleChoices.ROLE_GLBP, IPAddressRoleChoices.ROLE_GLBP,
IPAddressRoleChoices.ROLE_CARP, IPAddressRoleChoices.ROLE_CARP,
) )
#
# VLANs
#
# 12-bit VLAN ID (values 0 and 4095 are reserved)
VLAN_VID_MIN = 1
VLAN_VID_MAX = 4094
#
# Services
#
# 16-bit port number
SERVICE_PORT_MIN = 1
SERVICE_PORT_MAX = 65535

View File

@ -2,13 +2,8 @@ from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from netaddr import AddrFormatError, IPNetwork from netaddr import AddrFormatError, IPNetwork
from . import lookups from . import lookups, validators
from .formfields import IPFormField from .formfields import IPNetworkFormField
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
class BaseIPField(models.Field): class BaseIPField(models.Field):
@ -38,7 +33,7 @@ class BaseIPField(models.Field):
return str(self.to_python(value)) return str(self.to_python(value))
def form_class(self): def form_class(self):
return IPFormField return IPNetworkFormField
def formfield(self, **kwargs): def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()} defaults = {'form_class': self.form_class()}
@ -51,7 +46,7 @@ class IPNetworkField(BaseIPField):
IP prefix (network and mask) IP prefix (network and mask)
""" """
description = "PostgreSQL CIDR field" description = "PostgreSQL CIDR field"
default_validators = [prefix_validator] default_validators = [validators.prefix_validator]
def db_type(self, connection): def db_type(self, connection):
return 'cidr' return 'cidr'

View File

@ -1,13 +1,44 @@
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from netaddr import IPNetwork, AddrFormatError from django.core.validators import validate_ipv4_address, validate_ipv6_address
from netaddr import IPAddress, IPNetwork, AddrFormatError
# #
# Form fields # Form fields
# #
class IPFormField(forms.Field): class IPAddressFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, IPAddress):
return value
# netaddr is a bit too liberal with what it accepts as a valid IP address. For example, '1.2.3' will become
# IPAddress('1.2.0.3'). Here, we employ Django's built-in IPv4 and IPv6 address validators as a sanity check.
try:
validate_ipv4_address(value)
except ValidationError:
try:
validate_ipv6_address(value)
except ValidationError:
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
try:
return IPAddress(value)
except ValueError:
raise ValidationError('This field requires an IP address without a mask.')
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
class IPNetworkFormField(forms.Field):
default_error_messages = { default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", 'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
} }

View File

@ -13,17 +13,18 @@ from utilities.forms import (
SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES
) )
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .constants import *
from .choices import * from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
IP_FAMILY_CHOICES = [
('', 'All'),
(4, 'IPv4'),
(6, 'IPv6'),
]
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)]) PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)]) (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
])
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
(i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1)
])
# #
@ -219,7 +220,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
) )
family = forms.ChoiceField( family = forms.ChoiceField(
required=False, required=False,
choices=IP_FAMILY_CHOICES, choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family', label='Address family',
widget=StaticSelect2() widget=StaticSelect2()
) )
@ -452,8 +453,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
) )
) )
prefix_length = forms.IntegerField( prefix_length = forms.IntegerField(
min_value=1, min_value=PREFIX_LENGTH_MIN,
max_value=127, max_value=PREFIX_LENGTH_MAX,
required=False required=False
) )
tenant = forms.ModelChoiceField( tenant = forms.ModelChoiceField(
@ -512,7 +513,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
) )
family = forms.ChoiceField( family = forms.ChoiceField(
required=False, required=False,
choices=IP_FAMILY_CHOICES, choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family', label='Address family',
widget=StaticSelect2() widget=StaticSelect2()
) )
@ -899,8 +900,8 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
) )
) )
mask_length = forms.IntegerField( mask_length = forms.IntegerField(
min_value=1, min_value=IPADDRESS_MASK_LENGTH_MIN,
max_value=128, max_value=IPADDRESS_MASK_LENGTH_MAX,
required=False required=False
) )
tenant = forms.ModelChoiceField( tenant = forms.ModelChoiceField(
@ -972,7 +973,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
) )
family = forms.ChoiceField( family = forms.ChoiceField(
required=False, required=False,
choices=IP_FAMILY_CHOICES, choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family', label='Address family',
widget=StaticSelect2() widget=StaticSelect2()
) )
@ -1305,8 +1306,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class ServiceForm(BootstrapMixin, CustomFieldForm): class ServiceForm(BootstrapMixin, CustomFieldForm):
port = forms.IntegerField( port = forms.IntegerField(
min_value=1, min_value=SERVICE_PORT_MIN,
max_value=65535 max_value=SERVICE_PORT_MAX
) )
tags = TagField( tags = TagField(
required=False required=False

View File

@ -14,7 +14,7 @@ from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object from utilities.utils import serialize_object
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from .choices import * from .choices import *
from .constants import IPADDRESS_ROLES_NONUNIQUE from .constants import *
from .fields import IPNetworkField, IPAddressField from .fields import IPNetworkField, IPAddressField
from .managers import IPAddressManager from .managers import IPAddressManager
from .querysets import PrefixQuerySet from .querysets import PrefixQuerySet
@ -44,7 +44,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
max_length=50 max_length=50
) )
rd = models.CharField( rd = models.CharField(
max_length=21, max_length=VRF_RD_MAX_LENGTH,
unique=True, unique=True,
blank=True, blank=True,
null=True, null=True,
@ -1006,7 +1006,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
choices=ServiceProtocolChoices choices=ServiceProtocolChoices
) )
port = models.PositiveIntegerField( port = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(65535)], validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)],
verbose_name='Port number' verbose_name='Port number'
) )
ipaddresses = models.ManyToManyField( ipaddresses = models.ManyToManyField(

View File

@ -1,4 +1,26 @@
from django.core.validators import RegexValidator from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator, RegexValidator
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.'
code = 'max_prefix_length'
def compare(self, a, b):
return a.prefixlen > b
class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
code = 'min_prefix_length'
def compare(self, a, b):
return a.prefixlen < b
DNSValidator = RegexValidator( DNSValidator = RegexValidator(

View File

@ -15,6 +15,7 @@ from utilities.views import (
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from . import filters, forms, tables from . import filters, forms, tables
from .choices import * from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -86,23 +87,20 @@ def add_available_vlans(vlan_group, vlans):
""" """
Create fake records for all gaps between used VLANs Create fake records for all gaps between used VLANs
""" """
MIN_VLAN = 1
MAX_VLAN = 4094
if not vlans: if not vlans:
return [{'vid': MIN_VLAN, 'available': MAX_VLAN - MIN_VLAN + 1}] return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
prev_vid = MAX_VLAN prev_vid = VLAN_VID_MAX
new_vlans = [] new_vlans = []
for vlan in vlans: for vlan in vlans:
if vlan.vid - prev_vid > 1: if vlan.vid - prev_vid > 1:
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1}) new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
prev_vid = vlan.vid prev_vid = vlan.vid
if vlans[0].vid > MIN_VLAN: if vlans[0].vid > VLAN_VID_MIN:
new_vlans.append({'vid': MIN_VLAN, 'available': vlans[0].vid - MIN_VLAN}) new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
if prev_vid < MAX_VLAN: if prev_vid < VLAN_VID_MAX:
new_vlans.append({'vid': prev_vid + 1, 'available': MAX_VLAN - prev_vid}) new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
vlans = list(vlans) + new_vlans vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])

View File

View File

@ -0,0 +1,13 @@
from django.urls import reverse
from utilities.testing import APITestCase
class AppTest(APITestCase):
def test_root(self):
url = reverse('api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,24 @@
import urllib.parse
from django.test import TestCase
from django.urls import reverse
class HomeViewTestCase(TestCase):
def test_home(self):
url = reverse('home')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_search(self):
url = reverse('search')
params = {
'q': 'foo',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)

View File

@ -158,14 +158,17 @@ $(document).ready(function() {
filter_for_elements.each(function(index, filter_for_element) { filter_for_elements.each(function(index, filter_for_element) {
var param_name = $(filter_for_element).attr(attr_name); var param_name = $(filter_for_element).attr(attr_name);
var is_required = $(filter_for_element).attr("required");
var is_nullable = $(filter_for_element).attr("nullable"); var is_nullable = $(filter_for_element).attr("nullable");
var is_visible = $(filter_for_element).is(":visible"); var is_visible = $(filter_for_element).is(":visible");
var value = $(filter_for_element).val(); var value = $(filter_for_element).val();
if (param_name && is_visible && value) { if (param_name && is_visible) {
parameters[param_name] = value; if (value) {
} else if (param_name && is_visible && is_nullable) { parameters[param_name] = value;
parameters[param_name] = "null"; } else if (is_required && is_nullable) {
parameters[param_name] = "null";
}
} }
}); });

View File

@ -0,0 +1,5 @@
#
# Secrets
#
SECRET_PLAINTEXT_MAX_LENGTH = 65535

View File

@ -9,6 +9,7 @@ from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
StaticSelect2Multiple, TagFilterField StaticSelect2Multiple, TagFilterField
) )
from .constants import *
from .models import Secret, SecretRole, UserKey from .models import Secret, SecretRole, UserKey
@ -69,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm):
class SecretForm(BootstrapMixin, CustomFieldForm): class SecretForm(BootstrapMixin, CustomFieldForm):
plaintext = forms.CharField( plaintext = forms.CharField(
max_length=65535, max_length=SECRET_PLAINTEXT_MAX_LENGTH,
required=False, required=False,
label='Plaintext', label='Plaintext',
widget=forms.PasswordInput( widget=forms.PasswordInput(
@ -79,7 +80,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
) )
) )
plaintext2 = forms.CharField( plaintext2 = forms.CharField(
max_length=65535, max_length=SECRET_PLAINTEXT_MAX_LENGTH,
required=False, required=False,
label='Plaintext (verify)', label='Plaintext (verify)',
widget=forms.PasswordInput() widget=forms.PasswordInput()

View File

@ -29,5 +29,4 @@ class UserKeyFormTestCase(TestCase):
data={'public_key': SSH_PUBLIC_KEY}, data={'public_key': SSH_PUBLIC_KEY},
instance=self.userkey, instance=self.userkey,
) )
print(form.is_valid())
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())

View File

@ -144,25 +144,8 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-4 col-md-offset-4"> <div class="col-md-6 col-md-offset-3">
<div class="panel panel-default"> {% include 'dcim/inc/cable_form.html' %}
<div class="panel-heading"><strong>Cable</strong></div>
<div class="panel-body">
{% render_field form.status %}
{% render_field form.type %}
{% render_field form.label %}
{% render_field form.color %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-6">
{{ form.length }}
</div>
<div class="col-md-3">
{{ form.length_unit }}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -1,23 +1,5 @@
{% extends 'utilities/obj_edit.html' %} {% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %} {% block form %}
<div class="panel panel-default"> {% include 'dcim/inc/cable_form.html' %}
<div class="panel-heading"><strong>Cable</strong></div>
<div class="panel-body">
{% render_field form.type %}
{% render_field form.status %}
{% render_field form.label %}
{% render_field form.color %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-6">
{{ form.length }}
</div>
<div class="col-md-3">
{{ form.length_unit }}
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,19 @@
{% load form_helpers %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Cable</strong></div>
<div class="panel-body">
{% render_field form.status %}
{% render_field form.type %}
{% render_field form.label %}
{% render_field form.color %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_length">{{ form.length.label }}</label>
<div class="col-md-5">
{{ form.length }}
</div>
<div class="col-md-4">
{{ form.length_unit }}
</div>
</div>
</div>
</div>

View File

@ -3,8 +3,8 @@
{% block content %} {% block content %}
<div class="btn-group pull-right noprint" role="group"> <div class="btn-group pull-right noprint" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=0 %}" class="btn btn-default{% if request.GET.face != '1' %} active{% endif %}">Front</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=1 %}" class="btn btn-default{% if request.GET.face == '1' %} active{% endif %}">Rear</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div> </div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1> <h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row"> <div class="row">
@ -17,11 +17,7 @@
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
<p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p> <p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
</div> </div>
{% if face_id %} {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
{% else %}
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
{% endif %}
<div class="clearfix"></div> <div class="clearfix"></div>
<div class="rack_header"> <div class="rack_header">
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong> <strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>

View File

@ -13,7 +13,6 @@ from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
from utilities.choices import ChoiceSet
from .utils import dict_to_filter_params, dynamic_import from .utils import dict_to_filter_params, dynamic_import

View File

@ -18,7 +18,7 @@ class ChoiceSet(metaclass=ChoiceSetMeta):
@classmethod @classmethod
def values(cls): def values(cls):
return [c[0] for c in cls.CHOICES] return [c[0] for c in unpack_grouped_choices(cls.CHOICES)]
@classmethod @classmethod
def as_dict(cls): def as_dict(cls):

View File

@ -0,0 +1,50 @@
from django.test import TestCase
from utilities.choices import ChoiceSet
class ExampleChoices(ChoiceSet):
CHOICE_A = 'a'
CHOICE_B = 'b'
CHOICE_C = 'c'
CHOICE_1 = 1
CHOICE_2 = 2
CHOICE_3 = 3
CHOICES = (
('Letters', (
(CHOICE_A, 'A'),
(CHOICE_B, 'B'),
(CHOICE_C, 'C'),
)),
('Digits', (
(CHOICE_1, 'One'),
(CHOICE_2, 'Two'),
(CHOICE_3, 'Three'),
)),
)
LEGACY_MAP = {
CHOICE_A: 101,
CHOICE_B: 102,
CHOICE_C: 103,
CHOICE_1: 201,
CHOICE_2: 202,
CHOICE_3: 203,
}
class ChoiceSetTestCase(TestCase):
def test_values(self):
self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3])
def test_as_dict(self):
self.assertEqual(ExampleChoices.as_dict(), {
'a': 'A', 'b': 'B', 'c': 'C', 1: 'One', 2: 'Two', 3: 'Three'
})
def test_slug_to_id(self):
self.assertEqual(ExampleChoices.slug_to_id('a'), 101)
def test_id_to_slug(self):
self.assertEqual(ExampleChoices.id_to_slug(101), 'a')

View File

@ -1,6 +1,6 @@
import re import re
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator from django.core.validators import _lazy_re_compile, URLValidator
class EnhancedURLValidator(URLValidator): class EnhancedURLValidator(URLValidator):
@ -26,19 +26,3 @@ class EnhancedURLValidator(URLValidator):
r'(?:[/?#][^\s]*)?' # Path r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE) r'\Z', re.IGNORECASE)
schemes = AnyURLScheme() schemes = AnyURLScheme()
class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.'
code = 'max_prefix_length'
def compare(self, a, b):
return a.prefixlen > b
class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
code = 'min_prefix_length'
def compare(self, a, b):
return a.prefixlen < b

View File

@ -0,0 +1,170 @@
[
{
"model": "virtualization.clustertype",
"pk": 1,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "Public Cloud",
"slug": "public-cloud"
}
},
{
"model": "virtualization.clustertype",
"pk": 2,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "vSphere",
"slug": "vsphere"
}
},
{
"model": "virtualization.clustertype",
"pk": 3,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "Hyper-V",
"slug": "hyper-v"
}
},
{
"model": "virtualization.clustertype",
"pk": 4,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "libvirt",
"slug": "libvirt"
}
},
{
"model": "virtualization.clustertype",
"pk": 5,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "LXD",
"slug": "lxd"
}
},
{
"model": "virtualization.clustertype",
"pk": 6,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "Docker",
"slug": "docker"
}
},
{
"model": "virtualization.clustergroup",
"pk": 1,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "VM Host",
"slug": "vm-host"
}
},
{
"model": "virtualization.cluster",
"pk": 1,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "Digital Ocean",
"type": 1,
"group": 1,
"tenant": null,
"site": null,
"comments": ""
}
},
{
"model": "virtualization.cluster",
"pk": 2,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "Amazon EC2",
"type": 1,
"group": 1,
"tenant": null,
"site": null,
"comments": ""
}
},
{
"model": "virtualization.cluster",
"pk": 3,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "Microsoft Azure",
"type": 1,
"group": 1,
"tenant": null,
"site": null,
"comments": ""
}
},
{
"model": "virtualization.cluster",
"pk": 4,
"fields": {
"created": "2016-08-01",
"last_updated": "2016-08-01T15:22:42.289Z",
"name": "vSphere Cluster",
"type": 2,
"group": 1,
"tenant": null,
"site": null,
"comments": ""
}
},
{
"model": "virtualization.virtualmachine",
"pk": 1,
"fields": {
"local_context_data": null,
"created": "2019-12-19",
"last_updated": "2019-12-19T05:24:19.146Z",
"cluster": 2,
"tenant": null,
"platform": null,
"name": "vm1",
"status": "active",
"role": null,
"primary_ip4": null,
"primary_ip6": null,
"vcpus": null,
"memory": null,
"disk": null,
"comments": ""
}
},
{
"model": "virtualization.virtualmachine",
"pk": 2,
"fields": {
"local_context_data": null,
"created": "2019-12-19",
"last_updated": "2019-12-19T05:24:41.478Z",
"cluster": 1,
"tenant": null,
"platform": null,
"name": "vm2",
"status": "active",
"role": null,
"primary_ip4": null,
"primary_ip6": null,
"vcpus": null,
"memory": null,
"disk": null,
"comments": ""
}
}
]

View File

@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError
from taggit.forms import TagField from taggit.forms import TagField
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@ -747,8 +748,8 @@ class InterfaceCreateForm(ComponentForm):
) )
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=INTERFACE_MTU_MIN,
max_value=32767, max_value=INTERFACE_MTU_MAX,
label='MTU' label='MTU'
) )
mac_address = forms.CharField( mac_address = forms.CharField(
@ -836,8 +837,8 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
) )
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=INTERFACE_MTU_MIN,
max_value=32767, max_value=INTERFACE_MTU_MAX,
label='MTU' label='MTU'
) )
description = forms.CharField( description = forms.CharField(
@ -933,8 +934,8 @@ class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm):
) )
mtu = forms.IntegerField( mtu = forms.IntegerField(
required=False, required=False,
min_value=1, min_value=INTERFACE_MTU_MIN,
max_value=32767, max_value=INTERFACE_MTU_MAX,
label='MTU' label='MTU'
) )
description = forms.CharField( description = forms.CharField(