Initial work on half-height RUs

This commit is contained in:
jeremystretch 2022-06-09 17:27:58 -04:00
parent ba12db3019
commit 84f0561712
11 changed files with 232 additions and 127 deletions

View File

@ -4,8 +4,13 @@
### Breaking Changes ### 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). * 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 ### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
@ -23,6 +28,12 @@
### REST API Changes ### 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 * extras.CustomField
* Added `group_name` and `ui_visibility` fields * Added `group_name` and `ui_visibility` fields
* ipam.IPAddress * ipam.IPAddress

View File

@ -1,3 +1,5 @@
import decimal
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers 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. 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) name = serializers.CharField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True) device = NestedDeviceSerializer(read_only=True)
@ -283,6 +289,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
class DeviceTypeSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer() 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) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
@ -589,7 +602,14 @@ class DeviceSerializer(NetBoxModelSerializer):
location = NestedLocationSerializer(required=False, allow_null=True, default=None) location = NestedLocationSerializer(required=False, allow_null=True, default=None)
rack = NestedRackSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') 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) status = ChoiceField(choices=DeviceStatusChoices, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip = NestedIPAddressSerializer(read_only=True)

View File

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

View File

@ -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)]),
),
]

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import decimal
import svgwrite import svgwrite
from svgwrite.container import Group, Hyperlink from svgwrite.container import Group, Hyperlink
from svgwrite.shapes import Line, Rect from svgwrite.shapes import Line, Rect
@ -7,6 +8,7 @@ from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
from netbox.config import get_config
from utilities.utils import foreground_color from utilities.utils import foreground_color
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices
from .constants import RACK_ELEVATION_BORDER_WIDTH 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 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. :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.rack = rack
self.include_images = include_images self.include_images = include_images
if base_url is not None: self.base_url = base_url.rstrip('/') if base_url is not None else ''
self.base_url = base_url.rstrip('/')
else: # Set drawing dimensions
self.base_url = '' 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 # Determine the subset of devices within this rack that are viewable by the user, if any
permitted_devices = self.rack.devices permitted_devices = self.rack.devices
@ -78,15 +84,16 @@ class RackElevationSVG:
gradient.add_stop_color(offset='100%', color=color) gradient.add_stop_color(offset='100%', color=color)
drawing.defs.add(gradient) drawing.defs.add(gradient)
@staticmethod def _setup_drawing(self):
def _setup_drawing(width, height): 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)) drawing = svgwrite.Drawing(size=(width, height))
# add the stylesheet # Add the stylesheet
with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
drawing.defs.add(drawing.style(css_file.read())) drawing.defs.add(drawing.style(css_file.read()))
# add gradients # Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
@ -151,7 +158,7 @@ class RackElevationSVG:
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) 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')) 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( link_url = '{}{}?{}'.format(
self.base_url, self.base_url,
reverse('dcim:device_add'), reverse('dcim:device_add'),
@ -160,7 +167,7 @@ class RackElevationSVG:
'location': rack.location.pk if rack.location else '', 'location': rack.location.pk if rack.location else '',
'rack': rack.pk, 'rack': rack.pk,
'face': face_id, 'face': face_id,
'position': id_ 'position': unit
}) })
) )
link = drawing.add( link = drawing.add(
@ -173,98 +180,108 @@ class RackElevationSVG:
link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device')) link.add(drawing.text("add device", insert=text, class_='add-device'))
def merge_elevations(self, face): def draw_legend(self):
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):
""" """
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): for ru in range(0, self.rack.u_height):
start_y = ru * unit_height start_y = ru * self.unit_height
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) 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 unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
drawing.add( self.drawing.add(
drawing.text(str(unit), position_coordinates, class_="unit") 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 # Loop through all units in the elevation
device = unit['device'] device = unit['device']
height = unit.get('height', 1) height = unit.get('height', decimal.Decimal(1.0))
# Setup drawing coordinates # Setup drawing coordinates
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH x_offset = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH if self.rack.desc_units:
end_y = unit_height * height 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) start_cordinates = (x_offset, y_offset)
end_cordinates = (unit_width, end_y) size = (self.unit_width, end_y)
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) text_cordinates = (x_offset + (self.unit_width / 2), y_offset + end_y / 2)
# Draw the device # Draw the device
if device and device.face == face and device.pk in self.permitted_device_ids: if device and device.pk in self.permitted_device_ids:
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) print(device)
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: print(f' {start_cordinates}')
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_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: elif device:
# Devices which the user does not have permission to view are rendered only as unavailable space # 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')) 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"])
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
)
unit_cursor += height # 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.
"""
# 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 # Wrap the drawing with a border
border_width = RACK_ELEVATION_BORDER_WIDTH border_width = RACK_ELEVATION_BORDER_WIDTH
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
frame = drawing.rect( frame = Rect(
insert=(legend_width + border_offset, border_offset), insert=(self.legend_width + border_offset, border_offset),
size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width),
class_='rack' class_='rack'
) )
drawing.add(frame) self.drawing.add(frame)
return drawing return self.drawing
OFFSET = 0.5 OFFSET = 0.5

View File

@ -327,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
# Retrieve all units # Retrieve all units
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42) self.assertEqual(response.data['count'], 84)
# Search for specific units # Search for specific units
response = self.client.get(f'{url}?q=3', **self.header) 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) 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) 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): def test_get_rack_elevation_svg(self):
""" """

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import datetime import datetime
import decimal
import json import json
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal from decimal import Decimal
@ -226,6 +227,21 @@ def deepmerge(original, new):
return merged 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): def to_meters(length, unit):
""" """
Convert the given length to meters. Convert the given length to meters.