From 7f788eaa0646c02371daa4495a2c8a65bbde48a0 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 11 Dec 2019 13:39:10 -0500 Subject: [PATCH] review updates to svg rendering --- docs/release-notes/version-2.7.md | 14 +- netbox/dcim/api/serializers.py | 14 +- netbox/dcim/api/views.py | 6 +- netbox/dcim/choices.py | 11 + netbox/dcim/constants.py | 5 + netbox/dcim/models.py | 192 +++++++++--------- .../templates/dcim/rack_elevation_list.html | 4 +- 7 files changed, 137 insertions(+), 109 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 9fa18ea68..74b258425 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -54,9 +54,19 @@ By default, this endpoint returns a paginated JSON response representing each ra /api/dcim/racks//units/ ``` -All internal use of the rack units endpoint has been updated to use the new rack elevation endpoint. +In order to render the elevation as an SVG, include the `render_format=svg` query parameter in the request. You may also control the width of the elevation drawing in pixels with `unit_width=` and the height of each rack unit with `unit_height=`. The `unit_width` defaults to `230` and the `unit_height` default to `20` which produces elevations the same size as those that appear in the NetBox Web UI. The query parameter `face` is used to request either the `front` or `rear` of the elevation and defaults to `front`. -In order to render the elevation as an SVG, include the `render_format=svg` query parameter in the request. You may also control the width of the elevation drawing in pixels with `width=` and the height of each rack unit with `unit_height=`. The `width` defaults to `230` and the `unit_height` default to `20` which produces elevations the same size as those that appear in the NetBox Web UI. The query parameter `face` is used to request either the `front` or `rear` of the elevation and defaults to `front`. +Here is an example of the request url for an SVG rendering using the default parameters to render the front of the elevation: + +``` +/api/dcim/racks//elevation/?render_format=svg +``` + +Here is an example of the request url for an SVG rendering of the rear of the elevation having a width of 300 pixels and per unit height of 35 pixels: + +``` +/api/dcim/racks//elevation/?render_format=svg&face=rear&unit_width=300&unit_height=35 +``` Thanks to [@hellerve](https://github.com/hellerve) for doing the heavy lifting on this! diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5c51b75e9..4de459198 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -157,7 +157,7 @@ class RackUnitSerializer(serializers.Serializer): """ id = serializers.IntegerField(read_only=True) name = serializers.CharField(read_only=True) - face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True) + face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -172,12 +172,14 @@ class RackReservationSerializer(ValidatedModelSerializer): class RackElevationDetailFilterSerializer(serializers.Serializer): - face = serializers.ChoiceField(choices=['front', 'rear'], default='front') - render_format = serializers.ChoiceField(choices=['json', 'svg'], default='json') - width = serializers.IntegerField(default=230) - unit_height = serializers.IntegerField(default=20) + face = serializers.ChoiceField(choices=DeviceFaceChoices, default=DeviceFaceChoices.FACE_FRONT) + render_format = serializers.ChoiceField( + choices=RackElecationDetailRenderFormatChoices, + default=RackElecationDetailRenderFormatChoices.RENDER_FORMAT_SVG + ) + unit_width = serializers.IntegerField(default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT) + unit_height = serializers.IntegerField(default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT) exclude = serializers.IntegerField(required=False, default=None) - q = serializers.CharField(required=False, default=None) expand_devices = serializers.BooleanField(required=False, default=True) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 13d12bab7..e286026a5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -219,17 +219,17 @@ class RackViewSet(CustomFieldModelViewSet): data = serializer.validated_data if data['render_format'] == 'svg': - drawing = rack.get_elevation_svg(data['face'], data['width'], data['unit_height']) + # Render and return the elevation as an SVG drawing with the correct content type + drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height']) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') else: + # Return a JSON representation of the rack units in the elevation elevation = rack.get_rack_units( face=data['face'], exclude=data['exclude'], expand_devices=data['expand_devices'] ) - if data['q']: - elevation = [u for u in elevation if data['q'] in str(u['id'])] page = self.paginate_queryset(elevation) if page is not None: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index f9265480b..a4276ccb3 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -105,6 +105,17 @@ class RackDimensionUnitChoices(ChoiceSet): } +class RackElecationDetailRenderFormatChoices(ChoiceSet): + + RENDER_FORMAT_JSON = 'json' + RENDER_FORMAT_SVG = 'svg' + + CHOICES = ( + (RENDER_FORMAT_JSON, 'json'), + (RENDER_FORMAT_SVG, 'svg') + ) + + # # DeviceTypes # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index a33682f51..1840925aa 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -110,3 +110,8 @@ text { fill: none; } """ + + +# Rack Elevation SVG Size +RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 +RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f7f56af27..070bfd080 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -517,8 +517,8 @@ class RackElevationHelperMixin: link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) - def _draw_elevations(self, elevation, reserved_units, face, width, unit_height): - drawing = self._setup_drawing(width, unit_height * self.u_height) + def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height): + drawing = self._setup_drawing(unit_width, unit_height * self.u_height) unit_cursor = 0 total_units = len(elevation) @@ -532,8 +532,8 @@ class RackElevationHelperMixin: start_y = unit_cursor * unit_height end_y = unit_height * height start_cordinates = (0, start_y) - end_cordinates = (width, end_y) - text_cordinates = (width / 2, start_y + end_y / 2) + end_cordinates = (unit_width, end_y) + text_cordinates = (unit_width / 2, start_y + end_y / 2) # Draw the device if device and device.face == face: @@ -554,100 +554,11 @@ class RackElevationHelperMixin: unit_cursor += height # Wrap the drawing with a border - drawing.add(drawing.rect((0, 0), (width, self.u_height * unit_height), class_='rack')) + drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack')) return drawing - def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): - """ - Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'} - Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy. - - :param face: Rack face (front or rear) - :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack - :param expand_devices: When True, all units that a device occupies will be listed with each containing a - 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() - for u in self.units: - elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None} - - # Add devices to rack units list - if self.pk: - queryset = Device.objects.prefetch_related( - 'device_type', - 'device_type__manufacturer', - 'device_role' - ).annotate( - devicebay_count=Count('device_bays') - ).exclude( - pk=exclude - ).filter( - rack=self, - position__gt=0 - ).filter( - Q(face=face) | Q(device_type__is_full_depth=True) - ) - for device in queryset: - if expand_devices: - for u in range(device.position, device.position + device.device_type.u_height): - elevation[u]['device'] = device - else: - elevation[device.position]['device'] = device - 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()] - - def get_available_units(self, u_height=1, rack_face=None, exclude=list()): - """ - Return a list of units within the rack available to accommodate a device of a given U height (default 1). - Optionally exclude one or more devices when calculating empty units (needed when moving a device from one - position to another within a rack). - - :param u_height: Minimum number of contiguous free units required - :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth - :param exclude: List of devices IDs to exclude (useful when moving a device within a rack) - """ - - # Gather all devices which consume U space within the rack - devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) - - # Initialize the rack unit skeleton - units = list(range(1, self.u_height + 1)) - - # 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): - try: - units.remove(u) - except ValueError: - # Found overlapping devices in the rack! - pass - - # 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): - available_units.append(u) - - return list(reversed(available_units)) - - def get_reserved_units(self): - """ - 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 - return reserved_units - - def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, width=230, unit_height=20): + def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20): """ Return an SVG of the rack elevation @@ -659,7 +570,7 @@ class RackElevationHelperMixin: elevation = self.get_rack_units(face=face, expand_devices=False) reserved_units = self.get_reserved_units().keys() - return self._draw_elevations(elevation, reserved_units, face, width, unit_height) + return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): @@ -881,6 +792,95 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) + def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): + """ + Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'} + Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy. + + :param face: Rack face (front or rear) + :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack + :param expand_devices: When True, all units that a device occupies will be listed with each containing a + 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() + for u in self.units: + elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None} + + # Add devices to rack units list + if self.pk: + queryset = Device.objects.prefetch_related( + 'device_type', + 'device_type__manufacturer', + 'device_role' + ).annotate( + devicebay_count=Count('device_bays') + ).exclude( + pk=exclude + ).filter( + rack=self, + position__gt=0 + ).filter( + Q(face=face) | Q(device_type__is_full_depth=True) + ) + for device in queryset: + if expand_devices: + for u in range(device.position, device.position + device.device_type.u_height): + elevation[u]['device'] = device + else: + elevation[device.position]['device'] = device + 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()] + + def get_available_units(self, u_height=1, rack_face=None, exclude=list()): + """ + Return a list of units within the rack available to accommodate a device of a given U height (default 1). + Optionally exclude one or more devices when calculating empty units (needed when moving a device from one + position to another within a rack). + + :param u_height: Minimum number of contiguous free units required + :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth + :param exclude: List of devices IDs to exclude (useful when moving a device within a rack) + """ + + # Gather all devices which consume U space within the rack + devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude) + + # Initialize the rack unit skeleton + units = list(range(1, self.u_height + 1)) + + # 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): + try: + units.remove(u) + except ValueError: + # Found overlapping devices in the rack! + pass + + # 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): + available_units.append(u) + + return list(reversed(available_units)) + + def get_reserved_units(self): + """ + 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 + return reserved_units + def get_0u_devices(self): return self.devices.filter(position=0) diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 7fd07311e..de7c55919 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -18,9 +18,9 @@

{{ rack.facility_id|truncatechars:"30" }}

{% if face_id %} - {% include 'dcim/inc/rack_elevation.html' with face=1 %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' %} {% else %} - {% include 'dcim/inc/rack_elevation.html' with face=0 %} + {% include 'dcim/inc/rack_elevation.html' with face='front' %} {% endif %}