Merge pull request #9571 from netbox-community/51-half-height-rack-units

Closes #51: Half height rack units
This commit is contained in:
Jeremy Stretch 2022-06-20 14:13:44 -04:00 committed by GitHub
commit 7decad1ff3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 407 additions and 324 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(
@ -166,7 +168,7 @@ class DeviceType(NetBoxModel):
('model', self.model),
('slug', self.slug),
('part_number', self.part_number),
('u_height', self.u_height),
('u_height', float(self.u_height)),
('is_full_depth', self.is_full_depth),
('subdevice_role', self.subdevice_role),
('airflow', self.airflow),
@ -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)
@ -401,6 +405,7 @@ class Rack(NetBoxModel):
as utilized.
"""
# Determine unoccupied units
total_units = len(list(self.units))
available_units = self.get_available_units()
# Remove reserved units
@ -408,8 +413,8 @@ class Rack(NetBoxModel):
if u in available_units:
available_units.remove(u)
occupied_unit_count = self.u_height - len(available_units)
percentage = float(occupied_unit_count) / self.u_height * 100
occupied_unit_count = total_units - len(available_units)
percentage = float(occupied_unit_count) / total_units * 100
return percentage

View File

@ -1,5 +1,8 @@
import decimal
import svgwrite
from svgwrite.container import Group, Hyperlink
from svgwrite.image import Image
from svgwrite.gradients import LinearGradient
from svgwrite.shapes import Line, Rect
from svgwrite.text import Text
@ -7,6 +10,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
@ -20,11 +24,27 @@ __all__ = (
def get_device_name(device):
if device.virtual_chassis:
return f'{device.virtual_chassis.name}:{device.vc_position}'
name = f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
return device.name
name = device.name
else:
return str(device.device_type)
name = str(device.device_type)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
return name
def get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
)
class RackElevationSVG:
@ -36,13 +56,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
@ -50,21 +74,9 @@ class RackElevationSVG:
permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
@staticmethod
def _get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format(
device.name,
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
device.asset_tag or '',
device.serial or ''
)
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = drawing.linearGradient(
gradient = LinearGradient(
start=(0, 0),
end=(0, 25),
spreadMethod='repeat',
@ -76,195 +88,196 @@ class RackElevationSVG:
gradient.add_stop_color(offset='50%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color=color)
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
with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
# Add the stylesheet
with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
drawing.defs.add(drawing.style(css_file.read()))
# add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
# Add gradients
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
return drawing
def _draw_device_front(self, drawing, device, start, end, text):
def _get_device_coords(self, position, height):
"""
Return the X, Y coordinates of the top left corner for a device in the specified rack unit.
"""
x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH
y = RACK_ELEVATION_BORDER_WIDTH
if self.rack.desc_units:
y += int((position - 1) * self.unit_height)
else:
y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height)
return x, y
def _draw_device(self, device, coords, size, color=None, image=None):
name = get_device_name(device)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
description = get_device_description(device)
text_coords = (
coords[0] + size[0] / 2,
coords[1] + size[1] / 2
)
text_color = f'#{foreground_color(color)}' if color else '#000000'
# Create hyperlink element
link = Hyperlink(
href='{}{}'.format(
self.base_url,
reverse('dcim:device', kwargs={'pk': device.pk})
),
target='_blank',
)
link.set_desc(description)
if color:
link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot'))
else:
link.add(Rect(coords, size, class_='slot blocked'))
link.add(Text(name, insert=text_coords, fill=text_color))
# Embed device type image if provided
if self.include_images and image:
image = Image(
href='{}{}'.format(self.base_url, image.url),
insert=coords,
size=size,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
link.add(Text(name, insert=text_coords, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label'))
self.drawing.add(link)
def draw_device_front(self, device, coords, size):
"""
Draw the front (mounted) face of a device.
"""
color = device.device_role.color
link = drawing.add(
drawing.a(
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
target='_top',
fill='black'
)
image = device.device_type.front_image
self._draw_device(device, coords, size, color=color, image=image)
def draw_device_rear(self, device, coords, size):
"""
Draw the rear (opposite) face of a device.
"""
image = device.device_type.rear_image
self._draw_device(device, coords, size, image=image)
def draw_border(self):
"""
Draw a border around the collection of rack units.
"""
border_width = RACK_ELEVATION_BORDER_WIDTH
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
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'
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(str(name), insert=text, fill=hex_color))
self.drawing.add(frame)
# Embed front device type image if one exists
if self.include_images and device.device_type.front_image:
image = drawing.image(
href='{}{}'.format(self.base_url, device.device_type.front_image.url),
insert=start,
size=end,
class_='device-image'
def draw_legend(self):
"""
Draw the rack unit labels along the lefthand side of the elevation.
"""
for ru in range(0, self.rack.u_height):
start_y = ru * self.unit_height + 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
self.drawing.add(
Text(str(unit), position_coordinates, class_='unit')
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(str(name), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text):
link = drawing.add(
drawing.a(
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
target='_top',
fill='black'
)
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, class_="slot blocked"))
link.add(drawing.text(get_device_name(device), insert=text))
# Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image:
image = drawing.image(
href='{}{}'.format(self.base_url, device.device_type.rear_image.url),
insert=start,
size=end,
class_='device-image'
)
image.fit(scale='slice')
link.add(image)
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
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):
link_url = '{}{}?{}'.format(
self.base_url,
def draw_background(self, face):
"""
Draw the rack unit placeholders which form the "background" of the rack elevation.
"""
x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
url_string = '{}?{}&position={{}}'.format(
reverse('dcim:device_add'),
urlencode({
'site': rack.site.pk,
'location': rack.location.pk if rack.location else '',
'rack': rack.pk,
'face': face_id,
'position': id_
'site': self.rack.site.pk,
'location': self.rack.location.pk if self.rack.location else '',
'rack': self.rack.pk,
'face': face,
})
)
link = drawing.add(
drawing.a(href=link_url, 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'))
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):
"""
Return an SVG document representing a rack 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)
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
drawing.add(
drawing.text(str(unit), position_coordinates, class_="unit")
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
text_coords = (
x_offset + self.unit_width / 2,
y_offset + self.unit_height / 2
)
for unit in self.merge_elevations(face):
link = Hyperlink(href=url_string.format(ru), target='_blank')
link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
link.add(Text('add device', insert=text_coords, class_='add-device'))
self.drawing.add(link)
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
start_cordinates = (x_offset, y_offset)
end_cordinates = (unit_width, end_y)
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
device_coords = self._get_device_coords(unit['id'], height)
device_size = (
self.unit_width,
int(self.unit_height * height)
)
# 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:
if device.face == face and not opposite:
self.draw_device_front(device, device_coords, device_size)
else:
self.draw_device_rear(device, device_coords, device_size)
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(device_coords, device_size, class_='blocked'))
unit_cursor += height
def render(self, face):
"""
Return an SVG document representing a rack elevation.
"""
# 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),
class_='rack'
)
drawing.add(frame)
# Initialize the drawing
self.drawing = self._setup_drawing()
return drawing
# Draw the empty rack & legend
self.draw_legend()
self.draw_background(face)
# 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)
# Draw the rack border last
self.draw_border()
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

