mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-20 02:06:42 -06:00
Merge branch 'develop' into 2921-tags-select2
This commit is contained in:
commit
ca035a72bd
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -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
|
||||||
#
|
#
|
||||||
|
@ -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,
|
||||||
|
@ -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()
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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):
|
||||||
|
@ -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'))
|
||||||
|
@ -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,
|
||||||
|
@ -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, {
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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(
|
||||||
|
@ -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
|
||||||
|
@ -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'
|
||||||
|
@ -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).",
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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(
|
||||||
|
@ -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'])
|
||||||
|
0
netbox/netbox/tests/__init__.py
Normal file
0
netbox/netbox/tests/__init__.py
Normal file
13
netbox/netbox/tests/test_api.py
Normal file
13
netbox/netbox/tests/test_api.py
Normal 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)
|
24
netbox/netbox/tests/test_views.py
Normal file
24
netbox/netbox/tests/test_views.py
Normal 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)
|
@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
5
netbox/secrets/constants.py
Normal file
5
netbox/secrets/constants.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#
|
||||||
|
# Secrets
|
||||||
|
#
|
||||||
|
|
||||||
|
SECRET_PLAINTEXT_MAX_LENGTH = 65535
|
@ -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()
|
||||||
|
@ -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())
|
||||||
|
@ -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">
|
||||||
|
@ -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 %}
|
||||||
|
19
netbox/templates/dcim/inc/cable_form.html
Normal file
19
netbox/templates/dcim/inc/cable_form.html
Normal 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>
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
50
netbox/utilities/tests/test_choices.py
Normal file
50
netbox/utilities/tests/test_choices.py
Normal 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')
|
@ -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
|
|
||||||
|
170
netbox/virtualization/fixtures/virtualization.json
Normal file
170
netbox/virtualization/fixtures/virtualization.json
Normal 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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user