diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index c4dffb4b9..6fac5b63d 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field. Stored a numeric integer. Options include: -* `min_value:` - Minimum value +* `min_value` - Minimum value * `max_value` - Maximum value ### 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. +### 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 -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 diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 42858f9ba..3b6c0ef5b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,18 @@ ## Enhancements * [#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 --- diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 3a6f8e5e9..0e05867e4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -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_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 = [ InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG, @@ -31,6 +44,17 @@ 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 # diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index b9f41edb5..2b379b9ff 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -66,6 +66,14 @@ "slug": "servertech" } }, +{ + "model": "dcim.manufacturer", + "pk": 4, + "fields": { + "name": "Dell", + "slug": "dell" + } +}, { "model": "dcim.devicetype", "pk": 1, @@ -144,6 +152,19 @@ "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", "pk": 1, @@ -1880,6 +1901,15 @@ "color": "yellow" } }, +{ + "model": "dcim.devicerole", + "pk": 7, + "fields": { + "name": "Server", + "slug": "server", + "color": "grey" + } +}, { "model": "dcim.platform", "pk": 1, @@ -2127,6 +2157,34 @@ "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", "pk": 1, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b71a898b5..794163a9e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,7 +5,6 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist -from django.db.models import Q from mptt.forms import TreeNodeChoiceField from netaddr import EUI from netaddr.core import AddrFormatError @@ -1305,8 +1304,8 @@ class RearPortTemplateCreateForm(ComponentForm): widget=StaticSelect2(), ) positions = forms.IntegerField( - min_value=1, - max_value=64, + min_value=REARPORT_POSITIONS_MIN, + max_value=REARPORT_POSITIONS_MAX, initial=1, 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: 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) if self.instance.pk: @@ -2128,8 +2137,8 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mgmt_only = forms.BooleanField( @@ -2615,8 +2624,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mac_address = forms.CharField( @@ -2770,8 +2779,8 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mgmt_only = forms.NullBooleanField( @@ -3050,8 +3059,8 @@ class RearPortCreateForm(ComponentForm): widget=StaticSelect2(), ) positions = forms.IntegerField( - min_value=1, - max_value=64, + min_value=REARPORT_POSITIONS_MIN, + max_value=REARPORT_POSITIONS_MAX, initial=1, 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', 'label', 'color', 'length', 'length_unit', ] + widgets = { + 'status': StaticSelect2, + 'type': StaticSelect2, + 'length_unit': StaticSelect2, + } class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): @@ -3368,6 +3382,11 @@ class CableForm(BootstrapMixin, forms.ModelForm): fields = [ 'type', 'status', 'label', 'color', 'length', 'length_unit', ] + widgets = { + 'status': StaticSelect2, + 'type': StaticSelect2, + 'length_unit': StaticSelect2, + } class CableCSVForm(forms.ModelForm): @@ -3518,7 +3537,7 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm): required=False ) color = forms.CharField( - max_length=6, + max_length=6, # RGB color code required=False, widget=ColorSelect() ) @@ -3597,7 +3616,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): widget=StaticSelect2() ) color = forms.CharField( - max_length=6, + max_length=6, # RGB color code required=False, widget=ColorSelect() ) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d1b596c22..37ee0a266 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -414,7 +414,7 @@ class RackElevationHelperMixin: drawing.add(drawing.text(str(device), insert=text)) @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( drawing.a( href='{}?{}'.format( @@ -424,6 +424,10 @@ class RackElevationHelperMixin: 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.text("add device", insert=text, class_='add-device')) @@ -453,12 +457,13 @@ class RackElevationHelperMixin: else: # Draw shallow devices, reservations, or empty units class_ = 'slot' + reservation = reserved_units.get(unit["id"]) if device: class_ += ' occupied' - if unit["id"] in reserved_units: + if reservation: class_ += ' reserved' 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 @@ -483,7 +488,12 @@ class RackElevationHelperMixin: 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 @@ -493,7 +503,7 @@ class RackElevationHelperMixin: height of the elevation """ 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) @@ -569,7 +579,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): help_text='Rail-to-rail width' ) u_height = models.PositiveSmallIntegerField( - default=42, + default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', 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 # 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. - if self.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True): - raise ValidationError({ - 'name': 'A device with this name already exists.' - }) + if self.name and self.tenant is None: + if Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True): + raise ValidationError({ + 'name': 'A device with this name already exists.' + }) super().validate_unique(exclude) @@ -1858,15 +1869,15 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): ) voltage = models.PositiveSmallIntegerField( validators=[MinValueValidator(1)], - default=120 + default=POWERFEED_VOLTAGE_DEFAULT ) amperage = models.PositiveSmallIntegerField( validators=[MinValueValidator(1)], - default=20 + default=POWERFEED_AMPERAGE_DEFAULT ) max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], - default=80, + default=POWERFEED_MAX_UTILIZATION_DEFAULT, help_text="Maximum permissible draw (percentage)" ) available_power = models.PositiveIntegerField( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a515df13c..a3a072bc9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -4,6 +4,7 @@ from netaddr import IPNetwork from rest_framework import status from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from dcim.api import serializers from dcim.choices import * from dcim.constants import * from dcim.models import ( @@ -595,6 +596,21 @@ class RackTest(APITestCase): 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): 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['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): url = reverse('dcim-api:device-list') @@ -2134,6 +2175,31 @@ class ConsolePortTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) 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): @@ -2245,6 +2311,31 @@ class ConsoleServerPortTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) 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): @@ -2358,6 +2449,31 @@ class PowerPortTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) 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): @@ -2469,6 +2585,31 @@ class PowerOutletTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) 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): @@ -2673,6 +2814,262 @@ class InterfaceTest(APITestCase): 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): def setUp(self): diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index d7a946568..5bbe36716 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -10,7 +10,7 @@ def get_id(model, slug): class DeviceTestCase(TestCase): - fixtures = ['dcim', 'ipam'] + fixtures = ['dcim', 'ipam', 'virtualization'] def test_racked_device(self): test = DeviceForm(data={ @@ -78,3 +78,15 @@ class DeviceTestCase(TestCase): }) self.assertTrue(test.is_valid()) 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')) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 7573d2cc4..32d864a51 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -285,7 +285,28 @@ class DeviceTestCase(TestCase): 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( site=self.site, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e41d44d95..fd3d09ab7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -30,6 +30,7 @@ from utilities.views import ( ) from virtualization.models import VirtualMachine from . import filters, forms, tables +from .choices import DeviceFaceChoices from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -376,16 +377,15 @@ class RackElevationListView(PermissionRequiredMixin, View): page = paginator.page(paginator.num_pages) # Determine rack face - if request.GET.get('face') == '1': - face_id = 1 - else: - face_id = 0 + rack_face = request.GET.get('face', DeviceFaceChoices.FACE_FRONT) + if rack_face not in DeviceFaceChoices.values(): + rack_face = DeviceFaceChoices.FACE_FRONT return render(request, 'dcim/rack_elevation_list.html', { 'paginator': paginator, 'page': page, 'total_count': total_count, - 'face_id': face_id, + 'rack_face': rack_face, '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 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) return render(request, self.template_name, { diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index fed003bed..bd7e864e1 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -14,10 +14,10 @@ from django.db import transaction from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField from mptt.models import MPTTModel -from ipam.formfields import IPFormField -from utilities.exceptions import AbortTransaction -from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator +from ipam.formfields import IPAddressFormField, IPNetworkFormField +from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING +from utilities.exceptions import AbortTransaction from .forms import ScriptForm from .signals import purge_changelog @@ -27,6 +27,8 @@ __all__ = [ 'ChoiceVar', 'FileVar', 'IntegerVar', + 'IPAddressVar', + 'IPAddressWithMaskVar', 'IPNetworkVar', 'MultiObjectVar', 'ObjectVar', @@ -48,15 +50,19 @@ class ScriptVariable: def __init__(self, label='', description='', default=None, required=True): - # Default field attributes - self.field_attrs = { - 'help_text': description, - 'required': required - } + # Initialize field attributes + if not hasattr(self, 'field_attrs'): + self.field_attrs = {} + if description: + self.field_attrs['help_text'] = description if label: self.field_attrs['label'] = label if 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): """ @@ -196,17 +202,32 @@ class FileVar(ScriptVariable): 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): """ 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): super().__init__(*args, **kwargs) - self.field_attrs['validators'] = list() - # Optional minimum/maximum prefix lengths if min_prefix_length is not None: self.field_attrs['validators'].append( diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 26e12772f..6237d1d95 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,6 +1,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from netaddr import IPNetwork +from netaddr import IPAddress, IPNetwork from dcim.models import DeviceRole from extras.scripts import * @@ -186,6 +186,54 @@ class ScriptVariablesTest(TestCase): self.assertTrue(form.is_valid()) 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): class TestScript(Script): @@ -198,6 +246,12 @@ class ScriptVariablesTest(TestCase): self.assertFalse(form.is_valid()) 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 data = {'var1': '192.0.2.0/24'} form = TestScript().as_form(data, None) diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 02698b7dd..026a82bb8 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -1,11 +1,19 @@ +import json +import uuid +from unittest.mock import patch + import django_rq from django.contrib.contenttypes.models import ContentType +from django.http import HttpResponse from django.urls import reverse +from requests import Session from rest_framework import status from dcim.models import Site from extras.choices import ObjectChangeActionChoices 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 @@ -22,11 +30,13 @@ class WebhookTest(APITestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) - PAYLOAD_URL = "http://localhost/" + DUMMY_URL = "http://localhost/" + DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" + webhooks = Webhook.objects.bulk_create(( - Webhook(name='Site Create Webhook', type_create=True, payload_url=PAYLOAD_URL), - Webhook(name='Site Update Webhook', type_update=True, payload_url=PAYLOAD_URL), - Webhook(name='Site Delete Webhook', type_delete=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=DUMMY_URL, secret=DUMMY_SECRET), + Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), )) for webhook in webhooks: 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[2], 'site') 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) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 5017582cc..cfa05d0f6 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,6 +1,9 @@ import datetime +import hashlib +import hmac from django.contrib.contenttypes.models import ContentType +from django.utils import timezone from extras.models import Webhook from utilities.api import get_serializer_for_model @@ -8,6 +11,18 @@ from .choices 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): """ 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, instance._meta.model_name, action, - str(datetime.datetime.now()), + str(timezone.now()), user.username, request_id ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 6f7ede4e4..e48d8a2d7 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,5 +1,3 @@ -import hashlib -import hmac import json import requests @@ -7,6 +5,7 @@ from django_rq import job from rest_framework.utils.encoders import JSONEncoder from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices +from .webhooks import generate_signature @job('default') @@ -23,7 +22,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'data': data } headers = { - 'Content-Type': webhook.get_http_content_type_display(), + 'Content-Type': webhook.http_content_type, } if 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 != '': # Sign the request with a hash of the secret key and its content. - hmac_prep = hmac.new( - key=webhook.secret.encode('utf8'), - msg=prepared_request.body.encode('utf8'), - digestmod=hashlib.sha512 - ) - prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest() + prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret) with requests.Session() as session: 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 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) else: raise requests.exceptions.RequestException( diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index cf6eb2a2a..41075e54a 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -4,10 +4,34 @@ from .choices import IPAddressRoleChoices BGP_ASN_MIN = 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 which are exempt from unique address enforcement IPAddressRoleChoices.ROLE_ANYCAST, @@ -17,3 +41,21 @@ IPADDRESS_ROLES_NONUNIQUE = ( IPAddressRoleChoices.ROLE_GLBP, 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 diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 72600d1b9..456a7debc 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -2,13 +2,8 @@ from django.core.exceptions import ValidationError from django.db import models from netaddr import AddrFormatError, IPNetwork -from . import lookups -from .formfields import IPFormField - - -def prefix_validator(prefix): - if prefix.ip != prefix.cidr.ip: - raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr)) +from . import lookups, validators +from .formfields import IPNetworkFormField class BaseIPField(models.Field): @@ -38,7 +33,7 @@ class BaseIPField(models.Field): return str(self.to_python(value)) def form_class(self): - return IPFormField + return IPNetworkFormField def formfield(self, **kwargs): defaults = {'form_class': self.form_class()} @@ -51,7 +46,7 @@ class IPNetworkField(BaseIPField): IP prefix (network and mask) """ description = "PostgreSQL CIDR field" - default_validators = [prefix_validator] + default_validators = [validators.prefix_validator] def db_type(self, connection): return 'cidr' diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index 2909a54b1..e8d171d7f 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,13 +1,44 @@ from django import forms 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 # -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 = { 'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", } diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index ac173fbd7..94067a1bc 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -13,17 +13,18 @@ from utilities.forms import ( SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES ) from virtualization.models import VirtualMachine +from .constants import * from .choices import * 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)]) -IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)]) +PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ + (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( required=False, - choices=IP_FAMILY_CHOICES, + choices=add_blank_choice(IPAddressFamilyChoices), label='Address family', widget=StaticSelect2() ) @@ -452,8 +453,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) ) prefix_length = forms.IntegerField( - min_value=1, - max_value=127, + min_value=PREFIX_LENGTH_MIN, + max_value=PREFIX_LENGTH_MAX, required=False ) tenant = forms.ModelChoiceField( @@ -512,7 +513,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) ) family = forms.ChoiceField( required=False, - choices=IP_FAMILY_CHOICES, + choices=add_blank_choice(IPAddressFamilyChoices), label='Address family', widget=StaticSelect2() ) @@ -899,8 +900,8 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd ) ) mask_length = forms.IntegerField( - min_value=1, - max_value=128, + min_value=IPADDRESS_MASK_LENGTH_MIN, + max_value=IPADDRESS_MASK_LENGTH_MAX, required=False ) tenant = forms.ModelChoiceField( @@ -972,7 +973,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo ) family = forms.ChoiceField( required=False, - choices=IP_FAMILY_CHOICES, + choices=add_blank_choice(IPAddressFamilyChoices), label='Address family', widget=StaticSelect2() ) @@ -1305,8 +1306,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class ServiceForm(BootstrapMixin, CustomFieldForm): port = forms.IntegerField( - min_value=1, - max_value=65535 + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX ) tags = TagField( required=False diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 885d5d617..b4ba92fb5 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,7 +14,7 @@ from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from virtualization.models import VirtualMachine from .choices import * -from .constants import IPADDRESS_ROLES_NONUNIQUE +from .constants import * from .fields import IPNetworkField, IPAddressField from .managers import IPAddressManager from .querysets import PrefixQuerySet @@ -44,7 +44,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): max_length=50 ) rd = models.CharField( - max_length=21, + max_length=VRF_RD_MAX_LENGTH, unique=True, blank=True, null=True, @@ -1006,7 +1006,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): choices=ServiceProtocolChoices ) port = models.PositiveIntegerField( - validators=[MinValueValidator(1), MaxValueValidator(65535)], + validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)], verbose_name='Port number' ) ipaddresses = models.ManyToManyField( diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py index 960675643..879e20e6a 100644 --- a/netbox/ipam/validators.py +++ b/netbox/ipam/validators.py @@ -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( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 5cbd55bf4..c8c7d40ca 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -15,6 +15,7 @@ from utilities.views import ( from virtualization.models import VirtualMachine from . import filters, forms, tables from .choices import * +from .constants import * 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 """ - MIN_VLAN = 1 - MAX_VLAN = 4094 - 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 = [] for vlan in vlans: if vlan.vid - prev_vid > 1: new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1}) prev_vid = vlan.vid - if vlans[0].vid > MIN_VLAN: - new_vlans.append({'vid': MIN_VLAN, 'available': vlans[0].vid - MIN_VLAN}) - if prev_vid < MAX_VLAN: - new_vlans.append({'vid': prev_vid + 1, 'available': MAX_VLAN - prev_vid}) + if vlans[0].vid > VLAN_VID_MIN: + new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN}) + if prev_vid < VLAN_VID_MAX: + new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid}) vlans = list(vlans) + new_vlans vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) diff --git a/netbox/netbox/tests/__init__.py b/netbox/netbox/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/netbox/tests/test_api.py b/netbox/netbox/tests/test_api.py new file mode 100644 index 000000000..0ee2d78dc --- /dev/null +++ b/netbox/netbox/tests/test_api.py @@ -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) diff --git a/netbox/netbox/tests/test_views.py b/netbox/netbox/tests/test_views.py new file mode 100644 index 000000000..db84dcd1a --- /dev/null +++ b/netbox/netbox/tests/test_views.py @@ -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) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 58783b5d0..b1ba8a37c 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -158,14 +158,17 @@ $(document).ready(function() { filter_for_elements.each(function(index, filter_for_element) { 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_visible = $(filter_for_element).is(":visible"); var value = $(filter_for_element).val(); - if (param_name && is_visible && value) { - parameters[param_name] = value; - } else if (param_name && is_visible && is_nullable) { - parameters[param_name] = "null"; + if (param_name && is_visible) { + if (value) { + parameters[param_name] = value; + } else if (is_required && is_nullable) { + parameters[param_name] = "null"; + } } }); diff --git a/netbox/secrets/constants.py b/netbox/secrets/constants.py new file mode 100644 index 000000000..a1c3cb3da --- /dev/null +++ b/netbox/secrets/constants.py @@ -0,0 +1,5 @@ +# +# Secrets +# + +SECRET_PLAINTEXT_MAX_LENGTH = 65535 diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index b815c3c5e..9c1acccdb 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -9,6 +9,7 @@ from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField ) +from .constants import * from .models import Secret, SecretRole, UserKey @@ -69,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm): class SecretForm(BootstrapMixin, CustomFieldForm): plaintext = forms.CharField( - max_length=65535, + max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, label='Plaintext', widget=forms.PasswordInput( @@ -79,7 +80,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm): ) ) plaintext2 = forms.CharField( - max_length=65535, + max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, label='Plaintext (verify)', widget=forms.PasswordInput() diff --git a/netbox/secrets/tests/test_form.py b/netbox/secrets/tests/test_form.py index 42111abbf..d122358cc 100644 --- a/netbox/secrets/tests/test_form.py +++ b/netbox/secrets/tests/test_form.py @@ -29,5 +29,4 @@ class UserKeyFormTestCase(TestCase): data={'public_key': SSH_PUBLIC_KEY}, instance=self.userkey, ) - print(form.is_valid()) self.assertFalse(form.is_valid()) diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index b1609f578..aa4c4bf8c 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -144,25 +144,8 @@
-
-
-
Cable
-
- {% render_field form.status %} - {% render_field form.type %} - {% render_field form.label %} - {% render_field form.color %} -
- -
- {{ form.length }} -
-
- {{ form.length_unit }} -
-
-
-
+
+ {% include 'dcim/inc/cable_form.html' %}
diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 17403a07d..685b68206 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -1,23 +1,5 @@ {% extends 'utilities/obj_edit.html' %} -{% load form_helpers %} {% block form %} -
-
Cable
-
- {% render_field form.type %} - {% render_field form.status %} - {% render_field form.label %} - {% render_field form.color %} -
- -
- {{ form.length }} -
-
- {{ form.length_unit }} -
-
-
-
+ {% include 'dcim/inc/cable_form.html' %} {% endblock %} diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html new file mode 100644 index 000000000..0799eb130 --- /dev/null +++ b/netbox/templates/dcim/inc/cable_form.html @@ -0,0 +1,19 @@ +{% load form_helpers %} +
+
Cable
+
+ {% render_field form.status %} + {% render_field form.type %} + {% render_field form.label %} + {% render_field form.color %} +
+ +
+ {{ form.length }} +
+
+ {{ form.length_unit }} +
+
+
+
diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index de7c55919..da4f002d6 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -3,8 +3,8 @@ {% block content %}
- Front - Rear + Front + Rear

