From d11de6d021624208e3e913f220cc8c8404c43ac6 Mon Sep 17 00:00:00 2001 From: hellerve Date: Mon, 18 Nov 2019 15:42:42 +0100 Subject: [PATCH 01/18] dcim: add rack-elevations api endpoint (references #2248) --- netbox/dcim/api/urls.py | 1 + netbox/dcim/api/views.py | 46 ++++++++++++++++++- netbox/dcim/views.py | 2 - netbox/templates/dcim/inc/rack_elevation.html | 43 +---------------- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index fd55d9b05..7169fcdbb 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -25,6 +25,7 @@ router.register(r'sites', views.SiteViewSet) router.register(r'rack-groups', views.RackGroupViewSet) router.register(r'rack-roles', views.RackRoleViewSet) router.register(r'racks', views.RackViewSet) +router.register(r'rack-elevations', views.RackElevationViewSet, basename='rack-elevation') router.register(r'rack-reservations', views.RackReservationViewSet) # Device types diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index bffe4b9e4..7eb40b526 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,9 +1,11 @@ from collections import OrderedDict +import svgwrite 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, 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 @@ -201,6 +203,46 @@ class RackViewSet(CustomFieldModelViewSet): return self.get_paginated_response(rack_units.data) +class RackElevationViewSet(ViewSet): + queryset = Rack.objects.prefetch_related( + 'devices' + ) + + def get_view_name(self): + return "Rack Elevations" + + + def retrieve(self, request, pk=None): + """ + Render rack + """ + rack = get_object_or_404(Rack, pk=pk) + + elevation = rack.get_front_elevation() + drawing = svgwrite.Drawing(size=(230, len(elevation)*20), style="box-sizing: border-box") + + for i, u in enumerate(elevation): + device = u['device'] + start = i * 20 + end = 20 + if device: + link = drawing.add(drawing.a(reverse('dcim:device', kwargs={'pk': device.pk}), fill='black')) + link.add(drawing.rect((0, start), (230, end), fill='#{}'.format(device.device_role.color), stroke='grey')) + link.add(drawing.text(device.name, insert=(0, start+20))) + else: + link = drawing.add( + drawing.a('{}?{}'.format( + reverse('dcim:device_add'), + urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': 0, 'position': u['id']}) + )) + ) + link.add(drawing.rect((0, start), (230, end), fill='white', stroke='lightgrey')) + + drawing.add(drawing.rect((0, 0), (230, len(elevation*20)), stroke='black', stroke_width=3, fill='none')) + + return HttpResponse(drawing.tostring(), content_type='image/svg+xml') + + # # Rack reservations # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ec16713fa..5a15198ba 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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(), }) diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index fdb6f9a36..bfcb76317 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -8,47 +8,6 @@
- - - - - +
From 808f5c95e390923e834a3b3d1d791576dc6ed10a Mon Sep 17 00:00:00 2001 From: hellerve Date: Wed, 20 Nov 2019 18:02:07 +0100 Subject: [PATCH 02/18] dcim: make front and rear work (references #2248) --- netbox/dcim/api/views.py | 10 ++++++++-- netbox/templates/dcim/inc/rack_elevation.html | 2 +- netbox/templates/dcim/rack.html | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 7eb40b526..d262abaac 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,7 +3,7 @@ from collections import OrderedDict import svgwrite from django.conf import settings from django.db.models import Count, F -from django.http import HttpResponseForbidden, HttpResponse +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 @@ -218,7 +218,13 @@ class RackElevationViewSet(ViewSet): """ rack = get_object_or_404(Rack, pk=pk) - elevation = rack.get_front_elevation() + side = request.GET.get('face', 'front') + if side == 'front': + elevation = rack.get_front_elevation() + elif side == 'rear': + elevation = rack.get_rear_elevation() + else: + return HttpResponseBadRequest('side should either be "front" or "back".') drawing = svgwrite.Drawing(size=(230, len(elevation)*20), style="box-sizing: border-box") for i, u in enumerate(elevation): diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index bfcb76317..92ad859f7 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -8,6 +8,6 @@
- +
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f82ee0d4f..09495b4f7 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -317,13 +317,13 @@

Front

- {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face="front" %}

Rear

- {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face="rear" %}
From 1a9b9f50d823bbb58ad800a9bbd6d1c999a211bd Mon Sep 17 00:00:00 2001 From: hellerve Date: Wed, 20 Nov 2019 18:27:04 +0100 Subject: [PATCH 03/18] dcim: fix fonts & texts in svg --- netbox/dcim/api/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d262abaac..61e989b3a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -225,7 +225,9 @@ class RackElevationViewSet(ViewSet): elevation = rack.get_rear_elevation() else: return HttpResponseBadRequest('side should either be "front" or "back".') + drawing = svgwrite.Drawing(size=(230, len(elevation)*20), style="box-sizing: border-box") + drawing.defs.add(drawing.style('* { font-family: "Helvetica Neue"; }')) for i, u in enumerate(elevation): device = u['device'] @@ -234,7 +236,7 @@ class RackElevationViewSet(ViewSet): if device: link = drawing.add(drawing.a(reverse('dcim:device', kwargs={'pk': device.pk}), fill='black')) link.add(drawing.rect((0, start), (230, end), fill='#{}'.format(device.device_role.color), stroke='grey')) - link.add(drawing.text(device.name, insert=(0, start+20))) + link.add(drawing.text(device.name, insert=(115, start+10), text_anchor="middle", dominant_baseline="middle")) else: link = drawing.add( drawing.a('{}?{}'.format( From 77890ad77558b426d457b5230702f99519fba356 Mon Sep 17 00:00:00 2001 From: hellerve Date: Wed, 20 Nov 2019 19:14:54 +0100 Subject: [PATCH 04/18] dcim: add inline stylesheet to rack elevation api view --- netbox/dcim/api/views.py | 40 +++++++++++++++++-- netbox/templates/dcim/inc/rack_elevation.html | 2 +- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 61e989b3a..e747046cc 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -203,6 +203,37 @@ class RackViewSet(CustomFieldModelViewSet): return self.get_paginated_response(rack_units.data) +RACK_ELEVATION_STYLE = """ +* { + font-family: 'Helvetica Neue'; + font-size: 13px; +} +rect { + box-sizing: border-box; +} + +.rack { + background-color: #f0f0f0; + fill: none; + stroke: black; + stroke-width: 3px; +} +.empty { + fill: #f9f9f9; + stroke: grey; +} +.empty:hover { + fill: #fff; +} +.empty+.add-device { + fill: none; +} +.empty:hover+.add-device { + fill: blue; +} +""" + + class RackElevationViewSet(ViewSet): queryset = Rack.objects.prefetch_related( 'devices' @@ -226,8 +257,8 @@ class RackElevationViewSet(ViewSet): else: return HttpResponseBadRequest('side should either be "front" or "back".') - drawing = svgwrite.Drawing(size=(230, len(elevation)*20), style="box-sizing: border-box") - drawing.defs.add(drawing.style('* { font-family: "Helvetica Neue"; }')) + drawing = svgwrite.Drawing(size=(230, len(elevation)*20)) + drawing.defs.add(drawing.style(RACK_ELEVATION_STYLE)) for i, u in enumerate(elevation): device = u['device'] @@ -244,9 +275,10 @@ class RackElevationViewSet(ViewSet): urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': 0, 'position': u['id']}) )) ) - link.add(drawing.rect((0, start), (230, end), fill='white', stroke='lightgrey')) + link.add(drawing.rect((0, start), (230, end), class_='empty')) + link.add(drawing.text("add device", insert=(115, start+10), text_anchor="middle", dominant_baseline="middle", class_="add-device")) - drawing.add(drawing.rect((0, 0), (230, len(elevation*20)), stroke='black', stroke_width=3, fill='none')) + drawing.add(drawing.rect((0, 0), (230, len(elevation*20)), class_='rack')) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 92ad859f7..1334465b3 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -8,6 +8,6 @@
- +
From 5d5b7e0dd92c01276c611990f87d0778cc48029a Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 21 Nov 2019 16:58:07 +0100 Subject: [PATCH 05/18] dcim: refactor reservations and make them resizable --- netbox/dcim/api/views.py | 177 ++++++++++++++---- netbox/templates/dcim/rack.html | 4 +- .../templates/dcim/rack_elevation_list.html | 4 +- 3 files changed, 147 insertions(+), 38 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e747046cc..5122b10eb 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -31,6 +31,8 @@ from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, ) from utilities.utils import get_subquery +# XXX: should this be moved to a util function so that we don’t have to import templatetags? +from utilities.templatetags.helpers import fgcolor from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -211,26 +213,50 @@ RACK_ELEVATION_STYLE = """ rect { box-sizing: border-box; } - +text { + text-anchor: middle; + dominant-baseline: middle; +} .rack { background-color: #f0f0f0; fill: none; stroke: black; stroke-width: 3px; } -.empty { - fill: #f9f9f9; - stroke: grey; +.slot { + fill: #f7f7f7; + stroke: #a0a0a0; } -.empty:hover { +.slot:hover { fill: #fff; } -.empty+.add-device { +.slot+.add-device { fill: none; } -.empty:hover+.add-device { +.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; +} """ @@ -242,6 +268,96 @@ 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(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)) + ) + link.add( + drawing.text(device.name, insert=text, fill=fgcolor(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, slot_height): + drawing = self._setup_drawing(width, slot_height*rack.u_height) + i = 0 + for u in elevation: + device = u['device'] + height = u['height'] + start_y = i * slot_height + end_y = slot_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*slot_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): """ @@ -249,36 +365,29 @@ class RackElevationViewSet(ViewSet): """ rack = get_object_or_404(Rack, pk=pk) - side = request.GET.get('face', 'front') - if side == 'front': - elevation = rack.get_front_elevation() - elif side == 'rear': - elevation = rack.get_rear_elevation() - else: - return HttpResponseBadRequest('side should either be "front" or "back".') + face_id = request.GET.get('face', '0') + if face_id not in ['0', '1']: + return HttpResponseBadRequest('side should either be "0" or "1".') + # this is safe because of the validation above + face_id = int(face_id) - drawing = svgwrite.Drawing(size=(230, len(elevation)*20)) - drawing.defs.add(drawing.style(RACK_ELEVATION_STYLE)) + width = request.GET.get('width', '230') + try: + width = int(width) + except ValueError: + return HttpResponseBadRequest('width must be numeric.') - for i, u in enumerate(elevation): - device = u['device'] - start = i * 20 - end = 20 - if device: - link = drawing.add(drawing.a(reverse('dcim:device', kwargs={'pk': device.pk}), fill='black')) - link.add(drawing.rect((0, start), (230, end), fill='#{}'.format(device.device_role.color), stroke='grey')) - link.add(drawing.text(device.name, insert=(115, start+10), text_anchor="middle", dominant_baseline="middle")) - else: - link = drawing.add( - drawing.a('{}?{}'.format( - reverse('dcim:device_add'), - urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': 0, 'position': u['id']}) - )) - ) - link.add(drawing.rect((0, start), (230, end), class_='empty')) - link.add(drawing.text("add device", insert=(115, start+10), text_anchor="middle", dominant_baseline="middle", class_="add-device")) + slot_height = request.GET.get('slot_height', '20') + try: + slot_height = int(slot_height) + except ValueError: + return HttpResponseBadRequest('slot_height must be numeric.') - drawing.add(drawing.rect((0, 0), (230, len(elevation*20)), class_='rack')) + elevation = self._get_elevation(rack) + + reserved = rack.get_reserved_units().keys() + + drawing = self._draw_elevations(rack, elevation, reserved, face_id, width, slot_height) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 09495b4f7..e30ef19b6 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -317,13 +317,13 @@

Front

- {% include 'dcim/inc/rack_elevation.html' with face="front" %} + {% include 'dcim/inc/rack_elevation.html' with face=0 %}

Rear

- {% include 'dcim/inc/rack_elevation.html' with face="rear" %} + {% include 'dcim/inc/rack_elevation.html' with face=1 %}
diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index a500bb3bb..7fd07311e 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -18,9 +18,9 @@

{{ rack.facility_id|truncatechars:"30" }}

{% if face_id %} - {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face=1 %} {% else %} - {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_front_elevation secondary_face=rack.get_rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face=0 %} {% endif %}
From e1fdc7773a921d9dfd9e15ad6890baaf609eb4f1 Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 21 Nov 2019 17:00:43 +0100 Subject: [PATCH 06/18] requirements: fix svgwrite version --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 9a6cf297a..443b79d29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ psycopg2-binary==2.8.3 py-gfm==0.1.4 pycryptodome==3.8.2 redis==3.3.11 +svgwrite==1.1.9 From bc859c6139e15aa96313edc0c763ae89d2231bb0 Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 21 Nov 2019 17:08:13 +0100 Subject: [PATCH 07/18] css: purge outdated rack styling --- netbox/project-static/css/base.css | 188 ----------------------------- 1 file changed, 188 deletions(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 9d4c099f4..45babe70b 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -187,194 +187,6 @@ ul.rack_legend li { padding: 5px 0; text-align: right; } -div.rack_frame { - float: left; - position: relative; -} -ul.rack { - border: 2px solid #404040; - float: left; - list-style-type: none; - padding: 0; - position: absolute; - width: 230px; -} -ul.rack li { - border-top: 1px solid #e0e0e0; - display: block; - font-size: 13px; - height: 20px; - overflow: hidden; - text-align: center; -} -ul.rack li.h2u { height: 40px; } -ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; } -ul.rack li.h3u { height: 60px; } -ul.rack li.h3u a, ul.rack li.h3u span { padding: 20px 0; } -ul.rack li.h4u { height: 80px; } -ul.rack li.h4u a, ul.rack li.h4u span { padding: 30px 0; } -ul.rack li.h5u { height: 100px; } -ul.rack li.h5u a, ul.rack li.h5u span { padding: 40px 0; } -ul.rack li.h6u { height: 120px; } -ul.rack li.h6u a, ul.rack li.h6u span { padding: 50px 0; } -ul.rack li.h7u { height: 140px; } -ul.rack li.h7u a, ul.rack li.h7u span { padding: 60px 0; } -ul.rack li.h8u { height: 160px; } -ul.rack li.h8u a, ul.rack li.h8u span { padding: 70px 0; } -ul.rack li.h9u { height: 180px; } -ul.rack li.h9u a, ul.rack li.h9u span { padding: 80px 0; } -ul.rack li.h10u { height: 200px; } -ul.rack li.h10u a, ul.rack li.h10u span { padding: 90px 0; } -ul.rack li.h11u { height: 220px; } -ul.rack li.h11u a, ul.rack li.h11u span { padding: 100px 0; } -ul.rack li.h12u { height: 240px; } -ul.rack li.h12u a, ul.rack li.h12u span { padding: 110px 0; } -ul.rack li.h13u { height: 260px; } -ul.rack li.h13u a, ul.rack li.h13u span { padding: 120px 0; } -ul.rack li.h14u { height: 280px; } -ul.rack li.h14u a, ul.rack li.h14u span { padding: 130px 0; } -ul.rack li.h15u { height: 300px; } -ul.rack li.h15u a, ul.rack li.h15u span { padding: 140px 0; } -ul.rack li.h16u { height: 320px; } -ul.rack li.h16u a, ul.rack li.h16u span { padding: 150px 0; } -ul.rack li.h17u { height: 340px; } -ul.rack li.h17u a, ul.rack li.h17u span { padding: 160px 0; } -ul.rack li.h18u { height: 360px; } -ul.rack li.h18u a, ul.rack li.h18u span { padding: 170px 0; } -ul.rack li.h19u { height: 380px; } -ul.rack li.h19u a, ul.rack li.h19u span { padding: 180px 0; } -ul.rack li.h20u { height: 400px; } -ul.rack li.h20u a, ul.rack li.h20u span { padding: 190px 0; } -ul.rack li.h21u { height: 420px; } -ul.rack li.h21u a, ul.rack li.h21u span { padding: 200px 0; } -ul.rack li.h22u { height: 440px; } -ul.rack li.h22u a, ul.rack li.h22u span { padding: 210px 0; } -ul.rack li.h23u { height: 460px; } -ul.rack li.h23u a, ul.rack li.h23u span { padding: 220px 0; } -ul.rack li.h24u { height: 480px; } -ul.rack li.h24u a, ul.rack li.h24u span { padding: 230px 0; } -ul.rack li.h25u { height: 500px; } -ul.rack li.h25u a, ul.rack li.h25u span { padding: 240px 0; } -ul.rack li.h26u { height: 520px; } -ul.rack li.h26u a, ul.rack li.h26u span { padding: 250px 0; } -ul.rack li.h27u { height: 540px; } -ul.rack li.h27u a, ul.rack li.h27u span { padding: 260px 0; } -ul.rack li.h28u { height: 560px; } -ul.rack li.h28u a, ul.rack li.h28u span { padding: 270px 0; } -ul.rack li.h29u { height: 580px; } -ul.rack li.h29u a, ul.rack li.h29u span { padding: 280px 0; } -ul.rack li.h30u { height: 600px; } -ul.rack li.h30u a, ul.rack li.h30u span { padding: 290px 0; } -ul.rack li.h31u { height: 620px; } -ul.rack li.h31u a, ul.rack li.h31u span { padding: 300px 0; } -ul.rack li.h32u { height: 640px; } -ul.rack li.h32u a, ul.rack li.h32u span { padding: 310px 0; } -ul.rack li.h33u { height: 660px; } -ul.rack li.h33u a, ul.rack li.h33u span { padding: 320px 0; } -ul.rack li.h34u { height: 680px; } -ul.rack li.h34u a, ul.rack li.h34u span { padding: 330px 0; } -ul.rack li.h35u { height: 700px; } -ul.rack li.h35u a, ul.rack li.h35u span { padding: 340px 0; } -ul.rack li.h36u { height: 720px; } -ul.rack li.h36u a, ul.rack li.h36u span { padding: 350px 0; } -ul.rack li.h37u { height: 740px; } -ul.rack li.h37u a, ul.rack li.h37u span { padding: 360px 0; } -ul.rack li.h38u { height: 760px; } -ul.rack li.h38u a, ul.rack li.h38u span { padding: 370px 0; } -ul.rack li.h39u { height: 780px; } -ul.rack li.h39u a, ul.rack li.h39u span { padding: 380px 0; } -ul.rack li.h40u { height: 800px; } -ul.rack li.h40u a, ul.rack li.h40u span { padding: 390px 0; } -ul.rack li.h41u { height: 820px; } -ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; } -ul.rack li.h42u { height: 840px; } -ul.rack li.h42u a, ul.rack li.h42u span { padding: 410px 0; } -ul.rack li.h43u { height: 860px; } -ul.rack li.h43u a, ul.rack li.h43u span { padding: 420px 0; } -ul.rack li.h44u { height: 880px; } -ul.rack li.h44u a, ul.rack li.h44u span { padding: 430px 0; } -ul.rack li.h45u { height: 900px; } -ul.rack li.h45u a, ul.rack li.h45u span { padding: 440px 0; } -ul.rack li.h46u { height: 920px; } -ul.rack li.h46u a, ul.rack li.h46u span { padding: 450px 0; } -ul.rack li.h47u { height: 940px; } -ul.rack li.h47u a, ul.rack li.h47u span { padding: 460px 0; } -ul.rack li.h48u { height: 960px; } -ul.rack li.h48u a, ul.rack li.h48u span { padding: 470px 0; } -ul.rack li.h49u { height: 980px; } -ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; } -ul.rack li.h50u { height: 1000px; } -ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; } -ul.rack_far_face { - background-color: #f7f7f7; - z-index: 100; -} -ul.rack_far_face li.occupied { - background: repeating-linear-gradient( - 45deg, - #f7f7f7, - #f7f7f7 7px, - #f0f0f0 7px, - #f0f0f0 14px - ); -} -ul.rack_far_face li.blocked { - background: repeating-linear-gradient( - 45deg, - #f7f7f7, - #f7f7f7 7px, - #ffc7c7 7px, - #ffc7c7 14px - ); -} -ul.rack_near_face li.reserved { - background: repeating-linear-gradient( - 45deg, - #f7f7f7, - #f7f7f7 7px, - #c7c7ff 7px, - #c7c7ff 14px - ); -} -ul.rack_near_face { - z-index: 200; -} -ul.rack_near_face li.occupied { - border-top: 1px solid #474747; - color: #474747; -} -ul.rack_near_face li.occupied:hover { - background-image: url('../img/tint_20.png'); -} -ul.rack_near_face li:first-child { - border-top: 0; -} -ul.rack_near_face li.available a { - color: #0000ff; - display: none; - text-decoration: none; -} -ul.rack_near_face li.available:hover { - background-color: #ffffff; -} -ul.rack_near_face li.available:hover a { - display: block; -} -ul.rack li.occupied a { - color: #ffffff; - display: block; - font-weight: bold; -} -ul.rack li.occupied a:hover { - text-decoration: none; -} -ul.rack li.occupied span { - cursor: default; - display: block; -} -li.occupied + li.available { - border-top: 1px solid #474747; -} /* Devices */ table.component-list td.subtable { From 82819d54290a240e63fc7137f37a12a7a635b54e Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 21 Nov 2019 17:21:48 +0100 Subject: [PATCH 08/18] dcim: remove elevation getters --- netbox/dcim/models.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2297909b4..0a78f862f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -670,14 +670,13 @@ 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=Device.FaceChoices.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 - :param remove_redundant: If True, rack units occupied by a device already listed will be omitted """ elevation = OrderedDict() @@ -691,22 +690,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel): .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: - for u in range(device.position, device.position + device.device_type.u_height): - elevation[u]['device'] = device + 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_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). From 66b6f586f1639020a8168424d9215344d59a2fb0 Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 21 Nov 2019 17:36:37 +0100 Subject: [PATCH 09/18] tests: update to reflect absence of utility functions --- netbox/dcim/tests/test_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2c3507758..634ccb3ca 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -125,14 +125,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=RACK_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=RACK_FACE_REAR) self.assertEqual(rack1_inventory_rear[-10]['device'], device1) del(rack1_inventory_rear[-10]) for u in rack1_inventory_rear: From b54a05d2558b05b349b8144dfca93f0f48509420 Mon Sep 17 00:00:00 2001 From: hellerve Date: Thu, 21 Nov 2019 17:49:39 +0100 Subject: [PATCH 10/18] dcim: make linter happy --- netbox/dcim/api/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5122b10eb..d66bc020b 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -318,7 +318,7 @@ class RackElevationViewSet(ViewSet): link.add(drawing.text("add device", insert=text, class_='add-device')) def _draw_elevations(self, rack, elevation, reserved, face_id, width, slot_height): - drawing = self._setup_drawing(width, slot_height*rack.u_height) + drawing = self._setup_drawing(width, slot_height * rack.u_height) i = 0 for u in elevation: device = u['device'] @@ -327,7 +327,7 @@ class RackElevationViewSet(ViewSet): end_y = slot_height * height start = (0, start_y) end = (width, end_y) - text = (width/2, start_y + end_y/2) + 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: @@ -342,7 +342,7 @@ class RackElevationViewSet(ViewSet): rack, drawing, start, end, text, u["id"], face_id, class_ ) i += height - drawing.add(drawing.rect((0, 0), (width, rack.u_height*slot_height), class_='rack')) + drawing.add(drawing.rect((0, 0), (width, rack.u_height * slot_height), class_='rack')) return drawing def _get_elevation(self, rack): From e5b49e25b9153c53f9551dda065d7a0bc28c266f Mon Sep 17 00:00:00 2001 From: hellerve Date: Sun, 8 Dec 2019 18:14:59 +0100 Subject: [PATCH 11/18] dcim api: add feedback from @jeremystretch to rack elevations api --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/api/views.py | 100 ++++++------------------------- netbox/dcim/constants.py | 55 +++++++++++++++++ netbox/dcim/models.py | 2 +- netbox/dcim/tests/test_models.py | 4 +- netbox/templates/dcim/rack.html | 4 +- 6 files changed, 80 insertions(+), 87 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3c4170373..84a08107b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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, required=False, allow_null=True) device = NestedDeviceSerializer(read_only=True) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index d66bc020b..22246ae2d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -15,7 +15,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, @@ -30,9 +30,7 @@ from ipam.models import Prefix, VLAN from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, ) -from utilities.utils import get_subquery -# XXX: should this be moved to a util function so that we don’t have to import templatetags? -from utilities.templatetags.helpers import fgcolor +from utilities.utils import get_subquery, foreground_color from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -205,61 +203,6 @@ class RackViewSet(CustomFieldModelViewSet): return self.get_paginated_response(rack_units.data) -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; -} -""" - - class RackElevationViewSet(ViewSet): queryset = Rack.objects.prefetch_related( 'devices' @@ -280,7 +223,7 @@ class RackElevationViewSet(ViewSet): drawing = svgwrite.Drawing(size=(width, height)) # add the stylesheet - drawing.defs.add(drawing.style(RACK_ELEVATION_STYLE)) + drawing.defs.add(drawing.style(constants.RACK_ELEVATION_STYLE)) # add gradients self._add_gradient(drawing, 'reserved', '#c7c7ff') @@ -296,12 +239,9 @@ class RackElevationViewSet(ViewSet): reverse('dcim:device', kwargs={'pk': device.pk}), fill='black' ) ) - link.add( - drawing.rect(start, end, fill='#{}'.format(color)) - ) - link.add( - drawing.text(device.name, insert=text, fill=fgcolor(color)) - ) + 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")) @@ -317,14 +257,14 @@ class RackElevationViewSet(ViewSet): 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, slot_height): - drawing = self._setup_drawing(width, slot_height * rack.u_height) + 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 * slot_height - end_y = slot_height * 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) @@ -342,7 +282,7 @@ class RackElevationViewSet(ViewSet): rack, drawing, start, end, text, u["id"], face_id, class_ ) i += height - drawing.add(drawing.rect((0, 0), (width, rack.u_height * slot_height), class_='rack')) + drawing.add(drawing.rect((0, 0), (width, rack.u_height * u_height), class_='rack')) return drawing def _get_elevation(self, rack): @@ -366,28 +306,26 @@ class RackElevationViewSet(ViewSet): rack = get_object_or_404(Rack, pk=pk) face_id = request.GET.get('face', '0') - if face_id not in ['0', '1']: - return HttpResponseBadRequest('side should either be "0" or "1".') - # this is safe because of the validation above - face_id = int(face_id) + if face_id not in ['front', 'rear']: + return HttpResponseBadRequest('face should either be "front" or "rear".') - width = request.GET.get('width', '230') + width = request.GET.get('u_width', '230') try: width = int(width) except ValueError: - return HttpResponseBadRequest('width must be numeric.') + return HttpResponseBadRequest('u_width must be numeric.') - slot_height = request.GET.get('slot_height', '20') + u_height = request.GET.get('u_height', '20') try: - slot_height = int(slot_height) + u_height = int(u_height) except ValueError: - return HttpResponseBadRequest('slot_height must be numeric.') + 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, slot_height) + 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/constants.py b/netbox/dcim/constants.py index 8dacd68f5..a33682f51 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -55,3 +55,58 @@ 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; +} +""" diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 0a78f862f..244e61e54 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -670,7 +670,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) - def get_rack_units(self, face=Device.FaceChoices.FACE_FRONT, exclude=None): + 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. diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 634ccb3ca..9d7772804 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -125,14 +125,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_rack_units(face=RACK_FACE_FRONT) + 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_rack_units(face=RACK_FACE_REAR) + 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: diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index e30ef19b6..0cc261a27 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -317,13 +317,13 @@

Front

- {% include 'dcim/inc/rack_elevation.html' with face=0 %} + {% include 'dcim/inc/rack_elevation.html' with face='front' %}

Rear

- {% include 'dcim/inc/rack_elevation.html' with face=1 %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
From 44b042e3fc051ceb39e8a644dbe1f5b61f9701b0 Mon Sep 17 00:00:00 2001 From: hellerve Date: Sun, 8 Dec 2019 18:24:13 +0100 Subject: [PATCH 12/18] dcim api: fix face default value in rackviewset --- netbox/dcim/api/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 22246ae2d..db4f4f154 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -183,7 +183,7 @@ class RackViewSet(CustomFieldModelViewSet): List rack units (by rack) """ 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: From 1ec191db926ae90f12d81515a2e6597dc13abb84 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 10 Dec 2019 03:18:10 -0500 Subject: [PATCH 13/18] 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) From d8dd5f00c1048da8fa621db534db62633b5f2d09 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 10 Dec 2019 03:19:26 -0500 Subject: [PATCH 14/18] removed rack elevations viewset --- netbox/dcim/api/urls.py | 1 - netbox/dcim/api/views.py | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 7169fcdbb..fd55d9b05 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -25,7 +25,6 @@ router.register(r'sites', views.SiteViewSet) router.register(r'rack-groups', views.RackGroupViewSet) router.register(r'rack-roles', views.RackRoleViewSet) router.register(r'racks', views.RackViewSet) -router.register(r'rack-elevations', views.RackElevationViewSet, basename='rack-elevation') router.register(r'rack-reservations', views.RackReservationViewSet) # Device types diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 58c8a6221..268ff1804 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -255,19 +255,6 @@ class RackViewSet(CustomFieldModelViewSet): return self.get_paginated_response(rack_units.data) -class RackElevationViewSet(ViewSet): - queryset = Rack.objects.prefetch_related( - 'devices' - ) - - def get_view_name(self): - return "Rack Elevations" - - def retrieve(self, request, pk=None): - pass - - - # # Rack reservations # From b4d724b5aed3509a64cd386cbf637ae8462a4832 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 11 Dec 2019 09:45:08 -0500 Subject: [PATCH 15/18] drf-yasg updates for rack elevations --- netbox/dcim/api/serializers.py | 10 ++++ netbox/dcim/api/views.py | 56 +++++++------------ netbox/dcim/forms.py | 2 +- netbox/dcim/models.py | 50 ++++++++++------- netbox/templates/dcim/inc/rack_elevation.html | 2 +- 5 files changed, 61 insertions(+), 59 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 84a08107b..5c51b75e9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -171,6 +171,16 @@ class RackReservationSerializer(ValidatedModelSerializer): fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] +class RackElevationDetailFilterSerializer(serializers.Serializer): + face = serializers.ChoiceField(choices=['front', 'rear'], default='front') + render_format = serializers.ChoiceField(choices=['json', 'svg'], default='json') + width = serializers.IntegerField(default=230) + unit_height = serializers.IntegerField(default=20) + exclude = serializers.IntegerField(required=False, default=None) + q = serializers.CharField(required=False, default=None) + expand_devices = serializers.BooleanField(required=False, default=True) + + # # Device types # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 268ff1804..13d12bab7 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -176,11 +176,13 @@ 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', 'front') exclude_pk = request.GET.get('exclude', None) @@ -201,53 +203,33 @@ 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)}) + @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) - 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) + 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': + drawing = rack.get_elevation_svg(data['face'], data['width'], data['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'])] + elevation = rack.get_rack_units( + face=data['face'], + exclude=data['exclude'], + expand_devices=data['expand_devices'] + ) + if data['q']: + elevation = [u for u in elevation if data['q'] in str(u['id'])] page = self.paginate_queryset(elevation) if page is not None: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9a836326a..92247af29 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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' ) ) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 0ef98d36e..f7f56af27 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -526,17 +526,8 @@ class RackElevationHelperMixin: # 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 - + height = unit.get('height', 1) + # Setup drawing cordinates start_y = unit_cursor * unit_height end_y = unit_height * height @@ -567,13 +558,16 @@ class RackElevationHelperMixin: return drawing - def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None): + 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 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() @@ -582,13 +576,29 @@ class RackElevationHelperMixin: # 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 + 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()] @@ -646,7 +656,7 @@ class RackElevationHelperMixin: :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) + 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, width, unit_height) diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 1334465b3..8f8204f9e 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -8,6 +8,6 @@
- +
From e0c81fd83727715abee488651b092419824fb0ab Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 11 Dec 2019 10:34:44 -0500 Subject: [PATCH 16/18] changelog for #2248 --- docs/release-notes/version-2.7.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index bf25eb3f1..90c1d532a 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -38,6 +38,28 @@ components can be imported in CSV-format. ## Changes +### Rack Elevations Rendered via SVG ([#2248](https://github.com/netbox-community/netbox/issues/2248)) + +v2.7.0 introduces a new method of rendering rack elevations as an [SVG](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics) via a REST API endpoint. This replaces the prior method of rendering elevations using pure HTML which was cumbersome and had several shortcomings. Allowing elevations to be rendered as an SVG in the API allows users to retrieve and make use of the drawings in their own tooling. This also opens the door to other feature requests related to rack elevations in the NetBox backlog. + +This feature implements a new REST API endpoint: + +``` +/api/dcim/racks//elevation/ +``` + +By default, this endpoint returns a paginated JSON response representing each rack unit in the given elevation. This is the same response returned by the rack units detail endpoint and for this reason the rack units endpoint has been deprecated and will be removed in v2.8: + +``` +/api/dcim/racks//units/ +``` + +All internal use of the rack units endpoint has been updated to use the new rack elevation endpoint. + +In order to render the elevation as an SVG, include the `render_format=svg` query parameter in the request. You may also control the width of the elevation drawing in pixels with `width=` and the height of each rack unit with `unit_height=`. The `width` defaults to `230` and the `unit_height` default to `20` which produces elevations the same size as those that appear in the NetBox Web UI. The query parameter `face` is used to request either the `front` or `rear` of the elevation and defaults to `front`. + +Thanks to [@hellerve](https://github.com/hellerve) for doing the heavy lifting on this! + ### Topology Maps Removed ([#2745](https://github.com/netbox-community/netbox/issues/2745)) The topology maps feature has been removed to help focus NetBox development efforts. From c09aefd509ef06c02e414793c2af4e07f6e3b54c Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 11 Dec 2019 10:39:46 -0500 Subject: [PATCH 17/18] updated changelog with deprecation notice of rack units endpoint --- docs/release-notes/version-2.7.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 90c1d532a..9fa18ea68 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -48,7 +48,7 @@ This feature implements a new REST API endpoint: /api/dcim/racks//elevation/ ``` -By default, this endpoint returns a paginated JSON response representing each rack unit in the given elevation. This is the same response returned by the rack units detail endpoint and for this reason the rack units endpoint has been deprecated and will be removed in v2.8: +By default, this endpoint returns a paginated JSON response representing each rack unit in the given elevation. This is the same response returned by the rack units detail endpoint and for this reason the rack units endpoint has been deprecated and will be removed in v2.8 (see [#3753](https://github.com/netbox-community/netbox/issues/3753)): ``` /api/dcim/racks//units/ From 7f788eaa0646c02371daa4495a2c8a65bbde48a0 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 11 Dec 2019 13:39:10 -0500 Subject: [PATCH 18/18] review updates to svg rendering --- docs/release-notes/version-2.7.md | 14 +- netbox/dcim/api/serializers.py | 14 +- netbox/dcim/api/views.py | 6 +- netbox/dcim/choices.py | 11 + netbox/dcim/constants.py | 5 + netbox/dcim/models.py | 192 +++++++++--------- .../templates/dcim/rack_elevation_list.html | 4 +- 7 files changed, 137 insertions(+), 109 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 9fa18ea68..74b258425 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -54,9 +54,19 @@ By default, this endpoint returns a paginated JSON response representing each ra /api/dcim/racks//units/ ``` -All internal use of the rack units endpoint has been updated to use the new rack elevation endpoint. +In order to render the elevation as an SVG, include the `render_format=svg` query parameter in the request. You may also control the width of the elevation drawing in pixels with `unit_width=` and the height of each rack unit with `unit_height=`. The `unit_width` defaults to `230` and the `unit_height` default to `20` which produces elevations the same size as those that appear in the NetBox Web UI. The query parameter `face` is used to request either the `front` or `rear` of the elevation and defaults to `front`. -In order to render the elevation as an SVG, include the `render_format=svg` query parameter in the request. You may also control the width of the elevation drawing in pixels with `width=` and the height of each rack unit with `unit_height=`. The `width` defaults to `230` and the `unit_height` default to `20` which produces elevations the same size as those that appear in the NetBox Web UI. The query parameter `face` is used to request either the `front` or `rear` of the elevation and defaults to `front`. +Here is an example of the request url for an SVG rendering using the default parameters to render the front of the elevation: + +``` +/api/dcim/racks//elevation/?render_format=svg +``` + +Here is an example of the request url for an SVG rendering of the rear of the elevation having a width of 300 pixels and per unit height of 35 pixels: + +``` +/api/dcim/racks//elevation/?render_format=svg&face=rear&unit_width=300&unit_height=35 +``` Thanks to [@hellerve](https://github.com/hellerve) for doing the heavy lifting on this! diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5c51b75e9..4de459198 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -157,7 +157,7 @@ class RackUnitSerializer(serializers.Serializer): """ id = serializers.IntegerField(read_only=True) name = serializers.CharField(read_only=True) - face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True) + face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -172,12 +172,14 @@ class RackReservationSerializer(ValidatedModelSerializer): class RackElevationDetailFilterSerializer(serializers.Serializer): - face = serializers.ChoiceField(choices=['front', 'rear'], default='front') - render_format = serializers.ChoiceField(choices=['json', 'svg'], default='json') - width = serializers.IntegerField(default=230) - unit_height = serializers.IntegerField(default=20) + 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) - q = serializers.CharField(required=False, default=None) expand_devices = serializers.BooleanField(required=False, default=True) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 13d12bab7..e286026a5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -219,17 +219,17 @@ class RackViewSet(CustomFieldModelViewSet): data = serializer.validated_data if data['render_format'] == 'svg': - drawing = rack.get_elevation_svg(data['face'], data['width'], data['unit_height']) + # 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'] ) - if data['q']: - elevation = [u for u in elevation if data['q'] in str(u['id'])] page = self.paginate_queryset(elevation) if page is not None: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index f9265480b..a4276ccb3 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index a33682f51..1840925aa 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -110,3 +110,8 @@ text { fill: none; } """ + + +# Rack Elevation SVG Size +RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 +RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f7f56af27..070bfd080 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -517,8 +517,8 @@ class RackElevationHelperMixin: 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) + 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) @@ -532,8 +532,8 @@ class RackElevationHelperMixin: 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) + 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: @@ -554,100 +554,11 @@ class RackElevationHelperMixin: unit_cursor += height # Wrap the drawing with a border - drawing.add(drawing.rect((0, 0), (width, self.u_height * unit_height), class_='rack')) + drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack')) return drawing - 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 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() - 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: - 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_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): + def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20): """ Return an SVG of the rack elevation @@ -659,7 +570,7 @@ class RackElevationHelperMixin: 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, width, unit_height) + return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): @@ -881,6 +792,95 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) + 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 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() + 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: + 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_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) diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 7fd07311e..de7c55919 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -18,9 +18,9 @@

{{ rack.facility_id|truncatechars:"30" }}

{% if face_id %} - {% include 'dcim/inc/rack_elevation.html' with face=1 %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' %} {% else %} - {% include 'dcim/inc/rack_elevation.html' with face=0 %} + {% include 'dcim/inc/rack_elevation.html' with face='front' %} {% endif %}