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 ### 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(
@ -166,7 +168,7 @@ class DeviceType(NetBoxModel):
('model', self.model), ('model', self.model),
('slug', self.slug), ('slug', self.slug),
('part_number', self.part_number), ('part_number', self.part_number),
('u_height', self.u_height), ('u_height', float(self.u_height)),
('is_full_depth', self.is_full_depth), ('is_full_depth', self.is_full_depth),
('subdevice_role', self.subdevice_role), ('subdevice_role', self.subdevice_role),
('airflow', self.airflow), ('airflow', self.airflow),
@ -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)
@ -401,6 +405,7 @@ class Rack(NetBoxModel):
as utilized. as utilized.
""" """
# Determine unoccupied units # Determine unoccupied units
total_units = len(list(self.units))
available_units = self.get_available_units() available_units = self.get_available_units()
# Remove reserved units # Remove reserved units
@ -408,8 +413,8 @@ class Rack(NetBoxModel):
if u in available_units: if u in available_units:
available_units.remove(u) available_units.remove(u)
occupied_unit_count = self.u_height - len(available_units) occupied_unit_count = total_units - len(available_units)
percentage = float(occupied_unit_count) / self.u_height * 100 percentage = float(occupied_unit_count) / total_units * 100
return percentage return percentage

View File

@ -1,5 +1,8 @@
import decimal
import svgwrite import svgwrite
from svgwrite.container import Group, Hyperlink from svgwrite.container import Group, Hyperlink
from svgwrite.image import Image
from svgwrite.gradients import LinearGradient
from svgwrite.shapes import Line, Rect from svgwrite.shapes import Line, Rect
from svgwrite.text import Text from svgwrite.text import Text
@ -7,6 +10,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
@ -20,11 +24,27 @@ __all__ = (
def get_device_name(device): def get_device_name(device):
if device.virtual_chassis: 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: elif device.name:
return device.name name = device.name
else: 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: 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 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
@ -50,21 +74,9 @@ class RackElevationSVG:
permitted_devices = permitted_devices.restrict(user, 'view') permitted_devices = permitted_devices.restrict(user, 'view')
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) 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 @staticmethod
def _add_gradient(drawing, id_, color): def _add_gradient(drawing, id_, color):
gradient = drawing.linearGradient( gradient = LinearGradient(
start=(0, 0), start=(0, 0),
end=(0, 25), end=(0, 25),
spreadMethod='repeat', spreadMethod='repeat',
@ -76,195 +88,196 @@ class RackElevationSVG:
gradient.add_stop_color(offset='50%', color='#f7f7f7') gradient.add_stop_color(offset='50%', color='#f7f7f7')
gradient.add_stop_color(offset='50%', color=color) gradient.add_stop_color(offset='50%', color=color)
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(f'{settings.STATIC_ROOT}/rack_elevation.css') 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, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
return drawing 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) name = get_device_name(device)
if device.devicebay_count: description = get_device_description(device)
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) text_coords = (
coords[0] + size[0] / 2,
coords[1] + size[1] / 2
)
text_color = f'#{foreground_color(color)}' if color else '#000000'
color = device.device_role.color # Create hyperlink element
link = drawing.add( link = Hyperlink(
drawing.a( href='{}{}'.format(
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, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(str(name), insert=text, fill=hex_color))
# 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'
)
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, 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
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'
)
self.drawing.add(frame)
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')
)
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'), reverse('dcim:device_add'),
urlencode({ urlencode({
'site': rack.site.pk, 'site': self.rack.site.pk,
'location': rack.location.pk if rack.location else '', 'location': self.rack.location.pk if self.rack.location else '',
'rack': rack.pk, 'rack': self.rack.pk,
'face': face_id, 'face': face,
'position': id_
}) })
) )
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): for ru in range(0, self.rack.u_height):
start_y = ru * unit_height y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) text_coords = (
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru x_offset + self.unit_width / 2,
drawing.add( y_offset + self.unit_height / 2
drawing.text(str(unit), position_coordinates, class_="unit")
) )
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 # 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 device_coords = self._get_device_coords(unit['id'], height)
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH device_size = (
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH self.unit_width,
end_y = unit_height * height 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)
# 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) if device.face == face and not opposite:
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: self.draw_device_front(device, device_coords, device_size)
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) else:
self.draw_device_rear(device, device_coords, device_size)
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(device_coords, device_size, class_='blocked'))
def render(self, face):
"""
Return an SVG document representing a rack elevation.
"""
# Initialize the drawing
self.drawing = self._setup_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: else:
# Draw shallow devices, reservations, or empty units opposite_face = DeviceFaceChoices.FACE_REAR
class_ = 'slot' # self.draw_face(opposite_face, opposite=True)
reservation = reserved_units.get(unit["id"]) self.draw_face(face)
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 # Draw the rack border last
self.draw_border()
# Wrap the drawing with a border return self.drawing
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)
return 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

