Explorar o código

Initial work on half-height RUs

jeremystretch %!s(int64=3) %!d(string=hai) anos
pai
achega
84f0561712

+ 11 - 0
docs/release-notes/version-3.3.md

@@ -4,8 +4,13 @@
 
 ### Breaking Changes
 
+* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units.
 * The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None).
 
+### New Features
+
+#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51))
+
 ### Enhancements
 
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@@ -23,6 +28,12 @@
 
 ### REST API Changes
 
+* dcim.Device
+    * The `position` field has been changed from an integer to a decimal
+* dcim.DeviceType
+    * The `u_height` field has been changed from an integer to a decimal
+* dcim.Rack
+    * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
 * extras.CustomField
     * Added `group_name` and `ui_visibility` fields
 * ipam.IPAddress

+ 22 - 2
netbox/dcim/api/serializers.py

@@ -1,3 +1,5 @@
+import decimal
+
 from django.contrib.contenttypes.models import ContentType
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
@@ -201,7 +203,11 @@ class RackUnitSerializer(serializers.Serializer):
     """
     A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database.
     """
-    id = serializers.IntegerField(read_only=True)
+    id = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        read_only=True
+    )
     name = serializers.CharField(read_only=True)
     face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
     device = NestedDeviceSerializer(read_only=True)
@@ -283,6 +289,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
 class DeviceTypeSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
     manufacturer = NestedManufacturerSerializer()
+    u_height = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        label='Position (U)',
+        min_value=decimal.Decimal(0.5),
+        default=1.0
+    )
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
     device_count = serializers.IntegerField(read_only=True)
@@ -589,7 +602,14 @@ class DeviceSerializer(NetBoxModelSerializer):
     location = NestedLocationSerializer(required=False, allow_null=True, default=None)
     rack = NestedRackSerializer(required=False, allow_null=True, default=None)
     face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
-    position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None)
+    position = serializers.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        allow_null=True,
+        label='Position (U)',
+        min_value=decimal.Decimal(0.5),
+        default=None
+    )
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
     primary_ip = NestedIPAddressSerializer(read_only=True)

+ 1 - 1
netbox/dcim/forms/models.py

@@ -467,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
             'location_id': '$location',
         }
     )
-    position = forms.IntegerField(
+    position = forms.DecimalField(
         required=False,
         help_text="The lowest-numbered unit occupied by the device",
         widget=APISelect(

+ 23 - 0
netbox/dcim/migrations/0154_half_height_rack_units.py

@@ -0,0 +1,23 @@
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0153_created_datetimefield'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='devicetype',
+            name='u_height',
+            field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4),
+        ),
+        migrations.AlterField(
+            model_name='device',
+            name='position',
+            field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
+        ),
+    ]

+ 8 - 4
netbox/dcim/models/devices.py

@@ -99,8 +99,10 @@ class DeviceType(NetBoxModel):
         blank=True,
         help_text='Discrete part number (optional)'
     )
-    u_height = models.PositiveSmallIntegerField(
-        default=1,
+    u_height = models.DecimalField(
+        max_digits=4,
+        decimal_places=1,
+        default=1.0,
         verbose_name='Height (U)'
     )
     is_full_depth = models.BooleanField(
@@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel):
         blank=True,
         null=True
     )
