initial cleanup of rack elevations

This commit is contained in:
John Anderson 2019-12-10 03:18:10 -05:00
parent 20c8abe8da
commit 1ec191db92
3 changed files with 263 additions and 190 deletions

View File

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

View File

@ -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,6 +201,59 @@ 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'
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): class RackElevationViewSet(ViewSet):
queryset = Rack.objects.prefetch_related( queryset = Rack.objects.prefetch_related(
@ -211,123 +263,9 @@ class RackElevationViewSet(ViewSet):
def get_view_name(self): def get_view_name(self):
return "Rack Elevations" 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): def retrieve(self, request, pk=None):
""" pass
Render rack
"""
rack = get_object_or_404(Rack, pk=pk)
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')
# #

View File

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