Merge pull request #3754 from netbox-community/2248-svg-rack-elevations

2248 svg rack elevations
This commit is contained in:
John Anderson
2019-12-11 14:26:32 -05:00
committed by GitHub
15 changed files with 318 additions and 264 deletions

View File

@@ -157,7 +157,7 @@ class RackUnitSerializer(serializers.Serializer):
"""
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(read_only=True)
face = serializers.IntegerField(read_only=True)
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
device = NestedDeviceSerializer(read_only=True)
@@ -171,6 +171,18 @@ class RackReservationSerializer(ValidatedModelSerializer):
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
class RackElevationDetailFilterSerializer(serializers.Serializer):
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)
expand_devices = serializers.BooleanField(required=False, default=True)
#
# Device types
#

View File

@@ -2,8 +2,8 @@ from collections import OrderedDict
from django.conf import settings
from django.db.models import Count, F
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from django.http import HttpResponseForbidden, HttpResponseBadRequest, HttpResponse
from django.shortcuts import get_object_or_404, reverse
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
from drf_yasg.utils import swagger_auto_schema
@@ -13,7 +13,7 @@ from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ViewSet
from circuits.models import Circuit
from dcim import filters
from dcim import constants, filters
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -28,6 +28,7 @@ from ipam.models import Prefix, VLAN
from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
)
from utilities.custom_inspectors import NullablePaginatorInspector
from utilities.utils import get_subquery
from virtualization.models import VirtualMachine
from . import serializers
@@ -175,13 +176,15 @@ class RackViewSet(CustomFieldModelViewSet):
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilter
@swagger_auto_schema(deprecated=True)
@action(detail=True)
def units(self, request, pk=None):
"""
List rack units (by rack)
"""
# TODO: Remove this action detail route in v2.8
rack = get_object_or_404(Rack, pk=pk)
face = request.GET.get('face', 0)
face = request.GET.get('face', 'front')
exclude_pk = request.GET.get('exclude', None)
if exclude_pk is not None:
try:
@@ -200,6 +203,39 @@ 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)},
query_serializer=serializers.RackElevationDetailFilterSerializer
)
@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)
serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET)
if not serializer.is_valid():
return Response(serializer.errors, 400)
data = serializer.validated_data
if data['render_format'] == 'svg':
# 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']
)
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)
#
# Rack reservations

View File

@@ -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
#

View File

@@ -55,3 +55,63 @@ COMPATIBLE_TERMINATION_TYPES = {
'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
'circuittermination': ['interface', 'frontport', 'rearport'],
}
RACK_ELEVATION_STYLE = """
* {
font-family: 'Helvetica Neue';
font-size: 13px;
}
rect {
box-sizing: border-box;
}
text {
text-anchor: middle;
dominant-baseline: middle;
}
.rack {
background-color: #f0f0f0;
fill: none;
stroke: black;
stroke-width: 3px;
}
.slot {
fill: #f7f7f7;
stroke: #a0a0a0;
}
.slot:hover {
fill: #fff;
}
.slot+.add-device {
fill: none;
}
.slot:hover+.add-device {
fill: blue;
}
.reserved {
fill: url(#reserved);
}
.reserved:hover {
fill: url(#reserved);
}
.occupied {
fill: url(#occupied);
}
.occupied:hover {
fill: url(#occupied);
}
.blocked {
fill: url(#blocked);
}
.blocked:hover {
fill: url(#blocked);
}
.blocked:hover+.add-device {
fill: none;
}
"""
# Rack Elevation SVG Size
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20

View File

@@ -1475,7 +1475,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
empty_value=None,
help_text="The lowest-numbered unit occupied by the device",
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/units/',
api_url='/api/dcim/racks/{{rack}}/elevation/',
disabled_indicator='device'
)
)

View File

@@ -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
@@ -458,7 +461,126 @@ 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, unit_width, unit_height):
drawing = self._setup_drawing(unit_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']
height = unit.get('height', 1)
# Setup drawing cordinates
start_y = unit_cursor * unit_height
end_y = unit_height * height
start_cordinates = (0, start_y)
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:
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), (unit_width, self.u_height * unit_height), class_='rack'))
return drawing
def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_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, expand_devices=False)
reserved_units = self.get_reserved_units().keys()
return self._draw_elevations(elevation, reserved_units, face, unit_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.
@@ -677,14 +799,16 @@ 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, remove_redundant=False):
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 remove_redundant: If True, rack units occupied by a device already listed will be omitted
: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()
@@ -693,27 +817,32 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
# 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)):
if remove_redundant:
elevation[device.position]['device'] = device
for u in range(device.position + 1, device.position + device.device_type.u_height):
elevation.pop(u, None)
else:
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_front_elevation(self):
return self.get_rack_units(face=DeviceFaceChoices.FACE_FRONT, remove_redundant=True)
def get_rear_elevation(self):
return self.get_rack_units(face=DeviceFaceChoices.FACE_REAR, remove_redundant=True)
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).

View File

@@ -126,14 +126,14 @@ class RackTestCase(TestCase):
self.assertEqual(list(self.rack.units), list(reversed(range(1, 43))))
# Validate inventory (front face)
rack1_inventory_front = self.rack.get_front_elevation()
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:
self.assertIsNone(u['device'])
# Validate inventory (rear face)
rack1_inventory_rear = self.rack.get_rear_elevation()
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:

View File

@@ -419,8 +419,6 @@ class RackView(PermissionRequiredMixin, View):
'nonracked_devices': nonracked_devices,
'next_rack': next_rack,
'prev_rack': prev_rack,
'front_elevation': rack.get_front_elevation(),
'rear_elevation': rack.get_rear_elevation(),
})