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/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..db4f4f154 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, 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 @@ -13,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, @@ -28,7 +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 +from utilities.utils import get_subquery, foreground_color from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -181,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: @@ -201,6 +203,133 @@ 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 _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) + + 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') + + # # Rack reservations # 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 2297909b4..244e61e54 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=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 - :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). diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2c3507758..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_front_elevation() + rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) self.assertEqual(rack1_inventory_front[-10]['device'], device1) del(rack1_inventory_front[-10]) for u in rack1_inventory_front: self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rear_elevation() + rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) self.assertEqual(rack1_inventory_rear[-10]['device'], device1) del(rack1_inventory_rear[-10]) for u in rack1_inventory_rear: 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/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 { diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index fdb6f9a36..1334465b3 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -8,47 +8,6 @@
- - - - - +
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f82ee0d4f..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 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' %}
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 %}
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