@ -5,6 +5,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):
@ -74,148 +75,142 @@ class RackTestCase(TestCase):
def setUp(self):
self.site1 = Site.objects.create(
name='TestSite1',
slug='test-site-1'
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
self.site2 = Site.objects.create(
name='TestSite2',
slug='test-site-2'
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
Location(name='Location 2', slug='location-2', site=sites[1]),
)
self.location1 = Location.objects.create(
name='TestGroup1',
slug='test-group-1',
site=self.site1
)
self.location2 = Location.objects.create(
name='TestGroup2',
slug='test-group-2',
site=self.site2
)
self.rack = Rack.objects.create(
name='TestRack1',
for location in locations:
location.save()
Rack.objects.create(
name='Rack 1',
facility_id='A101',
site=self.site1,
location=self.location1,
site=sites[0],
location=locations[0],
u_height=42
)
self.manufacturer = Manufacturer.objects.create(
name='Acme',
slug='acme'
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5),
)
DeviceType.objects.bulk_create(device_types)
self.device_type = {
'ff2048': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='FrameForwarder 2048',
slug='ff2048'
),
'cc5000': DeviceType.objects.create(
manufacturer=self.manufacturer,
model='CurrentCatapult 5000',
slug='cc5000',
u_height=0
),
}
self.role = {
'Server': DeviceRole.objects.create(
name='Server',
slug='server',
),
'Switch': DeviceRole.objects.create(
name='Switch',
slug='switch',
),
'Console Server': DeviceRole.objects.create(
name='Console Server',
slug='console-server',
),
'PDU': DeviceRole.objects.create(
name='PDU',
slug='pdu',
),
}
DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
def test_rack_device_outside_height(self):
rack1 = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42
)
rack1.save()
site = Site.objects.first()
rack = Rack.objects.first()
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
site=self.site1,
rack=rack1,
name='Device 1',
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
site=site,
rack=rack,
position=43,
face=DeviceFaceChoices.FACE_FRONT,
)
device1.save()
with self.assertRaises(ValidationError):
rack1.clean()
rack.clean()
def test_location_site(self):
site1 = Site.objects.get(name='Site 1')
location2 = Location.objects.get(name='Location 2')
rack_invalid_location = Rack(
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42,
location=self.location2
rack2 = Rack(
name='Rack 2',
site=site1,
location=location2,
u_height=42
)
rack_invalid_location.save()
rack2.save()
with self.assertRaises(ValidationError):
rack_invalid_location.clean()
rack2.clean()
def test_mount_single_device(self):
site = Site.objects.first()
rack = Rack.objects.first()
device1 = Device(
name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
device_role=DeviceRole.objects.get(slug='switch'),
site=self.site1,
rack=self.rack,
position=10,
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
site=site,
rack=rack,
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(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 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 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):
pdu = Device.objects.create(
name='TestPDU',
device_role=self.role.get('PDU'),
device_type=self.device_type.get('cc5000'),
site=self.site1,
rack=self.rack,
position=None,
face='',
)
self.assertTrue(pdu)
"""
Check that a 0RU device can be mounted in a rack with no face/position.
"""
site = Site.objects.first()
rack = Rack.objects.first()
Device(
name='Device 1',
device_role=DeviceRole.objects.first(),
device_type=DeviceType.objects.first(),
site=site,
rack=rack
).save()
def test_mount_half_u_devices(self):
"""
Check that two 0.5U devices can be mounted in the same rack unit.
"""
rack = Rack.objects.first()
attrs = {
'device_type': DeviceType.objects.get(u_height=0.5),
'device_role': DeviceRole.objects.first(),
'site': Site.objects.first(),
'rack': rack,
'face': DeviceFaceChoices.FACE_FRONT,
}
Device(name='Device 1', position=1, **attrs).save()
Device(name='Device 2', position=1.5, **attrs).save()
self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3)
def test_change_rack_site(self):
"""
@ -224,19 +219,16 @@ class RackTestCase(TestCase):
site_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
# Create Rack1 in Site A
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
# Create Device1 in Rack1
device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role)
device1 = Device.objects.create(
site=site_a,
rack=rack1,
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first()
)
# Move Rack1 to Site B
rack1.site = site_b

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.