@ -5,6 +5,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):
@ -74,148 +75,142 @@ class RackTestCase(TestCase):
def setUp(self): def setUp(self):
self.site1 = Site.objects.create( sites = (
name='TestSite1', Site(name='Site 1', slug='site-1'),
slug='test-site-1' Site(name='Site 2', slug='site-2'),
) )
self.site2 = Site.objects.create( Site.objects.bulk_create(sites)
name='TestSite2',
slug='test-site-2' 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( for location in locations:
name='TestGroup1', location.save()
slug='test-group-1',
site=self.site1 Rack.objects.create(
) name='Rack 1',
self.location2 = Location.objects.create(
name='TestGroup2',
slug='test-group-2',
site=self.site2
)
self.rack = Rack.objects.create(
name='TestRack1',
facility_id='A101', facility_id='A101',
site=self.site1, site=sites[0],
location=self.location1, location=locations[0],
u_height=42 u_height=42
) )
self.manufacturer = Manufacturer.objects.create(
name='Acme', manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
slug='acme' 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 = { DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
'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',
),
}
def test_rack_device_outside_height(self): def test_rack_device_outside_height(self):
site = Site.objects.first()
rack1 = Rack( rack = Rack.objects.first()
name='TestRack2',
facility_id='A102',
site=self.site1,
u_height=42
)
rack1.save()
device1 = Device( device1 = Device(
name='TestSwitch1', name='Device 1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.get(slug='switch'), device_role=DeviceRole.objects.first(),
site=self.site1, site=site,
rack=rack1, rack=rack,
position=43, position=43,
face=DeviceFaceChoices.FACE_FRONT, face=DeviceFaceChoices.FACE_FRONT,
) )
device1.save() device1.save()
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
rack1.clean() rack.clean()
def test_location_site(self): def test_location_site(self):
site1 = Site.objects.get(name='Site 1')
location2 = Location.objects.get(name='Location 2')
rack_invalid_location = Rack( rack2 = Rack(
name='TestRack2', name='Rack 2',
facility_id='A102', site=site1,
site=self.site1, location=location2,
u_height=42, u_height=42
location=self.location2
) )
rack_invalid_location.save() rack2.save()
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
rack_invalid_location.clean() rack2.clean()
def test_mount_single_device(self): def test_mount_single_device(self):
site = Site.objects.first()
rack = Rack.objects.first()
device1 = Device( device1 = Device(
name='TestSwitch1', name='TestSwitch1',
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.get(slug='switch'), device_role=DeviceRole.objects.first(),
site=self.site1, site=site,
rack=self.rack, rack=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(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 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 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):
pdu = Device.objects.create( """
name='TestPDU', Check that a 0RU device can be mounted in a rack with no face/position.
device_role=self.role.get('PDU'), """
device_type=self.device_type.get('cc5000'), site = Site.objects.first()
site=self.site1, rack = Rack.objects.first()
rack=self.rack,
position=None, Device(
face='', name='Device 1',
) device_role=DeviceRole.objects.first(),
self.assertTrue(pdu) 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): 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_a = Site.objects.create(name='Site A', slug='site-a')
site_b = Site.objects.create(name='Site B', slug='site-b') 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 # Create Rack1 in Site A
rack1 = Rack.objects.create(site=site_a, name='Rack 1') rack1 = Rack.objects.create(site=site_a, name='Rack 1')
# Create Device1 in Rack1 # 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 # Move Rack1 to Site B
rack1.site = site_b rack1.site = site_b

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.