From 84f056171286d18c1c14a2fc9d28155a7dcf169a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 9 Jun 2022 17:27:58 -0400 Subject: [PATCH 1/7] Initial work on half-height RUs --- docs/release-notes/version-3.3.md | 11 ++ netbox/dcim/api/serializers.py | 24 ++- netbox/dcim/forms/models.py | 2 +- .../migrations/0154_half_height_rack_units.py | 23 +++ netbox/dcim/models/devices.py | 12 +- netbox/dcim/models/racks.py | 56 +++--- netbox/dcim/svg.py | 175 ++++++++++-------- netbox/dcim/tests/test_api.py | 8 +- netbox/dcim/tests/test_models.py | 31 +++- netbox/utilities/forms/utils.py | 1 - netbox/utilities/utils.py | 16 ++ 11 files changed, 232 insertions(+), 127 deletions(-) create mode 100644 netbox/dcim/migrations/0154_half_height_rack_units.py diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 514a92e88..229509b9c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7fcab6ba3..ba7f661b5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 179893219..fe461b061 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -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( diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py new file mode 100644 index 000000000..dd21fddcf --- /dev/null +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -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)]), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e88af2d05..14147f388 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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' ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 81d699b11..f963fb396 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -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) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 1de68ec36..dfb788e38 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -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 diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 22537abe0..a6631208b 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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): """ diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 8566f969b..eefef3fb4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -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): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 9a4b011e0..a6f037e0b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -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 diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 2b939471c..6a1b560e1 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -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. From 0c915f7de9612c7485da3713cc6d63f368698a5d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 14:54:18 -0400 Subject: [PATCH 2/7] Clean up rack elevation rendering --- netbox/dcim/svg.py | 167 ++++++++++++++++++++++----------------------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index dfb788e38..95db476ad 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -94,18 +94,34 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') return drawing - def _draw_device_front(self, drawing, device, start, end, text): + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device_front(self, drawing, device, start, size): + text_coords = ( + start[0] + size[0] / 2, + start[1] + size[1] / 2 + ) name = get_device_name(device) if device.devicebay_count: name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - color = device.device_role.color + link = drawing.add( drawing.a( href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), @@ -114,25 +130,30 @@ class RackElevationSVG: ) ) link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) + link.add(drawing.rect(start, size, style='fill: #{}'.format(color), class_='slot')) hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) + link.add(drawing.text(str(name), insert=text_coords, 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, + size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(drawing.text(str(name), insert=text, stroke='black', + link.add(drawing.text(str(name), insert=text_coords, 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')) + link.add(drawing.text(str(name), insert=text_coords, fill='white', class_='device-image-label')) + + def _draw_device_rear(self, drawing, device, start, size): + text_coords = ( + start[0] + size[0] / 2, + start[1] + size[1] / 2 + ) - 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})), @@ -141,57 +162,76 @@ class RackElevationSVG: ) ) 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)) + link.add(drawing.rect(start, size, class_="slot blocked")) + link.add(drawing.text(get_device_name(device), insert=text_coords)) # 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, + size=size, class_='device-image' ) image.fit(scale='slice') link.add(image) - link.add(drawing.text(get_device_name(device), insert=text, stroke='black', + link.add(Text(get_device_name(device), insert=text_coords, 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')) + link.add(Text(get_device_name(device), insert=text_coords, fill='white', class_='device-image-label')) - def _draw_empty(self, drawing, rack, start, end, text, unit, face_id, class_, reservation): - link_url = '{}{}?{}'.format( - self.base_url, - reverse('dcim:device_add'), - urlencode({ - 'site': rack.site.pk, - 'location': rack.location.pk if rack.location else '', - 'rack': rack.pk, - 'face': face_id, - 'position': unit - }) + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' ) - link = 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')) + 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 + 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") + 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'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height + text_coords = ( + x_offset + self.unit_width / 2, + y_offset + self.unit_height / 2 + ) + + 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(self.drawing.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. @@ -202,54 +242,21 @@ class RackElevationSVG: device = unit['device'] height = unit.get('height', decimal.Decimal(1.0)) - # Setup drawing coordinates - 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 - + start_cordinates = self._get_device_coords(unit['id'], height) end_y = int(self.unit_height * height) - start_cordinates = (x_offset, y_offset) 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.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) + self._draw_device_front(self.drawing, device, start_cordinates, size) else: - self._draw_device_rear(self.drawing, device, start_cordinates, size, text_cordinates) + self._draw_device_rear(self.drawing, device, start_cordinates, size) elif device: # Devices which the user does not have permission to view are rendered only as unavailable space self.drawing.add(Rect(start_cordinates, size, class_='blocked')) - # 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. @@ -258,10 +265,9 @@ class RackElevationSVG: # Initialize the drawing self.drawing = self._setup_drawing() - # reserved_units = self.rack.get_reserved_units() - - # Draw the unit legend + # 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: @@ -271,15 +277,8 @@ class RackElevationSVG: # 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 = 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) + # Draw the rack border last + self.draw_border() return self.drawing From ae129485583afe89c9bfa51d301f10de3a96a37d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 16:00:39 -0400 Subject: [PATCH 3/7] Refactor device rendering methods --- netbox/dcim/svg.py | 153 ++++++++++++++++++++++----------------------- 1 file changed, 75 insertions(+), 78 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 95db476ad..0986e94d3 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -1,6 +1,8 @@ import decimal import svgwrite from svgwrite.container import Group, Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient from svgwrite.shapes import Line, Rect from svgwrite.text import Text @@ -22,11 +24,27 @@ __all__ = ( def get_device_name(device): if device.virtual_chassis: - return f'{device.virtual_chassis.name}:{device.vc_position}' + name = f'{device.virtual_chassis.name}:{device.vc_position}' elif device.name: - return device.name + name = device.name else: - return str(device.device_type) + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) โ€” {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) class RackElevationSVG: @@ -56,21 +74,9 @@ class RackElevationSVG: permitted_devices = permitted_devices.restrict(user, 'view') self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - @staticmethod - def _get_device_description(device): - return '{} ({}) โ€” {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - @staticmethod def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( + gradient = LinearGradient( start=(0, 0), end=(0, 25), spreadMethod='repeat', @@ -82,6 +88,7 @@ class RackElevationSVG: gradient.add_stop_color(offset='50%', color='#f7f7f7') gradient.add_stop_color(offset='50%', color=color) gradient.add_stop_color(offset='100%', color=color) + drawing.defs.add(gradient) def _setup_drawing(self): @@ -90,7 +97,7 @@ class RackElevationSVG: drawing = svgwrite.Drawing(size=(width, height)) # 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())) # Add gradients @@ -112,72 +119,60 @@ class RackElevationSVG: return x, y - def _draw_device_front(self, drawing, device, start, size): - text_coords = ( - start[0] + size[0] / 2, - start[1] + size[1] / 2 - ) + def _draw_device(self, device, coords, size, color=None, image=None): name = get_device_name(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - color = device.device_role.color - - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, size, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text_coords, 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=size, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(str(name), insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text_coords, fill='white', class_='device-image-label')) - - def _draw_device_rear(self, drawing, device, start, size): + description = get_device_description(device) text_coords = ( - start[0] + size[0] / 2, - start[1] + size[1] / 2 + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 ) + text_color = f'#{foreground_color(color)}' if color else '#000000' - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, size, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text_coords)) + 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 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, + # 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(get_device_name(device), insert=text_coords, stroke='black', + link.add(Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(get_device_name(device), insert=text_coords, fill='white', 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): """ @@ -228,7 +223,7 @@ class RackElevationSVG: 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(self.drawing.text('add device', insert=text_coords, class_='add-device')) + link.add(Text('add device', insert=text_coords, class_='add-device')) self.drawing.add(link) @@ -242,20 +237,22 @@ class RackElevationSVG: device = unit['device'] height = unit.get('height', decimal.Decimal(1.0)) - start_cordinates = self._get_device_coords(unit['id'], height) - end_y = int(self.unit_height * height) - size = (self.unit_width, end_y) + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) # Draw the device if device and device.pk in self.permitted_device_ids: if device.face == face and not opposite: - self._draw_device_front(self.drawing, device, start_cordinates, size) + self.draw_device_front(device, device_coords, device_size) else: - self._draw_device_rear(self.drawing, device, start_cordinates, size) + self.draw_device_rear(device, device_coords, device_size) elif device: # Devices which the user does not have permission to view are rendered only as unavailable space - self.drawing.add(Rect(start_cordinates, size, class_='blocked')) + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) def render(self, face): """ From 278891c262a49ef29079e2b4af186af265567555 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 16:14:51 -0400 Subject: [PATCH 4/7] Fix rack utilization calculation --- netbox/dcim/models/racks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index f963fb396..12cc4dd38 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -405,6 +405,7 @@ class Rack(NetBoxModel): as utilized. """ # Determine unoccupied units + total_units = len(list(self.units)) available_units = self.get_available_units() # Remove reserved units @@ -412,8 +413,8 @@ class Rack(NetBoxModel): if u in available_units: available_units.remove(u) - occupied_unit_count = self.u_height - len(available_units) - percentage = float(occupied_unit_count) / self.u_height * 100 + occupied_unit_count = total_units - len(available_units) + percentage = float(occupied_unit_count) / total_units * 100 return percentage From 0d6d68c62fac1072b78ebfd91863b5405beb61a6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Sun, 19 Jun 2022 21:28:57 -0400 Subject: [PATCH 5/7] Fix YAML representation of decimal values --- netbox/dcim/models/devices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 14147f388..43b84974b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -168,7 +168,7 @@ class DeviceType(NetBoxModel): ('model', self.model), ('slug', self.slug), ('part_number', self.part_number), - ('u_height', self.u_height), + ('u_height', float(self.u_height)), ('is_full_depth', self.is_full_depth), ('subdevice_role', self.subdevice_role), ('airflow', self.airflow), From 4ced0bed13a86b2f87788ed104944c5c1752e737 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 12:37:56 -0400 Subject: [PATCH 6/7] Clean up rack model tests --- netbox/dcim/tests/test_models.py | 166 ++++++++++++------------------- 1 file changed, 63 insertions(+), 103 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index eefef3fb4..da54fc98d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,5 +1,3 @@ -import decimal - from django.core.exceptions import ValidationError from django.test import TestCase @@ -77,126 +75,90 @@ class RackTestCase(TestCase): def setUp(self): - self.site1 = Site.objects.create( - name='TestSite1', - slug='test-site-1' + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.site2 = Site.objects.create( - name='TestSite2', - slug='test-site-2' + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - self.location1 = Location.objects.create( - name='TestGroup1', - slug='test-group-1', - site=self.site1 - ) - self.location2 = Location.objects.create( - name='TestGroup2', - slug='test-group-2', - site=self.site2 - ) - self.rack = Rack.objects.create( - name='TestRack1', + for location in locations: + location.save() + + Rack.objects.create( + name='Rack 1', facility_id='A101', - site=self.site1, - location=self.location1, + site=sites[0], + location=locations[0], u_height=42 ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), ) + DeviceType.objects.bulk_create(device_types) - self.device_type = { - 'ff2048': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' - ), - 'cc5000': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='CurrentCatapult 5000', - slug='cc5000', - u_height=0 - ), - } - self.role = { - 'Server': DeviceRole.objects.create( - name='Server', - slug='server', - ), - 'Switch': DeviceRole.objects.create( - name='Switch', - slug='switch', - ), - 'Console Server': DeviceRole.objects.create( - name='Console Server', - slug='console-server', - ), - 'PDU': DeviceRole.objects.create( - name='PDU', - slug='pdu', - ), - - } + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') def test_rack_device_outside_height(self): - - rack1 = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42 - ) - rack1.save() + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( - name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=rack1, + name='Device 1', + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=43, face=DeviceFaceChoices.FACE_FRONT, ) device1.save() with self.assertRaises(ValidationError): - rack1.clean() + rack.clean() def test_location_site(self): + site1 = Site.objects.get(name='Site 1') + location2 = Location.objects.get(name='Location 2') - rack_invalid_location = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42, - location=self.location2 + rack2 = Rack( + name='Rack 2', + site=site1, + location=location2, + u_height=42 ) - rack_invalid_location.save() + rack2.save() with self.assertRaises(ValidationError): - rack_invalid_location.clean() + rack2.clean() def test_mount_single_device(self): + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=self.rack, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(drange(42.5, 0.5, -0.5))) + self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) rack1_inventory_front = { - u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) } self.assertEqual(rack1_inventory_front[10.0]['device'], device1) self.assertEqual(rack1_inventory_front[10.5]['device'], device1) @@ -207,7 +169,7 @@ class RackTestCase(TestCase): # Validate inventory (rear face) rack1_inventory_rear = { - u['id']: u for u in self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) } self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) @@ -217,16 +179,17 @@ class RackTestCase(TestCase): self.assertIsNone(u['device']) def test_mount_zero_ru(self): - pdu = Device.objects.create( + site = Site.objects.first() + rack = Rack.objects.first() + + device = Device.objects.create( name='TestPDU', - device_role=self.role.get('PDU'), - device_type=self.device_type.get('cc5000'), - site=self.site1, - rack=self.rack, - position=None, - face='', + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack ) - self.assertTrue(pdu) + self.assertTrue(device) def test_change_rack_site(self): """ @@ -235,19 +198,16 @@ class RackTestCase(TestCase): site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' - ) - device_role = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - # Create Rack1 in Site A rack1 = Rack.objects.create(site=site_a, name='Rack 1') # Create Device1 in Rack1 - device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role) + device1 = Device.objects.create( + site=site_a, + rack=rack1, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first() + ) # Move Rack1 to Site B rack1.site = site_b From 103729c0855aad2f45fcaa2cf680799236f3e201 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 20 Jun 2022 13:57:37 -0400 Subject: [PATCH 7/7] Add test for 0.5U devices --- netbox/dcim/tests/test_models.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index da54fc98d..98d57801d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -100,6 +100,7 @@ class RackTestCase(TestCase): 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) @@ -179,17 +180,37 @@ class RackTestCase(TestCase): self.assertIsNone(u['device']) def test_mount_zero_ru(self): + """ + Check that a 0RU device can be mounted in a rack with no face/position. + """ site = Site.objects.first() rack = Rack.objects.first() - device = Device.objects.create( - name='TestPDU', + Device( + name='Device 1', device_role=DeviceRole.objects.first(), device_type=DeviceType.objects.first(), site=site, rack=rack - ) - self.assertTrue(device) + ).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): """