From 1ec191db926ae90f12d81515a2e6597dc13abb84 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 10 Dec 2019 03:18:10 -0500 Subject: [PATCH] initial cleanup of rack elevations --- base_requirements.txt | 4 + netbox/dcim/api/views.py | 174 ++++++++----------------- netbox/dcim/models.py | 275 +++++++++++++++++++++++++++++---------- 3 files changed, 263 insertions(+), 190 deletions(-) diff --git a/base_requirements.txt b/base_requirements.txt index 448cbc6bc..ede46f679 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -82,3 +82,7 @@ pycryptodome # In-memory key/value store used for caching and queuing # https://github.com/andymccurdy/redis-py redis + +# Python Package to write SVG files - used for rack elevations +# https://github.com/mozman/svgwrite +svgwrite diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index db4f4f154..58c8a6221 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,11 +1,9 @@ from collections import OrderedDict -import svgwrite from django.conf import settings from django.db.models import Count, F from django.http import HttpResponseForbidden, HttpResponseBadRequest, HttpResponse from django.shortcuts import get_object_or_404, reverse -from django.utils.http import urlencode from drf_yasg import openapi from drf_yasg.openapi import Parameter from drf_yasg.utils import swagger_auto_schema @@ -30,7 +28,8 @@ from ipam.models import Prefix, VLAN from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, ) -from utilities.utils import get_subquery, foreground_color +from utilities.custom_inspectors import NullablePaginatorInspector +from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -202,6 +201,59 @@ class RackViewSet(CustomFieldModelViewSet): rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) return self.get_paginated_response(rack_units.data) + @swagger_auto_schema(responses={200: serializers.RackUnitSerializer(many=True)}) + @action(detail=True) + def elevation(self, request, pk=None): + """ + Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG. + """ + rack = get_object_or_404(Rack, pk=pk) + face = request.GET.get('face') + if face not in ['front', 'rear']: + face = 'front' + + if request.GET.get('render_format', 'json') == 'svg': + # Render the elevantion as an SVG + width = request.GET.get('width', 230) + try: + width = int(width) + except ValueError: + return HttpResponseBadRequest('width must be an integer.') + + unit_height = request.GET.get('unit_height', 20) + try: + unit_height = int(unit_height) + except ValueError: + return HttpResponseBadRequest('unit_height must be numeric.') + + drawing = rack.get_elevation_svg(face, width, unit_height) + + return HttpResponse(drawing.tostring(), content_type='image/svg+xml') + + else: + # Render a JSON response of the elevation + exclude = request.GET.get('exclude', None) + if exclude is not None: + try: + if isinstance(exclude, list): + exclude = [int(item) for item in exclude] + else: + exclude = int(exclude) + except ValueError: + exclude = None + + elevation = rack.get_rack_units(face, exclude) + + # Enable filtering rack units by ID + q = request.GET.get('q', None) + if q: + elevation = [u for u in elevation if q in str(u['id'])] + + page = self.paginate_queryset(elevation) + if page is not None: + rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) + return self.get_paginated_response(rack_units.data) + class RackElevationViewSet(ViewSet): queryset = Rack.objects.prefetch_related( @@ -211,123 +263,9 @@ class RackElevationViewSet(ViewSet): def get_view_name(self): return "Rack Elevations" - def _add_gradient(self, drawing, id_, color): - gradient = drawing.linearGradient(start=('0', '20%'), end=('0', '40%'), spreadMethod='repeat', id_=id_, gradientTransform='rotate(80)') - gradient.add_stop_color(offset='0%', color='#f7f7f7') - 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, width, height): - drawing = svgwrite.Drawing(size=(width, height)) - - # add the stylesheet - drawing.defs.add(drawing.style(constants.RACK_ELEVATION_STYLE)) - - # add gradients - self._add_gradient(drawing, 'reserved', '#c7c7ff') - self._add_gradient(drawing, 'occupied', '#f0f0f0') - self._add_gradient(drawing, 'blocked', '#ffc7c7') - - return drawing - - def _draw_device_front(self, drawing, device, start, end, text): - color = device.device_role.color - link = drawing.add( - drawing.a( - reverse('dcim:device', kwargs={'pk': device.pk}), fill='black' - ) - ) - link.add(drawing.rect(start, end, fill='#{}'.format(color))) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(device.name, insert=text, fill=hex_color)) - - def _draw_device_rear(self, drawing, device, start, end, text): - drawing.add(drawing.rect(start, end, class_="blocked")) - drawing.add(drawing.text(device.name, insert=text)) - - def _draw_empty(self, rack, drawing, start, end, text, id_, face_id, class_): - link = drawing.add( - drawing.a('{}?{}'.format( - reverse('dcim:device_add'), - urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_}) - )) - ) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) - - def _draw_elevations(self, rack, elevation, reserved, face_id, width, u_height): - drawing = self._setup_drawing(width, u_height * rack.u_height) - i = 0 - for u in elevation: - device = u['device'] - height = u['height'] - start_y = i * u_height - end_y = u_height * height - start = (0, start_y) - end = (width, end_y) - text = (width / 2, start_y + end_y / 2) - if device and device.face == face_id: - self._draw_device_front(drawing, device, start, end, text) - elif device and device.device_type.is_full_depth: - self._draw_device_rear(drawing, device, start, end, text) - else: - class_ = 'slot' - if device: - class_ += ' occupied' - if u["id"] in reserved: - class_ += ' reserved' - self._draw_empty( - rack, drawing, start, end, text, u["id"], face_id, class_ - ) - i += height - drawing.add(drawing.rect((0, 0), (width, rack.u_height * u_height), class_='rack')) - return drawing - - def _get_elevation(self, rack): - elevation = OrderedDict() - for u in rack.units: - elevation[u] = {'id': u, 'device': None, 'height': 1} - - for device in Device.objects.prefetch_related('device_role')\ - .filter(rack=rack, position__gt=0): - 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 elevation.values() - def retrieve(self, request, pk=None): - """ - Render rack - """ - rack = get_object_or_404(Rack, pk=pk) + pass - face_id = request.GET.get('face', '0') - if face_id not in ['front', 'rear']: - return HttpResponseBadRequest('face should either be "front" or "rear".') - - width = request.GET.get('u_width', '230') - try: - width = int(width) - except ValueError: - return HttpResponseBadRequest('u_width must be numeric.') - - u_height = request.GET.get('u_height', '20') - try: - u_height = int(u_height) - except ValueError: - return HttpResponseBadRequest('u_height must be numeric.') - - elevation = self._get_elevation(rack) - - reserved = rack.get_reserved_units().keys() - - drawing = self._draw_elevations(rack, elevation, reserved, face_id, width, u_height) - - return HttpResponse(drawing.tostring(), content_type='image/svg+xml') # diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 244e61e54..0ef98d36e 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,6 +1,7 @@ from collections import OrderedDict from itertools import count, groupby +import svgwrite from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation @@ -11,6 +12,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, Sum from django.urls import reverse +from django.utils.http import urlencode from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField @@ -19,7 +21,8 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta from utilities.fields import ColorField from utilities.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel -from utilities.utils import serialize_object, to_meters +from utilities.utils import foreground_color, serialize_object, to_meters + from .choices import * from .constants import * from .exceptions import LoopDetected @@ -451,7 +454,205 @@ class RackRole(ChangeLoggedModel): ) -class Rack(ChangeLoggedModel, CustomFieldModel): +class RackElevationHelperMixin: + """ + Utility class that renders rack elevations. Contains helper methods for rendering elevations as a list of + rack units represented as dictionaries, or an SVG of the elevation. + """ + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = drawing.linearGradient( + start=('0', '20%'), + end=('0', '40%'), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(80)' + ) + gradient.add_stop_color(offset='0%', color='#f7f7f7') + 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) + + @staticmethod + def _setup_drawing(width, height): + drawing = svgwrite.Drawing(size=(width, height)) + + # add the stylesheet + drawing.defs.add(drawing.style(RACK_ELEVATION_STYLE)) + + # add gradients + RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff') + RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#f0f0f0') + RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7') + + return drawing + + @staticmethod + def _draw_device_front(drawing, device, start, end, text): + color = device.device_role.color + link = drawing.add( + drawing.a( + reverse('dcim:device', kwargs={'pk': device.pk}), fill='black' + ) + ) + link.add(drawing.rect(start, end, fill='#{}'.format(color))) + hex_color = '#{}'.format(foreground_color(color)) + link.add(drawing.text(device.name, insert=text, fill=hex_color)) + + @staticmethod + def _draw_device_rear(drawing, device, start, end, text): + drawing.add(drawing.rect(start, end, class_="blocked")) + drawing.add(drawing.text(device.name, insert=text)) + + @staticmethod + def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_): + link = drawing.add( + drawing.a('{}?{}'.format( + reverse('dcim:device_add'), + urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_}) + )) + ) + 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) + + unit_cursor = 0 + total_units = len(elevation) + while unit_cursor < total_units: + # Loop through all units in the elevation + unit = elevation[unit_cursor] + device = unit['device'] + if device: + # Look ahead to get the total device height + height = 0 + look_ahead_unit_cursor = unit_cursor + while elevation[look_ahead_unit_cursor]['device'] == device and look_ahead_unit_cursor < total_units: + height += 1 + look_ahead_unit_cursor += 1 + else: + # Empty unit + height = 1 + + # Setup drawing cordinates + 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) + + # Draw the device + if device and device.face == face: + self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) + elif device and device.device_type.is_full_depth: + self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + else: + # Draw shallow devices, reservations, or empty units + class_ = 'slot' + if device: + class_ += ' occupied' + if unit["id"] in reserved_units: + class_ += ' reserved' + self._draw_empty( + drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_ + ) + + unit_cursor += height + + # Wrap the drawing with a border + drawing.add(drawing.rect((0, 0), (width, self.u_height * unit_height), class_='rack')) + + return drawing + + def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None): + """ + 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 + """ + + 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: + for device in Device.objects.prefetch_related('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 u in range(device.position, device.position + device.device_type.u_height): + elevation[u]['device'] = device + + 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): + """ + Return an SVG of the rack elevation + + :param face: Enum of [front, rear] representing the desired side of the rack elevation to render + :param width: Width in pixles for the rendered drawing + :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total + height of the elevation + """ + elevation = self.get_rack_units(face=face) + reserved_units = self.get_reserved_units().keys() + + return self._draw_elevations(elevation, reserved_units, face, width, unit_height) + + +class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -670,76 +871,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) - def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None): - """ - 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 - """ - - 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: - for device in Device.objects.prefetch_related('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 u in range(device.position, device.position + device.device_type.u_height): - elevation[u]['device'] = device - - 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)