mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
Initial work on half-height RUs
This commit is contained in:
parent
ba12db3019
commit
84f0561712
@ -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
|
||||
|
@ -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)
|
||||
|
@ -467,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
position = forms.IntegerField(
|
||||
position = forms.DecimalField(
|
||||
required=False,
|
||||
help_text="The lowest-numbered unit occupied by the device",
|
||||
widget=APISelect(
|
||||
|
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal 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)]),
|
||||
),
|
||||
]
|
@ -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'
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user