-    position = models.PositiveSmallIntegerField(
+    position = models.DecimalField(
+        max_digits=4,
+        decimal_places=1,
         blank=True,
         null=True,
-        validators=[MinValueValidator(1)],
+        validators=[MinValueValidator(1), MaxValueValidator(99.5)],
         verbose_name='Position (U)',
         help_text='The lowest-numbered unit occupied by the device'
     )

+ 30 - 26
netbox/dcim/models/racks.py

@@ -1,4 +1,4 @@
-from collections import OrderedDict
+import decimal
 
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericRelation
@@ -13,11 +13,10 @@ from django.urls import reverse
 from dcim.choices import *
 from dcim.constants import *
 from dcim.svg import RackElevationSVG
-from netbox.config import get_config
 from netbox.models import OrganizationalModel, NetBoxModel
 from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
-from utilities.utils import array_to_string
+from utilities.utils import array_to_string, drange
 from .device_components import PowerOutlet, PowerPort
 from .devices import Device
 from .power import PowerFeed
@@ -242,10 +241,13 @@ class Rack(NetBoxModel):
 
     @property
     def units(self):
+        """
+        Return a list of unit numbers, top to bottom.
+        """
+        max_position = self.u_height + decimal.Decimal(0.5)
         if self.desc_units:
-            return range(1, self.u_height + 1)
-        else:
-            return reversed(range(1, self.u_height + 1))
+            drange(0.5, max_position, 0.5)
+        return drange(max_position, 0.5, -0.5)
 
     def get_status_color(self):
         return RackStatusChoices.colors.get(self.status)
@@ -263,12 +265,12 @@ class Rack(NetBoxModel):
             reference to the device. When False, only the bottom most unit for a device is included and that unit
             contains a height attribute for the device
         """
-
-        elevation = OrderedDict()
+        elevation = {}
         for u in self.units:
+            u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
             elevation[u] = {
                 'id': u,
-                'name': f'U{u}',
+                'name': u_name,
                 'face': face,
                 'device': None,
                 'occupied': False
@@ -278,7 +280,7 @@ class Rack(NetBoxModel):
         if self.pk:
 
             # Retrieve all devices installed within the rack
-            queryset = Device.objects.prefetch_related(
+            devices = Device.objects.prefetch_related(
                 'device_type',
                 'device_type__manufacturer',
                 'device_role'
@@ -299,9 +301,9 @@ class Rack(NetBoxModel):
             if user is not None:
                 permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
 
-            for device in queryset:
+            for device in devices:
                 if expand_devices:
-                    for u in range(device.position, device.position + device.device_type.u_height):
+                    for u in drange(device.position, device.position + device.device_type.u_height, 0.5):
                         if user is None or device.pk in permitted_device_ids:
                             elevation[u]['device'] = device
                         elevation[u]['occupied'] = True
@@ -310,8 +312,6 @@ class Rack(NetBoxModel):
                         elevation[device.position]['device'] = device
                     elevation[device.position]['occupied'] = True
                     elevation[device.position]['height'] = device.device_type.u_height
-                    for u in range(device.position + 1, device.position + device.device_type.u_height):
-                        elevation.pop(u, None)
 
         return [u for u in elevation.values()]
 
@@ -331,12 +331,12 @@ class Rack(NetBoxModel):
             devices = devices.exclude(pk__in=exclude)
 
         # Initialize the rack unit skeleton
-        units = list(range(1, self.u_height + 1))
+        units = list(self.units)
 
         # Remove units consumed by installed devices
         for d in devices:
             if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
-                for u in range(d.position, d.position + d.device_type.u_height):
+                for u in drange(d.position, d.position + d.device_type.u_height, 0.5):
                     try:
                         units.remove(u)
                     except ValueError:
@@ -346,7 +346,7 @@ class Rack(NetBoxModel):
         # Remove units without enough space above them to accommodate a device of the specified height
         available_units = []
         for u in units:
-            if set(range(u, u + u_height)).issubset(units):
+            if set(drange(u, u + u_height, 0.5)).issubset(units):
                 available_units.append(u)
 
         return list(reversed(available_units))
@@ -356,9 +356,9 @@ class Rack(NetBoxModel):
         Return a dictionary mapping all reserved units within the rack to their reservation.
         """
         reserved_units = {}
-        for r in self.reservations.all():
-            for u in r.units:
-                reserved_units[u] = r
+        for reservation in self.reservations.all():
+            for u in reservation.units:
+                reserved_units[u] = reservation
         return reserved_units
 
     def get_elevation_svg(
@@ -384,13 +384,17 @@ class Rack(NetBoxModel):
         :param include_images: Embed front/rear device images where available
         :param base_url: Base URL for links and images. If none, URLs will be relative.
         """
-        elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url)
-        if unit_width is None or unit_height is None:
-            config = get_config()
-            unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
-            unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+        elevation = RackElevationSVG(
+            self,
+            unit_width=unit_width,
+            unit_height=unit_height,
+            legend_width=legend_width,
+            user=user,
+            include_images=include_images,
+            base_url=base_url
+        )
 
-        return elevation.render(face, unit_width, unit_height, legend_width)
+        return elevation.render(face)
 
     def get_0u_devices(self):
         return self.devices.filter(position=0)

+ 96 - 79
netbox/dcim/svg.py

@@ -1,3 +1,4 @@
+import decimal
 import svgwrite
 from svgwrite.container import Group, Hyperlink
 from svgwrite.shapes import Line, Rect
@@ -7,6 +8,7 @@ from django.conf import settings
 from django.urls import reverse
 from django.utils.http import urlencode
 
+from netbox.config import get_config
 from utilities.utils import foreground_color
 from .choices import DeviceFaceChoices
 from .constants import RACK_ELEVATION_BORDER_WIDTH
@@ -36,13 +38,17 @@ class RackElevationSVG:
     :param include_images: If true, the SVG document will embed front/rear device face images, where available
     :param base_url: Base URL for links within the SVG document. If none, links will be relative.
     """
-    def __init__(self, rack, user=None, include_images=True, base_url=None):
+    def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True,
+                 base_url=None):
         self.rack = rack
         self.include_images = include_images
-        if base_url is not None:
-            self.base_url = base_url.rstrip('/')
-        else:
-            self.base_url = ''
+        self.base_url = base_url.rstrip('/') if base_url is not None else ''
+
+        # Set drawing dimensions
+        config = get_config()
+        self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
+        self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
+        self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
 
         # Determine the subset of devices within this rack that are viewable by the user, if any
         permitted_devices = self.rack.devices
@@ -78,15 +84,16 @@ class RackElevationSVG:
         gradient.add_stop_color(offset='100%', color=color)
         drawing.defs.add(gradient)
 
-    @staticmethod
-    def _setup_drawing(width, height):
+    def _setup_drawing(self):
+        width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2
+        height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
         drawing = svgwrite.Drawing(size=(width, height))
 
-        # add the stylesheet
+        # Add the stylesheet
         with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
             drawing.defs.add(drawing.style(css_file.read()))
 
-        # add gradients
+        # Add gradients
         RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
         RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
         RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
@@ -151,7 +158,7 @@ class RackElevationSVG:
                      stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
             link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
 
-    def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
+    def _draw_empty(self, drawing, rack, start, end, text, unit, face_id, class_, reservation):
         link_url = '{}{}?{}'.format(
             self.base_url,
             reverse('dcim:device_add'),
@@ -160,7 +167,7 @@ class RackElevationSVG:
                 'location': rack.location.pk if rack.location else '',
                 'rack': rack.pk,
                 'face': face_id,
-                'position': id_
+                'position': unit
             })
         )
         link = drawing.add(
@@ -173,98 +180,108 @@ class RackElevationSVG:
         link.add(drawing.rect(start, end, class_=class_))
         link.add(drawing.text("add device", insert=text, class_='add-device'))
 
-    def merge_elevations(self, face):
-        elevation = self.rack.get_rack_units(face=face, expand_devices=False)
-        if face == DeviceFaceChoices.FACE_REAR:
-            other_face = DeviceFaceChoices.FACE_FRONT
-        else:
-            other_face = DeviceFaceChoices.FACE_REAR
-        other = self.rack.get_rack_units(face=other_face)
-
-        unit_cursor = 0
-        for u in elevation:
-            o = other[unit_cursor]
-            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
-                u['device'] = o['device']
-                u['height'] = 1
-            unit_cursor += u.get('height', 1)
-
-        return elevation
-
-    def render(self, face, unit_width, unit_height, legend_width):
+    def draw_legend(self):
         """
-        Return an SVG document representing a rack elevation.
+        Draw the rack unit labels along the lefthand side of the elevation.
         """
-        drawing = self._setup_drawing(
-            unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
-            unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
-        )
-        reserved_units = self.rack.get_reserved_units()
-
-        unit_cursor = 0
         for ru in range(0, self.rack.u_height):
-            start_y = ru * unit_height
-            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
+            start_y = ru * self.unit_height
+            position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
             unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
-            drawing.add(
-                drawing.text(str(unit), position_coordinates, class_="unit")
+            self.drawing.add(
+                Text(str(unit), position_coordinates, class_="unit")
             )
 
-        for unit in self.merge_elevations(face):
+    def draw_face(self, face, opposite=False):
+        """
+        Draw any occupied rack units for the specified rack face.
+        """
+        for unit in self.rack.get_rack_units(face=face, expand_devices=False):
 
             # Loop through all units in the elevation
             device = unit['device']
-            height = unit.get('height', 1)
+            height = unit.get('height', decimal.Decimal(1.0))
 
             # Setup drawing coordinates
-            x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
-            y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
-            end_y = unit_height * height
+            x_offset = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
+            if self.rack.desc_units:
+                y_offset = int(unit['id'] * self.unit_height) + RACK_ELEVATION_BORDER_WIDTH
+            else:
+                y_offset = self.drawing['height'] - int(unit['id'] * self.unit_height) - RACK_ELEVATION_BORDER_WIDTH
+
+            end_y = int(self.unit_height * height)
             start_cordinates = (x_offset, y_offset)
-            end_cordinates = (unit_width, end_y)
-            text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
+            size = (self.unit_width, end_y)
+            text_cordinates = (x_offset + (self.unit_width / 2), y_offset + end_y / 2)
 
             # Draw the device
-            if device and device.face == face and device.pk in self.permitted_device_ids:
-                self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
-            elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
-                self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
+            if device and device.pk in self.permitted_device_ids:
+                print(device)
+                print(f'    {start_cordinates}')
+                print(f'    {size}')
+
+                if device.face == face and not opposite:
+                    self._draw_device_front(self.drawing, device, start_cordinates, size, text_cordinates)
+                else:
+                    self._draw_device_rear(self.drawing, device, start_cordinates, size, text_cordinates)
+
             elif device:
                 # Devices which the user does not have permission to view are rendered only as unavailable space
-                drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
-            else:
-                # Draw shallow devices, reservations, or empty units
-                class_ = 'slot'
-                reservation = reserved_units.get(unit["id"])
-                if device:
-                    class_ += ' occupied'
-                if reservation:
-                    class_ += ' reserved'
-                self._draw_empty(
-                    drawing,
-                    self.rack,
-                    start_cordinates,
-                    end_cordinates,
-                    text_cordinates,
-                    unit["id"],
-                    face,
-                    class_,
-                    reservation
-                )
+                self.drawing.add(Rect(start_cordinates, size, class_='blocked'))
+
+            # else:
+            #     # Draw shallow devices, reservations, or empty units
+            #     class_ = 'slot'
+            #     # reservation = reserved_units.get(unit["id"])
+            #     reservation = None
+            #     if device:
+            #         class_ += ' occupied'
+            #     if reservation:
+            #         class_ += ' reserved'
+            #     self._draw_empty(
+            #         self.drawing,
+            #         self.rack,
+            #         start_cordinates,
+            #         end_cordinates,
+            #         text_cordinates,
+            #         unit["id"],
+            #         face,
+            #         class_,
+            #         reservation
+            #     )
+
+    def render(self, face):
+        """
+        Return an SVG document representing a rack elevation.
+        """
 
-            unit_cursor += height
+        # Initialize the drawing
+        self.drawing = self._setup_drawing()
+
+        # reserved_units = self.rack.get_reserved_units()
+
+        # Draw the unit legend
+        self.draw_legend()
+
+        # Draw the opposite rack face first, then the near face
+        if face == DeviceFaceChoices.FACE_REAR:
+            opposite_face = DeviceFaceChoices.FACE_FRONT
+        else:
+            opposite_face = DeviceFaceChoices.FACE_REAR
+        # self.draw_face(opposite_face, opposite=True)
+        self.draw_face(face)
 
         # Wrap the drawing with a border
         border_width = RACK_ELEVATION_BORDER_WIDTH
         border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
-        frame = drawing.rect(
-            insert=(legend_width + border_offset, border_offset),
-            size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
+        frame = Rect(
+            insert=(self.legend_width + border_offset, border_offset),
+            size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
             class_='rack'
         )
-        drawing.add(frame)
+        self.drawing.add(frame)
 
-        return drawing
+        return self.drawing
 
 
 OFFSET = 0.5

+ 4 - 4
netbox/dcim/tests/test_api.py

@@ -327,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
 
         # Retrieve all units
         response = self.client.get(url, **self.header)
-        self.assertEqual(response.data['count'], 42)
+        self.assertEqual(response.data['count'], 84)
 
         # Search for specific units
         response = self.client.get(f'{url}?q=3', **self.header)
-        self.assertEqual(response.data['count'], 13)
+        self.assertEqual(response.data['count'], 26)
         response = self.client.get(f'{url}?q=U3', **self.header)
-        self.assertEqual(response.data['count'], 11)
+        self.assertEqual(response.data['count'], 22)
         response = self.client.get(f'{url}?q=U10', **self.header)
-        self.assertEqual(response.data['count'], 1)
+        self.assertEqual(response.data['count'], 2)
 
     def test_get_rack_elevation_svg(self):
         """

+ 21 - 10
netbox/dcim/tests/test_models.py

@@ -1,3 +1,5 @@
+import decimal
+
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
@@ -5,6 +7,7 @@ from circuits.models import *
 from dcim.choices import *
 from dcim.models import *
 from tenancy.models import Tenant
+from utilities.utils import drange
 
 
 class LocationTestCase(TestCase):
@@ -183,26 +186,34 @@ class RackTestCase(TestCase):
             device_role=DeviceRole.objects.get(slug='switch'),
             site=self.site1,
             rack=self.rack,
-            position=10,
+            position=10.0,
             face=DeviceFaceChoices.FACE_REAR,
         )
         device1.save()
 
         # Validate rack height
-        self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
+        self.assertEqual(list(self.rack.units), list(drange(42.5, 0.5, -0.5)))
 
         # Validate inventory (front face)
-        rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
-        self.assertEqual(rack1_inventory_front[-10]['device'], device1)
-        del(rack1_inventory_front[-10])
-        for u in rack1_inventory_front:
+        rack1_inventory_front = {
+            u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
+        }
+        self.assertEqual(rack1_inventory_front[10.0]['device'], device1)
+        self.assertEqual(rack1_inventory_front[10.5]['device'], device1)
+        del(rack1_inventory_front[10.0])
+        del(rack1_inventory_front[10.5])
+        for u in rack1_inventory_front.values():
             self.assertIsNone(u['device'])
 
         # Validate inventory (rear face)
-        rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
-        self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
-        del(rack1_inventory_rear[-10])
-        for u in rack1_inventory_rear:
+        rack1_inventory_rear = {
+            u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
+        }
+        self.assertEqual(rack1_inventory_rear[10.0]['device'], device1)
+        self.assertEqual(rack1_inventory_rear[10.5]['device'], device1)
+        del(rack1_inventory_rear[10.0])
+        del(rack1_inventory_rear[10.5])
+        for u in rack1_inventory_rear.values():
             self.assertIsNone(u['device'])
 
     def test_mount_zero_ru(self):

+ 0 - 1
netbox/utilities/forms/utils.py

@@ -1,7 +1,6 @@
 import re
 
 from django import forms
-from django.conf import settings
 from django.forms.models import fields_for_model
 
 from utilities.choices import unpack_grouped_choices

+ 16 - 0
netbox/utilities/utils.py

@@ -1,4 +1,5 @@
 import datetime
+import decimal
 import json
 from collections import OrderedDict
 from decimal import Decimal
@@ -226,6 +227,21 @@ def deepmerge(original, new):
     return merged
 
 
+def drange(start, end, step=decimal.Decimal(1)):
+    """
+    Decimal-compatible implementation of Python's range()
+    """
+    start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step)
+    if start < end:
+        while start < end:
+            yield start
+            start += step
+    else:
+        while start > end:
+            yield start
+            start += step
+
+
 def to_meters(length, unit):
     """
     Convert the given length to meters.