mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Merge pull request #9571 from netbox-community/51-half-height-rack-units
Closes #51: Half height rack units
This commit is contained in:
commit
7decad1ff3
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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(
|
||||||
|
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,
|
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'
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
Loading…
Reference in New Issue
Block a user