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
* 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

View File

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

View File

@ -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(

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,
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'
)

View File

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

View File

@ -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'))
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
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

View File

@ -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):
"""

View File

@ -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):

View File

@ -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

View File

@ -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.