Преглед изворни кода

initial cleanup of rack elevations

John Anderson пре 6 година
родитељ
комит
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
 # In-memory key/value store used for caching and queuing
 # https://github.com/andymccurdy/redis-py
 # https://github.com/andymccurdy/redis-py
 redis
 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
 from collections import OrderedDict
 
 
-import svgwrite
 from django.conf import settings
 from django.conf import settings
 from django.db.models import Count, F
 from django.db.models import Count, F
 from django.http import HttpResponseForbidden, HttpResponseBadRequest, HttpResponse
 from django.http import HttpResponseForbidden, HttpResponseBadRequest, HttpResponse
 from django.shortcuts import get_object_or_404, reverse
 from django.shortcuts import get_object_or_404, reverse
-from django.utils.http import urlencode
 from drf_yasg import openapi
 from drf_yasg import openapi
 from drf_yasg.openapi import Parameter
 from drf_yasg.openapi import Parameter
 from drf_yasg.utils import swagger_auto_schema
 from drf_yasg.utils import swagger_auto_schema
@@ -30,7 +28,8 @@ from ipam.models import Prefix, VLAN
 from utilities.api import (
 from utilities.api import (
     get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
     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 virtualization.models import VirtualMachine
 from . import serializers
 from . import serializers
 from .exceptions import MissingFilterException
 from .exceptions import MissingFilterException
@@ -202,132 +201,71 @@ class RackViewSet(CustomFieldModelViewSet):
             rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
             rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
             return self.get_paginated_response(rack_units.data)
             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 collections import OrderedDict
 from itertools import count, groupby
 from itertools import count, groupby
 
 
+import svgwrite
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 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 import models
 from django.db.models import Count, Q, Sum
 from django.db.models import Count, Q, Sum
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.http import urlencode
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
@@ -19,7 +21,8 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta
 from utilities.fields import ColorField
 from utilities.fields import ColorField
 from utilities.managers import NaturalOrderingManager
 from utilities.managers import NaturalOrderingManager
 from utilities.models import ChangeLoggedModel
 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 .choices import *
 from .constants import *
 from .constants import *
 from .exceptions import LoopDetected
 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.
     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.
     Each Rack is assigned to a Site and (optionally) a RackGroup.
@@ -670,76 +871,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
     def get_status_class(self):
     def get_status_class(self):
         return self.STATUS_CLASS_MAP.get(self.status)
         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):
     def get_0u_devices(self):
         return self.devices.filter(position=0)
         return self.devices.filter(position=0)