{% block title %}Rack Elevations{% endblock %}

@@ -17,11 +17,7 @@ {{ rack.name|truncatechars:"25" }}

{{ rack.facility_id|truncatechars:"30" }}

- {% if face_id %} - {% include 'dcim/inc/rack_elevation.html' with face='rear' %} - {% else %} - {% include 'dcim/inc/rack_elevation.html' with face='front' %} - {% endif %} + {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
{{ rack.name|truncatechars:"25" }} diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 7c37a5b20..5ef4156aa 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -13,7 +13,6 @@ from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet -from utilities.choices import ChoiceSet from .utils import dict_to_filter_params, dynamic_import diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 6e6afe3e2..19082dbb6 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -18,7 +18,7 @@ class ChoiceSet(metaclass=ChoiceSetMeta): @classmethod def values(cls): - return [c[0] for c in cls.CHOICES] + return [c[0] for c in unpack_grouped_choices(cls.CHOICES)] @classmethod def as_dict(cls): diff --git a/netbox/utilities/tests/test_choices.py b/netbox/utilities/tests/test_choices.py new file mode 100644 index 000000000..adff4eb60 --- /dev/null +++ b/netbox/utilities/tests/test_choices.py @@ -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') diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index fb7a5bba7..cfa733208 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,6 +1,6 @@ import re -from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from django.core.validators import _lazy_re_compile, URLValidator class EnhancedURLValidator(URLValidator): @@ -26,19 +26,3 @@ class EnhancedURLValidator(URLValidator): r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) 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 diff --git a/netbox/virtualization/fixtures/virtualization.json b/netbox/virtualization/fixtures/virtualization.json new file mode 100644 index 000000000..3c9537802 --- /dev/null +++ b/netbox/virtualization/fixtures/virtualization.json @@ -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": "" + } +} +] diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index de47893eb..7fea8a99a 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from taggit.forms import TagField 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.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm @@ -747,8 +748,8 @@ class InterfaceCreateForm(ComponentForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mac_address = forms.CharField( @@ -836,8 +837,8 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) description = forms.CharField( @@ -933,8 +934,8 @@ class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) description = forms.CharField(