Просмотр исходного кода

initial cleanup of rack elevations

John Anderson 6 лет назад
Родитель
Сommit
1ec191db92
3 измененных файлов с 263 добавлено и 190 удалено
  1. 4 0
      base_requirements.txt
  2. 56 118
      netbox/dcim/api/views.py
  3. 203 72
      netbox/dcim/models.py

+ 4 - 0
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

+ 56 - 118
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,132 +201,71 @@ 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'
 
-class RackElevationViewSet(ViewSet):
-    queryset = Rack.objects.prefetch_related(
-        'devices'
-    )
+        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.')
 
-    def get_view_name(self):
-        return "Rack Elevations"
+            unit_height = request.GET.get('unit_height', 20)
+            try:
+                unit_height = int(unit_height)
+            except ValueError:
+                return HttpResponseBadRequest('unit_height must be numeric.')
 
-    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()
+            drawing = rack.get_elevation_svg(face, width, unit_height)
 
-    def retrieve(self, request, pk=None):
-        """
-        Render rack
-        """
-        rack = get_object_or_404(Rack, pk=pk)
+            return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
 
-        face_id = request.GET.get('face', '0')
-        if face_id not in ['front', 'rear']:
-            return HttpResponseBadRequest('face should either be "front" or "rear".')
+        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
 
-        width = request.GET.get('u_width', '230')
-        try:
-            width = int(width)
-        except ValueError:
-            return HttpResponseBadRequest('u_width must be numeric.')
+            elevation = rack.get_rack_units(face, exclude)
 
-        u_height = request.GET.get('u_height', '20')
-        try:
-            u_height = int(u_height)
-        except ValueError:
-            return HttpResponseBadRequest('u_height must be numeric.')
+            # 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)
 
-        elevation = self._get_elevation(rack)
 
-        reserved = rack.get_reserved_units().keys()
+class RackElevationViewSet(ViewSet):
+    queryset = Rack.objects.prefetch_related(
+        'devices'
+    )
 
-        drawing = self._draw_elevations(rack, elevation, reserved, face_id, width, u_height)
+    def get_view_name(self):
+        return "Rack Elevations"
+
+    def retrieve(self, request, pk=None):
+        pass
 
-        return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
 
 
 #

+ 203 - 72
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)