Skip to content

Commit 05938d6

Browse files
authored
Merge pull request #3754 from netbox-community/2248-svg-rack-elevations
2248 svg rack elevations
2 parents 9f204f7 + 7f788ea commit 05938d6

File tree

15 files changed

+317
-263
lines changed

15 files changed

+317
-263
lines changed

base_requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,7 @@ pycryptodome
8282
# In-memory key/value store used for caching and queuing
8383
# https://github.com/andymccurdy/redis-py
8484
redis
85+
86+
# Python Package to write SVG files - used for rack elevations
87+
# https://github.com/mozman/svgwrite
88+
svgwrite

docs/release-notes/version-2.7.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,38 @@ STORAGE_CONFIG = {
7676

7777
## Changes
7878

79+
### Rack Elevations Rendered via SVG ([#2248](https://github.com/netbox-community/netbox/issues/2248))
80+
81+
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.
82+
83+
This feature implements a new REST API endpoint:
84+
85+
```
86+
/api/dcim/racks/<id>/elevation/
87+
```
88+
89+
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)):
90+
91+
```
92+
/api/dcim/racks/<id>/units/
93+
```
94+
95+
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=<width in pixels>` and the height of each rack unit with `unit_height=<height in pixels>`. 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`.
96+
97+
Here is an example of the request url for an SVG rendering using the default parameters to render the front of the elevation:
98+
99+
```
100+
/api/dcim/racks/<id>/elevation/?render_format=svg
101+
```
102+
103+
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:
104+
105+
```
106+
/api/dcim/racks/<id>/elevation/?render_format=svg&face=rear&unit_width=300&unit_height=35
107+
```
108+
109+
Thanks to [@hellerve](https://github.com/hellerve) for doing the heavy lifting on this!
110+
79111
### Topology Maps Removed ([#2745](https://github.com/netbox-community/netbox/issues/2745))
80112
81113
The topology maps feature has been removed to help focus NetBox development efforts.

netbox/dcim/api/serializers.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ class RackUnitSerializer(serializers.Serializer):
157157
"""
158158
id = serializers.IntegerField(read_only=True)
159159
name = serializers.CharField(read_only=True)
160-
face = serializers.IntegerField(read_only=True)
160+
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
161161
device = NestedDeviceSerializer(read_only=True)
162162

163163

@@ -171,6 +171,18 @@ class Meta:
171171
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
172172

173173

174+
class RackElevationDetailFilterSerializer(serializers.Serializer):
175+
face = serializers.ChoiceField(choices=DeviceFaceChoices, default=DeviceFaceChoices.FACE_FRONT)
176+
render_format = serializers.ChoiceField(
177+
choices=RackElecationDetailRenderFormatChoices,
178+
default=RackElecationDetailRenderFormatChoices.RENDER_FORMAT_SVG
179+
)
180+
unit_width = serializers.IntegerField(default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT)
181+
unit_height = serializers.IntegerField(default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT)
182+
exclude = serializers.IntegerField(required=False, default=None)
183+
expand_devices = serializers.BooleanField(required=False, default=True)
184+
185+
174186
#
175187
# Device types
176188
#

netbox/dcim/api/views.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
from django.conf import settings
44
from django.db.models import Count, F
5-
from django.http import HttpResponseForbidden
6-
from django.shortcuts import get_object_or_404
5+
from django.http import HttpResponseForbidden, HttpResponseBadRequest, HttpResponse
6+
from django.shortcuts import get_object_or_404, reverse
77
from drf_yasg import openapi
88
from drf_yasg.openapi import Parameter
99
from drf_yasg.utils import swagger_auto_schema
@@ -13,7 +13,7 @@
1313
from rest_framework.viewsets import GenericViewSet, ViewSet
1414

1515
from circuits.models import Circuit
16-
from dcim import filters
16+
from dcim import constants, filters
1717
from dcim.models import (
1818
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
1919
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -28,6 +28,7 @@
2828
from utilities.api import (
2929
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
3030
)
31+
from utilities.custom_inspectors import NullablePaginatorInspector
3132
from utilities.utils import get_subquery
3233
from virtualization.models import VirtualMachine
3334
from . import serializers
@@ -175,13 +176,15 @@ class RackViewSet(CustomFieldModelViewSet):
175176
serializer_class = serializers.RackSerializer
176177
filterset_class = filters.RackFilter
177178

179+
@swagger_auto_schema(deprecated=True)
178180
@action(detail=True)
179181
def units(self, request, pk=None):
180182
"""
181183
List rack units (by rack)
182184
"""
185+
# TODO: Remove this action detail route in v2.8
183186
rack = get_object_or_404(Rack, pk=pk)
184-
face = request.GET.get('face', 0)
187+
face = request.GET.get('face', 'front')
185188
exclude_pk = request.GET.get('exclude', None)
186189
if exclude_pk is not None:
187190
try:
@@ -200,6 +203,39 @@ def units(self, request, pk=None):
200203
rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
201204
return self.get_paginated_response(rack_units.data)
202205

206+
@swagger_auto_schema(
207+
responses={200: serializers.RackUnitSerializer(many=True)},
208+
query_serializer=serializers.RackElevationDetailFilterSerializer
209+
)
210+
@action(detail=True)
211+
def elevation(self, request, pk=None):
212+
"""
213+
Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG.
214+
"""
215+
rack = get_object_or_404(Rack, pk=pk)
216+
serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET)
217+
if not serializer.is_valid():
218+
return Response(serializer.errors, 400)
219+
data = serializer.validated_data
220+
221+
if data['render_format'] == 'svg':
222+
# Render and return the elevation as an SVG drawing with the correct content type
223+
drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height'])
224+
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
225+
226+
else:
227+
# Return a JSON representation of the rack units in the elevation
228+
elevation = rack.get_rack_units(
229+
face=data['face'],
230+
exclude=data['exclude'],
231+
expand_devices=data['expand_devices']
232+
)
233+
234+
page = self.paginate_queryset(elevation)
235+
if page is not None:
236+
rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
237+
return self.get_paginated_response(rack_units.data)
238+
203239

204240
#
205241
# Rack reservations

netbox/dcim/choices.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ class RackDimensionUnitChoices(ChoiceSet):
105105
}
106106

107107

108+
class RackElecationDetailRenderFormatChoices(ChoiceSet):
109+
110+
RENDER_FORMAT_JSON = 'json'
111+
RENDER_FORMAT_SVG = 'svg'
112+
113+
CHOICES = (
114+
(RENDER_FORMAT_JSON, 'json'),
115+
(RENDER_FORMAT_SVG, 'svg')
116+
)
117+
118+
108119
#
109120
# DeviceTypes
110121
#

netbox/dcim/constants.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,63 @@
5555
'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
5656
'circuittermination': ['interface', 'frontport', 'rearport'],
5757
}
58+
59+
60+
RACK_ELEVATION_STYLE = """
61+
* {
62+
font-family: 'Helvetica Neue';
63+
font-size: 13px;
64+
}
65+
rect {
66+
box-sizing: border-box;
67+
}
68+
text {
69+
text-anchor: middle;
70+
dominant-baseline: middle;
71+
}
72+
.rack {
73+
background-color: #f0f0f0;
74+
fill: none;
75+
stroke: black;
76+
stroke-width: 3px;
77+
}
78+
.slot {
79+
fill: #f7f7f7;
80+
stroke: #a0a0a0;
81+
}
82+
.slot:hover {
83+
fill: #fff;
84+
}
85+
.slot+.add-device {
86+
fill: none;
87+
}
88+
.slot:hover+.add-device {
89+
fill: blue;
90+
}
91+
.reserved {
92+
fill: url(#reserved);
93+
}
94+
.reserved:hover {
95+
fill: url(#reserved);
96+
}
97+
.occupied {
98+
fill: url(#occupied);
99+
}
100+
.occupied:hover {
101+
fill: url(#occupied);
102+
}
103+
.blocked {
104+
fill: url(#blocked);
105+
}
106+
.blocked:hover {
107+
fill: url(#blocked);
108+
}
109+
.blocked:hover+.add-device {
110+
fill: none;
111+
}
112+
"""
113+
114+
115+
# Rack Elevation SVG Size
116+
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
117+
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20

netbox/dcim/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1475,7 +1475,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
14751475
empty_value=None,
14761476
help_text="The lowest-numbered unit occupied by the device",
14771477
widget=APISelect(
1478-
api_url='/api/dcim/racks/{{rack}}/units/',
1478+
api_url='/api/dcim/racks/{{rack}}/elevation/',
14791479
disabled_indicator='device'
14801480
)
14811481
)

0 commit comments

Comments
 (0)