mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge branch 'develop' into develop-2.8
This commit is contained in:
commit
28e3b7af18
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 📖 Contributing Policy
|
||||||
|
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
||||||
|
about: Please read through our contributing policy before opening an issue or pull request
|
||||||
|
- name: 💬 Discussion Group
|
||||||
|
url: https://groups.google.com/forum/#!forum/netbox-discuss
|
||||||
|
about: Join our discussion group for assistance with installation issues and other problems
|
@ -32,7 +32,8 @@ class DeviceIPsReport(Report):
|
|||||||
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
|
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
|
||||||
|
|
||||||
```
|
```
|
||||||
from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE
|
from dcim.choices import DeviceStatusChoices
|
||||||
|
from dcim.constants import CONNECTION_STATUS_PLANNED
|
||||||
from dcim.models import ConsolePort, Device, PowerPort
|
from dcim.models import ConsolePort, Device, PowerPort
|
||||||
from extras.reports import Report
|
from extras.reports import Report
|
||||||
|
|
||||||
@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report):
|
|||||||
def test_console_connection(self):
|
def test_console_connection(self):
|
||||||
|
|
||||||
# Check that every console port for every active device has a connection defined.
|
# Check that every console port for every active device has a connection defined.
|
||||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
|
active = DeviceStatusChoices.STATUS_ACTIVE
|
||||||
|
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
|
||||||
if console_port.connected_endpoint is None:
|
if console_port.connected_endpoint is None:
|
||||||
self.log_failure(
|
self.log_failure(
|
||||||
console_port.device,
|
console_port.device,
|
||||||
@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report):
|
|||||||
def test_power_connections(self):
|
def test_power_connections(self):
|
||||||
|
|
||||||
# Check that every active device has at least two connected power supplies.
|
# Check that every active device has at least two connected power supplies.
|
||||||
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
|
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
|
||||||
connected_ports = 0
|
connected_ports = 0
|
||||||
for power_port in PowerPort.objects.filter(device=device):
|
for power_port in PowerPort.objects.filter(device=device):
|
||||||
if power_port.connected_endpoint is not None:
|
if power_port.connected_endpoint is not None:
|
||||||
|
@ -1,15 +1,47 @@
|
|||||||
# v2.7.7 (FUTURE)
|
# v2.7.8 (FUTURE)
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
* [#4224](https://github.com/netbox-community/netbox/issues/4224) - Fix display of rear device image if front image is not defined
|
||||||
|
* [#4228](https://github.com/netbox-community/netbox/issues/4228) - Improve fit of device images in rack elevations
|
||||||
|
* [#4232](https://github.com/netbox-community/netbox/issues/4232) - Enforce consistent background striping in rack elevations
|
||||||
|
* [#4235](https://github.com/netbox-community/netbox/issues/4235) - Fix API representation of `content_type` for export templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v2.7.7 (2020-02-20)
|
||||||
|
|
||||||
|
**Note:** This release fixes a bug affecting the natural ordering of interfaces. If any interfaces appear unordered in
|
||||||
|
NetBox, run the following management command to recalculate their naturalized values after upgrading:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 manage.py renaturalize dcim.Interface
|
||||||
|
```
|
||||||
|
|
||||||
## Enhancements
|
## Enhancements
|
||||||
|
|
||||||
|
* [#1529](https://github.com/netbox-community/netbox/issues/1529) - Enable display of device images in rack elevations
|
||||||
|
* [#2511](https://github.com/netbox-community/netbox/issues/2511) - Compare object change to the previous change
|
||||||
|
* [#3810](https://github.com/netbox-community/netbox/issues/3810) - Preserve slug value when editing existing objects
|
||||||
* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment
|
* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment
|
||||||
* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings
|
* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings
|
||||||
|
* [#4206](https://github.com/netbox-community/netbox/issues/4206) - Add RJ-11 console port type
|
||||||
|
* [#4209](https://github.com/netbox-community/netbox/issues/4209) - Enable filtering interfaces list view by enabled
|
||||||
|
|
||||||
## Bug Fixes
|
## Bug Fixes
|
||||||
|
|
||||||
* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API
|
* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API
|
||||||
|
* [#3967](https://github.com/netbox-community/netbox/issues/3967) - Fix missing migration for interface templates of type "other"
|
||||||
* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine
|
* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine
|
||||||
* [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list
|
* [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list
|
||||||
|
* [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel
|
||||||
|
* [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log
|
||||||
|
* [#4194](https://github.com/netbox-community/netbox/issues/4194) - Role field should not be required when searching/filtering secrets
|
||||||
|
* [#4196](https://github.com/netbox-community/netbox/issues/4196) - Fix exception when viewing LLDP neighbors page
|
||||||
|
* [#4202](https://github.com/netbox-community/netbox/issues/4202) - Prevent reassignment to master device when bulk editing VC member interfaces
|
||||||
|
* [#4204](https://github.com/netbox-community/netbox/issues/4204) - Fix assignment of mask length when bulk editing prefixes
|
||||||
|
* [#4211](https://github.com/netbox-community/netbox/issues/4211) - Include trailing text when naturalizing interface names
|
||||||
|
* [#4213](https://github.com/netbox-community/netbox/issues/4213) - Restore display of tags and custom fields on power feed view
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -186,6 +186,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
|||||||
unit_height = serializers.IntegerField(
|
unit_height = serializers.IntegerField(
|
||||||
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
|
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
|
||||||
)
|
)
|
||||||
|
legend_width = serializers.IntegerField(
|
||||||
|
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
||||||
|
)
|
||||||
exclude = serializers.IntegerField(
|
exclude = serializers.IntegerField(
|
||||||
required=False,
|
required=False,
|
||||||
default=None
|
default=None
|
||||||
@ -194,6 +197,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
|||||||
required=False,
|
required=False,
|
||||||
default=True
|
default=True
|
||||||
)
|
)
|
||||||
|
include_images = serializers.BooleanField(
|
||||||
|
required=False,
|
||||||
|
default=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
@ -220,7 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
|||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
|
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
|
||||||
'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
|
||||||
|
'last_updated', 'device_count',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -193,7 +193,13 @@ class RackViewSet(CustomFieldModelViewSet):
|
|||||||
|
|
||||||
if data['render'] == 'svg':
|
if data['render'] == 'svg':
|
||||||
# Render and return the elevation as an SVG drawing with the correct content type
|
# 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'])
|
drawing = rack.get_elevation_svg(
|
||||||
|
face=data['face'],
|
||||||
|
unit_width=data['unit_width'],
|
||||||
|
unit_height=data['unit_height'],
|
||||||
|
legend_width=data['legend_width'],
|
||||||
|
include_images=data['include_images']
|
||||||
|
)
|
||||||
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -195,6 +195,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
|||||||
|
|
||||||
TYPE_DE9 = 'de-9'
|
TYPE_DE9 = 'de-9'
|
||||||
TYPE_DB25 = 'db-25'
|
TYPE_DB25 = 'db-25'
|
||||||
|
TYPE_RJ11 = 'rj-11'
|
||||||
TYPE_RJ12 = 'rj-12'
|
TYPE_RJ12 = 'rj-12'
|
||||||
TYPE_RJ45 = 'rj-45'
|
TYPE_RJ45 = 'rj-45'
|
||||||
TYPE_USB_A = 'usb-a'
|
TYPE_USB_A = 'usb-a'
|
||||||
@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
|||||||
('Serial', (
|
('Serial', (
|
||||||
(TYPE_DE9, 'DE-9'),
|
(TYPE_DE9, 'DE-9'),
|
||||||
(TYPE_DB25, 'DB-25'),
|
(TYPE_DB25, 'DB-25'),
|
||||||
|
(TYPE_RJ11, 'RJ-11'),
|
||||||
(TYPE_RJ12, 'RJ-12'),
|
(TYPE_RJ12, 'RJ-12'),
|
||||||
(TYPE_RJ45, 'RJ-45'),
|
(TYPE_RJ45, 'RJ-45'),
|
||||||
)),
|
)),
|
||||||
|
@ -9,10 +9,10 @@ from .choices import InterfaceTypeChoices
|
|||||||
|
|
||||||
RACK_U_HEIGHT_DEFAULT = 42
|
RACK_U_HEIGHT_DEFAULT = 42
|
||||||
|
|
||||||
|
RACK_ELEVATION_BORDER_WIDTH = 2
|
||||||
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||||
|
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
|
||||||
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
|
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
|
||||||
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
204
netbox/dcim/elevations.py
Normal file
204
netbox/dcim/elevations.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import svgwrite
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
|
from utilities.utils import foreground_color
|
||||||
|
from .choices import DeviceFaceChoices
|
||||||
|
from .constants import RACK_ELEVATION_BORDER_WIDTH
|
||||||
|
|
||||||
|
|
||||||
|
class RackElevationSVG:
|
||||||
|
"""
|
||||||
|
Use this class to render a rack elevation as an SVG image.
|
||||||
|
|
||||||
|
:param rack: A NetBox Rack instance
|
||||||
|
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
||||||
|
"""
|
||||||
|
def __init__(self, rack, include_images=True):
|
||||||
|
self.rack = rack
|
||||||
|
self.include_images = include_images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_gradient(drawing, id_, color):
|
||||||
|
gradient = drawing.linearGradient(
|
||||||
|
start=(0, 0),
|
||||||
|
end=(0, 25),
|
||||||
|
spreadMethod='repeat',
|
||||||
|
id_=id_,
|
||||||
|
gradientTransform='rotate(45, 0, 0)',
|
||||||
|
gradientUnits='userSpaceOnUse'
|
||||||
|
)
|
||||||
|
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
|
||||||
|
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
|
||||||
|
drawing.defs.add(drawing.style(css_file.read()))
|
||||||
|
|
||||||
|
# add gradients
|
||||||
|
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
|
||||||
|
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||||
|
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||||
|
|
||||||
|
return drawing
|
||||||
|
|
||||||
|
def _draw_device_front(self, drawing, device, start, end, text):
|
||||||
|
name = str(device)
|
||||||
|
if device.devicebay_count:
|
||||||
|
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
||||||
|
|
||||||
|
color = device.device_role.color
|
||||||
|
link = drawing.add(
|
||||||
|
drawing.a(
|
||||||
|
href=reverse('dcim:device', kwargs={'pk': device.pk}),
|
||||||
|
target='_top',
|
||||||
|
fill='black'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
link.set_desc('{} — {} ({}U) {} {}'.format(
|
||||||
|
device.device_role, device.device_type.display_name,
|
||||||
|
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
||||||
|
))
|
||||||
|
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
|
||||||
|
hex_color = '#{}'.format(foreground_color(color))
|
||||||
|
link.add(drawing.text(str(name), insert=text, fill=hex_color))
|
||||||
|
|
||||||
|
# Embed front device type image if one exists
|
||||||
|
if self.include_images and device.device_type.front_image:
|
||||||
|
url = device.device_type.front_image.url
|
||||||
|
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||||
|
image.fit(scale='slice')
|
||||||
|
link.add(image)
|
||||||
|
|
||||||
|
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||||
|
rect = drawing.rect(start, end, class_="slot blocked")
|
||||||
|
rect.set_desc('{} — {} ({}U) {} {}'.format(
|
||||||
|
device.device_role, device.device_type.display_name,
|
||||||
|
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
||||||
|
))
|
||||||
|
drawing.add(rect)
|
||||||
|
drawing.add(drawing.text(str(device), insert=text))
|
||||||
|
|
||||||
|
# Embed rear device type image if one exists
|
||||||
|
if self.include_images and device.device_type.rear_image:
|
||||||
|
url = device.device_type.rear_image.url
|
||||||
|
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||||
|
image.fit(scale='slice')
|
||||||
|
drawing.add(image)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||||
|
link = drawing.add(
|
||||||
|
drawing.a(
|
||||||
|
href='{}?{}'.format(
|
||||||
|
reverse('dcim:device_add'),
|
||||||
|
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
|
||||||
|
),
|
||||||
|
target='_top'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if reservation:
|
||||||
|
link.set_desc('{} — {} · {}'.format(
|
||||||
|
reservation.description, reservation.user, reservation.created
|
||||||
|
))
|
||||||
|
link.add(drawing.rect(start, end, class_=class_))
|
||||||
|
link.add(drawing.text("add device", insert=text, class_='add-device'))
|
||||||
|
|
||||||
|
def merge_elevations(self, face):
|
||||||
|
elevation = self.rack.get_rack_units(face=face, expand_devices=False)
|
||||||
|
if face == DeviceFaceChoices.FACE_REAR:
|
||||||
|
other_face = DeviceFaceChoices.FACE_FRONT
|
||||||
|
else:
|
||||||
|
other_face = DeviceFaceChoices.FACE_REAR
|
||||||
|
other = self.rack.get_rack_units(face=other_face)
|
||||||
|
|
||||||
|
unit_cursor = 0
|
||||||
|
for u in elevation:
|
||||||
|
o = other[unit_cursor]
|
||||||
|
if not u['device'] and o['device']:
|
||||||
|
u['device'] = o['device']
|
||||||
|
u['height'] = 1
|
||||||
|
unit_cursor += u.get('height', 1)
|
||||||
|
|
||||||
|
return elevation
|
||||||
|
|
||||||
|
def render(self, face, unit_width, unit_height, legend_width):
|
||||||
|
"""
|
||||||
|
Return an SVG document representing a rack elevation.
|
||||||
|
"""
|
||||||
|
drawing = self._setup_drawing(
|
||||||
|
unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
|
||||||
|
unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||||
|
)
|
||||||
|
reserved_units = self.rack.get_reserved_units()
|
||||||
|
|
||||||
|
unit_cursor = 0
|
||||||
|
for ru in range(0, self.rack.u_height):
|
||||||
|
start_y = ru * unit_height
|
||||||
|
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
||||||
|
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||||
|
drawing.add(
|
||||||
|
drawing.text(str(unit), position_coordinates, class_="unit")
|
||||||
|
)
|
||||||
|
|
||||||
|
for unit in self.merge_elevations(face):
|
||||||
|
|
||||||
|
# Loop through all units in the elevation
|
||||||
|
device = unit['device']
|
||||||
|
height = unit.get('height', 1)
|
||||||
|
|
||||||
|
# Setup drawing coordinates
|
||||||
|
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
|
||||||
|
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
|
||||||
|
end_y = unit_height * height
|
||||||
|
start_cordinates = (x_offset, y_offset)
|
||||||
|
end_cordinates = (unit_width, end_y)
|
||||||
|
text_cordinates = (x_offset + (unit_width / 2), y_offset + 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'
|
||||||
|
reservation = reserved_units.get(unit["id"])
|
||||||
|
if device:
|
||||||
|
class_ += ' occupied'
|
||||||
|
if reservation:
|
||||||
|
class_ += ' reserved'
|
||||||
|
self._draw_empty(
|
||||||
|
drawing,
|
||||||
|
self.rack,
|
||||||
|
start_cordinates,
|
||||||
|
end_cordinates,
|
||||||
|
text_cordinates,
|
||||||
|
unit["id"],
|
||||||
|
face,
|
||||||
|
class_,
|
||||||
|
reservation
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_cursor += height
|
||||||
|
|
||||||
|
# Wrap the drawing with a border
|
||||||
|
border_width = RACK_ELEVATION_BORDER_WIDTH
|
||||||
|
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
|
||||||
|
frame = drawing.rect(
|
||||||
|
insert=(legend_width + border_offset, border_offset),
|
||||||
|
size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
|
||||||
|
class_='rack'
|
||||||
|
)
|
||||||
|
drawing.add(frame)
|
||||||
|
|
||||||
|
return drawing
|
@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
|||||||
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url="/api/dcim/sites/"
|
api_url="/api/dcim/sites/"
|
||||||
)
|
)
|
||||||
@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = DeviceType
|
model = DeviceType
|
||||||
fields = [
|
fields = [
|
||||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
|
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||||
'tags',
|
'front_image', 'rear_image', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'subdevice_role': StaticSelect2()
|
'subdevice_role': StaticSelect2()
|
||||||
@ -2764,6 +2763,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
|||||||
device = forms.ModelChoiceField(
|
device = forms.ModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
disabled=True,
|
||||||
widget=forms.HiddenInput()
|
widget=forms.HiddenInput()
|
||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
@ -2821,6 +2821,12 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
|||||||
|
|
||||||
class InterfaceFilterForm(DeviceComponentFilterForm):
|
class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||||
model = Interface
|
model = Interface
|
||||||
|
enabled = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
widget=StaticSelect2(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
)
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
@ -3061,6 +3067,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
|||||||
device = forms.ModelChoiceField(
|
device = forms.ModelChoiceField(
|
||||||
queryset=Device.objects.all(),
|
queryset=Device.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
disabled=True,
|
||||||
widget=forms.HiddenInput()
|
widget=forms.HiddenInput()
|
||||||
)
|
)
|
||||||
type = forms.ChoiceField(
|
type = forms.ChoiceField(
|
||||||
@ -4522,7 +4529,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
||||||
site = DynamicModelChoiceField(
|
site = DynamicModelChoiceField(
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
required=False,
|
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
api_url="/api/dcim/sites/",
|
api_url="/api/dcim/sites/",
|
||||||
filter_for={
|
filter_for={
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
from django.db.models import Manager, QuerySet
|
|
||||||
|
|
||||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
|
||||||
|
|
||||||
|
|
||||||
class InterfaceQuerySet(QuerySet):
|
|
||||||
|
|
||||||
def connectable(self):
|
|
||||||
"""
|
|
||||||
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
|
|
||||||
wireless).
|
|
||||||
"""
|
|
||||||
return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES)
|
|
||||||
|
|
||||||
|
|
||||||
class InterfaceManager(Manager):
|
|
||||||
|
|
||||||
def get_queryset(self):
|
|
||||||
return InterfaceQuerySet(self.model, using=self._db)
|
|
20
netbox/dcim/migrations/0097_interfacetemplate_type_other.py
Normal file
20
netbox/dcim/migrations/0097_interfacetemplate_type_other.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def interfacetemplate_type_to_slug(apps, schema_editor):
|
||||||
|
InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
|
||||||
|
InterfaceTemplate.objects.filter(type=32767).update(type='other')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0096_interface_ordering'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Missed type "other" in the initial migration (see #3967)
|
||||||
|
migrations.RunPython(
|
||||||
|
code=interfacetemplate_type_to_slug
|
||||||
|
),
|
||||||
|
]
|
23
netbox/dcim/migrations/0098_devicetype_images.py
Normal file
23
netbox/dcim/migrations/0098_devicetype_images.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 2.2.9 on 2020-02-20 15:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dcim', '0097_interfacetemplate_type_other'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicetype',
|
||||||
|
name='front_image',
|
||||||
|
field=models.ImageField(blank=True, upload_to='devicetype-images'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='devicetype',
|
||||||
|
name='rear_image',
|
||||||
|
field=models.ImageField(blank=True, upload_to='devicetype-images'),
|
||||||
|
),
|
||||||
|
]
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0096_interface_ordering'),
|
('dcim', '0098_devicetype_images'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -1,7 +1,6 @@
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from itertools import count, groupby
|
from itertools import count, groupby
|
||||||
|
|
||||||
import svgwrite
|
|
||||||
import yaml
|
import yaml
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@ -13,7 +12,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, F, ProtectedError, Sum
|
from django.db.models import Count, F, ProtectedError, Sum
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
|
||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
from taggit.managers import TaggableManager
|
from taggit.managers import TaggableManager
|
||||||
from timezone_field import TimeZoneField
|
from timezone_field import TimeZoneField
|
||||||
@ -21,10 +19,11 @@ from timezone_field import TimeZoneField
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.fields import ASNField
|
from dcim.fields import ASNField
|
||||||
|
from dcim.elevations import RackElevationSVG
|
||||||
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
|
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
|
||||||
from utilities.fields import ColorField, NaturalOrderingField
|
from utilities.fields import ColorField, NaturalOrderingField
|
||||||
from utilities.models import ChangeLoggedModel
|
from utilities.models import ChangeLoggedModel
|
||||||
from utilities.utils import foreground_color, to_meters
|
from utilities.utils import to_meters
|
||||||
from .device_component_templates import (
|
from .device_component_templates import (
|
||||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
|
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
|
||||||
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||||
@ -350,180 +349,7 @@ class RackRole(ChangeLoggedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RackElevationHelperMixin:
|
class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||||
"""
|
|
||||||
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', '0%'),
|
|
||||||
end=('0', '5%'),
|
|
||||||
spreadMethod='repeat',
|
|
||||||
id_=id_,
|
|
||||||
gradientTransform='rotate(45, 0, 0)',
|
|
||||||
gradientUnits='userSpaceOnUse'
|
|
||||||
)
|
|
||||||
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
|
|
||||||
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
|
|
||||||
drawing.defs.add(drawing.style(css_file.read()))
|
|
||||||
|
|
||||||
# add gradients
|
|
||||||
RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff')
|
|
||||||
RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#d7d7d7')
|
|
||||||
RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc0c0')
|
|
||||||
|
|
||||||
return drawing
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _draw_device_front(drawing, device, start, end, text):
|
|
||||||
name = str(device)
|
|
||||||
if device.devicebay_count:
|
|
||||||
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
|
||||||
|
|
||||||
color = device.device_role.color
|
|
||||||
link = drawing.add(
|
|
||||||
drawing.a(
|
|
||||||
href=reverse('dcim:device', kwargs={'pk': device.pk}),
|
|
||||||
target='_top',
|
|
||||||
fill='black'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
link.set_desc('{} — {} ({}U) {} {}'.format(
|
|
||||||
device.device_role, device.device_type.display_name,
|
|
||||||
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
|
||||||
))
|
|
||||||
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
|
|
||||||
hex_color = '#{}'.format(foreground_color(color))
|
|
||||||
link.add(drawing.text(str(name), insert=text, fill=hex_color))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _draw_device_rear(drawing, device, start, end, text):
|
|
||||||
rect = drawing.rect(start, end, class_="slot blocked")
|
|
||||||
rect.set_desc('{} — {} ({}U) {} {}'.format(
|
|
||||||
device.device_role, device.device_type.display_name,
|
|
||||||
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
|
||||||
))
|
|
||||||
drawing.add(rect)
|
|
||||||
drawing.add(drawing.text(str(device), insert=text))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
|
||||||
link = drawing.add(
|
|
||||||
drawing.a(
|
|
||||||
href='{}?{}'.format(
|
|
||||||
reverse('dcim:device_add'),
|
|
||||||
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
|
|
||||||
),
|
|
||||||
target='_top'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if reservation:
|
|
||||||
link.set_desc('{} — {} · {}'.format(
|
|
||||||
reservation.description, reservation.user, reservation.created
|
|
||||||
))
|
|
||||||
link.add(drawing.rect(start, end, class_=class_))
|
|
||||||
link.add(drawing.text("add device", insert=text, class_='add-device'))
|
|
||||||
|
|
||||||
def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width):
|
|
||||||
|
|
||||||
drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height)
|
|
||||||
|
|
||||||
unit_cursor = 0
|
|
||||||
for ru in range(0, self.u_height):
|
|
||||||
start_y = ru * unit_height
|
|
||||||
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2)
|
|
||||||
unit = ru + 1 if self.desc_units else self.u_height - ru
|
|
||||||
drawing.add(
|
|
||||||
drawing.text(str(unit), position_coordinates, class_="unit")
|
|
||||||
)
|
|
||||||
|
|
||||||
for unit in elevation:
|
|
||||||
|
|
||||||
# Loop through all units in the elevation
|
|
||||||
device = unit['device']
|
|
||||||
height = unit.get('height', 1)
|
|
||||||
|
|
||||||
# Setup drawing coordinates
|
|
||||||
start_y = unit_cursor * unit_height
|
|
||||||
end_y = unit_height * height
|
|
||||||
start_cordinates = (legend_width, start_y)
|
|
||||||
end_cordinates = (legend_width + unit_width, end_y)
|
|
||||||
text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
|
|
||||||
|
|
||||||
# Draw the device
|
|
||||||
if device and device.face == face:
|
|
||||||
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
|
||||||
elif device and device.device_type.is_full_depth:
|
|
||||||
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
|
||||||
else:
|
|
||||||
# Draw shallow devices, reservations, or empty units
|
|
||||||
class_ = 'slot'
|
|
||||||
reservation = reserved_units.get(unit["id"])
|
|
||||||
if device:
|
|
||||||
class_ += ' occupied'
|
|
||||||
if reservation:
|
|
||||||
class_ += ' reserved'
|
|
||||||
self._draw_empty(
|
|
||||||
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
|
|
||||||
)
|
|
||||||
|
|
||||||
unit_cursor += height
|
|
||||||
|
|
||||||
# Wrap the drawing with a border
|
|
||||||
drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack'))
|
|
||||||
|
|
||||||
return drawing
|
|
||||||
|
|
||||||
def merge_elevations(self, face):
|
|
||||||
elevation = self.get_rack_units(face=face, expand_devices=False)
|
|
||||||
other_face = DeviceFaceChoices.FACE_FRONT if face == DeviceFaceChoices.FACE_REAR else DeviceFaceChoices.FACE_REAR
|
|
||||||
other = self.get_rack_units(face=other_face)
|
|
||||||
|
|
||||||
unit_cursor = 0
|
|
||||||
for u in elevation:
|
|
||||||
o = other[unit_cursor]
|
|
||||||
if not u['device'] and o['device']:
|
|
||||||
u['device'] = o['device']
|
|
||||||
u['height'] = 1
|
|
||||||
unit_cursor += u.get('height', 1)
|
|
||||||
|
|
||||||
return elevation
|
|
||||||
|
|
||||||
def get_elevation_svg(
|
|
||||||
self,
|
|
||||||
face=DeviceFaceChoices.FACE_FRONT,
|
|
||||||
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
|
|
||||||
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
|
|
||||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
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.merge_elevations(face)
|
|
||||||
reserved_units = self.get_reserved_units()
|
|
||||||
|
|
||||||
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width)
|
|
||||||
|
|
||||||
|
|
||||||
class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
|
|
||||||
"""
|
"""
|
||||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||||
@ -835,6 +661,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
|
|||||||
reserved_units[u] = r
|
reserved_units[u] = r
|
||||||
return reserved_units
|
return reserved_units
|
||||||
|
|
||||||
|
def get_elevation_svg(
|
||||||
|
self,
|
||||||
|
face=DeviceFaceChoices.FACE_FRONT,
|
||||||
|
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
|
||||||
|
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
|
||||||
|
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||||
|
include_images=True
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return an SVG of the rack elevation
|
||||||
|
|
||||||
|
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
|
||||||
|
:param unit_width: Width in pixels 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
|
||||||
|
:param legend_width: Width of the unit legend, in pixels
|
||||||
|
:param include_images: Embed front/rear device images where available
|
||||||
|
"""
|
||||||
|
elevation = RackElevationSVG(self, include_images=include_images)
|
||||||
|
|
||||||
|
return elevation.render(face, unit_width, unit_height, legend_width)
|
||||||
|
|
||||||
def get_0u_devices(self):
|
def get_0u_devices(self):
|
||||||
return self.devices.filter(position=0)
|
return self.devices.filter(position=0)
|
||||||
|
|
||||||
@ -1025,6 +873,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
|||||||
help_text='Parent devices house child devices in device bays. Leave blank '
|
help_text='Parent devices house child devices in device bays. Leave blank '
|
||||||
'if this device type is neither a parent nor a child.'
|
'if this device type is neither a parent nor a child.'
|
||||||
)
|
)
|
||||||
|
front_image = models.ImageField(
|
||||||
|
upload_to='devicetype-images',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
rear_image = models.ImageField(
|
||||||
|
upload_to='devicetype-images',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
comments = models.TextField(
|
comments = models.TextField(
|
||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
@ -1056,6 +912,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
|||||||
# Save a copy of u_height for validation in clean()
|
# Save a copy of u_height for validation in clean()
|
||||||
self._original_u_height = self.u_height
|
self._original_u_height = self.u_height
|
||||||
|
|
||||||
|
# Save references to the original front/rear images
|
||||||
|
self._original_front_image = self.front_image
|
||||||
|
self._original_rear_image = self.rear_image
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('dcim:devicetype', args=[self.pk])
|
return reverse('dcim:devicetype', args=[self.pk])
|
||||||
|
|
||||||
@ -1175,6 +1035,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
|||||||
'u_height': "Child device types must be 0U."
|
'u_height': "Child device types must be 0U."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
ret = super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Delete any previously uploaded image files that are no longer in use
|
||||||
|
if self.front_image != self._original_front_image:
|
||||||
|
self._original_front_image.delete(save=False)
|
||||||
|
if self.rear_image != self._original_rear_image:
|
||||||
|
self._original_rear_image.delete(save=False)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
# Delete any uploaded image files
|
||||||
|
if self.front_image:
|
||||||
|
self.front_image.delete(save=False)
|
||||||
|
if self.rear_image:
|
||||||
|
self.rear_image.delete(save=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
return '{} {}'.format(self.manufacturer.name, self.model)
|
return '{} {}'.format(self.manufacturer.name, self.model)
|
||||||
|
@ -795,11 +795,12 @@ class InterfaceTable(BaseTable):
|
|||||||
class InterfaceDetailTable(DeviceComponentDetailTable):
|
class InterfaceDetailTable(DeviceComponentDetailTable):
|
||||||
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
|
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
|
||||||
name = tables.LinkColumn()
|
name = tables.LinkColumn()
|
||||||
|
enabled = BooleanColumn()
|
||||||
|
|
||||||
class Meta(InterfaceTable.Meta):
|
class Meta(InterfaceTable.Meta):
|
||||||
order_by = ('parent', 'name')
|
order_by = ('parent', 'name')
|
||||||
fields = ('pk', 'parent', 'name', 'type', 'description', 'cable')
|
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||||
sequence = ('pk', 'parent', 'name', 'type', 'description', 'cable')
|
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||||
|
|
||||||
|
|
||||||
class FrontPortTable(BaseTable):
|
class FrontPortTable(BaseTable):
|
||||||
|
@ -31,6 +31,7 @@ from utilities.views import (
|
|||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
from . import filters, forms, tables
|
from . import filters, forms, tables
|
||||||
from .choices import DeviceFaceChoices
|
from .choices import DeviceFaceChoices
|
||||||
|
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||||
from .models import (
|
from .models import (
|
||||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||||
@ -1181,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
|
|||||||
def get(self, request, pk):
|
def get(self, request, pk):
|
||||||
|
|
||||||
device = get_object_or_404(Device, pk=pk)
|
device = get_object_or_404(Device, pk=pk)
|
||||||
interfaces = device.vc_interfaces.connectable().prefetch_related(
|
interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
|
||||||
'_connected_interface__device'
|
'_connected_interface__device'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -40,10 +40,14 @@ class GraphSerializer(ValidatedModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class RenderedGraphSerializer(serializers.ModelSerializer):
|
class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||||
embed_url = serializers.SerializerMethodField()
|
embed_url = serializers.SerializerMethodField(
|
||||||
embed_link = serializers.SerializerMethodField()
|
read_only=True
|
||||||
|
)
|
||||||
|
embed_link = serializers.SerializerMethodField(
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
type = ContentTypeField(
|
type = ContentTypeField(
|
||||||
queryset=ContentType.objects.all()
|
read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -62,6 +66,9 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
|
|||||||
#
|
#
|
||||||
|
|
||||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||||
|
content_type = ContentTypeField(
|
||||||
|
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
|
||||||
|
)
|
||||||
template_language = ChoiceField(
|
template_language = ChoiceField(
|
||||||
choices=TemplateLanguageChoices,
|
choices=TemplateLanguageChoices,
|
||||||
default=TemplateLanguageChoices.LANGUAGE_JINJA2
|
default=TemplateLanguageChoices.LANGUAGE_JINJA2
|
||||||
|
@ -163,17 +163,17 @@ class ExportTemplateTest(APITestCase):
|
|||||||
|
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.content_type = ContentType.objects.get_for_model(Device)
|
content_type = ContentType.objects.get_for_model(Device)
|
||||||
self.exporttemplate1 = ExportTemplate.objects.create(
|
self.exporttemplate1 = ExportTemplate.objects.create(
|
||||||
content_type=self.content_type, name='Test Export Template 1',
|
content_type=content_type, name='Test Export Template 1',
|
||||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||||
)
|
)
|
||||||
self.exporttemplate2 = ExportTemplate.objects.create(
|
self.exporttemplate2 = ExportTemplate.objects.create(
|
||||||
content_type=self.content_type, name='Test Export Template 2',
|
content_type=content_type, name='Test Export Template 2',
|
||||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||||
)
|
)
|
||||||
self.exporttemplate3 = ExportTemplate.objects.create(
|
self.exporttemplate3 = ExportTemplate.objects.create(
|
||||||
content_type=self.content_type, name='Test Export Template 3',
|
content_type=content_type, name='Test Export Template 3',
|
||||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -194,7 +194,7 @@ class ExportTemplateTest(APITestCase):
|
|||||||
def test_create_exporttemplate(self):
|
def test_create_exporttemplate(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'content_type': self.content_type.pk,
|
'content_type': 'dcim.device',
|
||||||
'name': 'Test Export Template 4',
|
'name': 'Test Export Template 4',
|
||||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
}
|
}
|
||||||
@ -205,7 +205,7 @@ class ExportTemplateTest(APITestCase):
|
|||||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||||
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
|
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
|
||||||
self.assertEqual(exporttemplate4.content_type_id, data['content_type'])
|
self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
|
||||||
self.assertEqual(exporttemplate4.name, data['name'])
|
self.assertEqual(exporttemplate4.name, data['name'])
|
||||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||||
|
|
||||||
@ -213,17 +213,17 @@ class ExportTemplateTest(APITestCase):
|
|||||||
|
|
||||||
data = [
|
data = [
|
||||||
{
|
{
|
||||||
'content_type': self.content_type.pk,
|
'content_type': 'dcim.device',
|
||||||
'name': 'Test Export Template 4',
|
'name': 'Test Export Template 4',
|
||||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'content_type': self.content_type.pk,
|
'content_type': 'dcim.device',
|
||||||
'name': 'Test Export Template 5',
|
'name': 'Test Export Template 5',
|
||||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'content_type': self.content_type.pk,
|
'content_type': 'dcim.device',
|
||||||
'name': 'Test Export Template 6',
|
'name': 'Test Export Template 6',
|
||||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
},
|
},
|
||||||
@ -241,7 +241,7 @@ class ExportTemplateTest(APITestCase):
|
|||||||
def test_update_exporttemplate(self):
|
def test_update_exporttemplate(self):
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'content_type': self.content_type.pk,
|
'content_type': 'dcim.device',
|
||||||
'name': 'Test Export Template X',
|
'name': 'Test Export Template X',
|
||||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,7 @@ from django_tables2 import RequestConfig
|
|||||||
|
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.paginator import EnhancedPaginator
|
from utilities.paginator import EnhancedPaginator
|
||||||
|
from utilities.utils import shallow_compare_dict
|
||||||
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
|
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
|
||||||
from . import filters, forms
|
from . import filters, forms
|
||||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||||
@ -207,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View):
|
|||||||
orderable=False
|
orderable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objectchanges = ObjectChange.objects.filter(
|
||||||
|
changed_object_type=objectchange.changed_object_type,
|
||||||
|
changed_object_id=objectchange.changed_object_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_change = objectchanges.filter(time__gt=objectchange.time).order_by('time').first()
|
||||||
|
prev_change = objectchanges.filter(time__lt=objectchange.time).order_by('-time').first()
|
||||||
|
|
||||||
|
if prev_change:
|
||||||
|
diff_added = shallow_compare_dict(
|
||||||
|
prev_change.object_data,
|
||||||
|
objectchange.object_data,
|
||||||
|
exclude=['last_updated'],
|
||||||
|
)
|
||||||
|
diff_removed = {x: prev_change.object_data.get(x) for x in diff_added}
|
||||||
|
else:
|
||||||
|
# No previous change; this is the initial change that added the object
|
||||||
|
diff_added = diff_removed = objectchange.object_data
|
||||||
|
|
||||||
return render(request, 'extras/objectchange.html', {
|
return render(request, 'extras/objectchange.html', {
|
||||||
'objectchange': objectchange,
|
'objectchange': objectchange,
|
||||||
|
'diff_added': diff_added,
|
||||||
|
'diff_removed': diff_removed,
|
||||||
|
'next_change': next_change,
|
||||||
|
'prev_change': prev_change,
|
||||||
'related_changes_table': related_changes_table,
|
'related_changes_table': related_changes_table,
|
||||||
'related_changes_count': related_changes.count()
|
'related_changes_count': related_changes.count()
|
||||||
})
|
})
|
||||||
|
@ -164,9 +164,19 @@ class NetFamily(Transform):
|
|||||||
|
|
||||||
|
|
||||||
class NetMaskLength(Transform):
|
class NetMaskLength(Transform):
|
||||||
lookup_name = 'net_mask_length'
|
|
||||||
function = 'MASKLEN'
|
function = 'MASKLEN'
|
||||||
|
lookup_name = 'net_mask_length'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def output_field(self):
|
def output_field(self):
|
||||||
return IntegerField()
|
return IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class Host(Transform):
|
||||||
|
function = 'HOST'
|
||||||
|
lookup_name = 'host'
|
||||||
|
|
||||||
|
|
||||||
|
class Inet(Transform):
|
||||||
|
function = 'INET'
|
||||||
|
lookup_name = 'inet'
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.expressions import RawSQL
|
|
||||||
|
from ipam.lookups import Host, Inet
|
||||||
|
|
||||||
|
|
||||||
class IPAddressManager(models.Manager):
|
class IPAddressManager(models.Manager):
|
||||||
@ -13,4 +14,4 @@ class IPAddressManager(models.Manager):
|
|||||||
IP address as a /32 or /128.
|
IP address as a /32 or /128.
|
||||||
"""
|
"""
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('host')
|
return qs.order_by(Inet(Host('address')))
|
||||||
|
2
netbox/media/devicetype-images/.gitignore
vendored
Normal file
2
netbox/media/devicetype-images/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
@ -179,25 +179,9 @@ nav ul.pagination {
|
|||||||
|
|
||||||
/* Racks */
|
/* Racks */
|
||||||
div.rack_header {
|
div.rack_header {
|
||||||
margin-left: 36px;
|
margin-left: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 230px;
|
width: 220px;
|
||||||
}
|
|
||||||
ul.rack_legend {
|
|
||||||
float: left;
|
|
||||||
list-style-type: none;
|
|
||||||
margin-right: 6px;
|
|
||||||
padding: 0;
|
|
||||||
width: 30px;
|
|
||||||
}
|
|
||||||
ul.rack_legend li {
|
|
||||||
color: #c0c0c0;
|
|
||||||
display: block;
|
|
||||||
font-size: 10px;
|
|
||||||
height: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 5px 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Devices */
|
/* Devices */
|
||||||
|
@ -14,7 +14,7 @@ text {
|
|||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: black;
|
stroke: black;
|
||||||
stroke-width: 3px;
|
stroke-width: 2px;
|
||||||
}
|
}
|
||||||
.slot {
|
.slot {
|
||||||
fill: #f7f7f7;
|
fill: #f7f7f7;
|
||||||
@ -56,7 +56,6 @@ text {
|
|||||||
.blocked:hover+.add-device {
|
.blocked:hover+.add-device {
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 5px 0px;
|
padding: 5px 0px;
|
||||||
@ -65,3 +64,6 @@ text {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||||
}
|
}
|
||||||
|
.hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
@ -42,17 +42,23 @@ $(document).ready(function() {
|
|||||||
return s.substring(0, num_chars); // Trim to first num_chars chars
|
return s.substring(0, num_chars); // Trim to first num_chars chars
|
||||||
}
|
}
|
||||||
var slug_field = $('#id_slug');
|
var slug_field = $('#id_slug');
|
||||||
slug_field.change(function() {
|
|
||||||
$(this).attr('_changed', true);
|
|
||||||
});
|
|
||||||
if (slug_field) {
|
if (slug_field) {
|
||||||
var slug_source = $('#id_' + slug_field.attr('slug-source'));
|
var slug_source = $('#id_' + slug_field.attr('slug-source'));
|
||||||
var slug_length = slug_field.attr('maxlength');
|
var slug_length = slug_field.attr('maxlength');
|
||||||
|
if (slug_field.val()) {
|
||||||
|
slug_field.attr('_changed', true);
|
||||||
|
}
|
||||||
|
slug_field.change(function() {
|
||||||
|
$(this).attr('_changed', true);
|
||||||
|
});
|
||||||
slug_source.on('keyup change', function() {
|
slug_source.on('keyup change', function() {
|
||||||
if (slug_field && !slug_field.attr('_changed')) {
|
if (slug_field && !slug_field.attr('_changed')) {
|
||||||
slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
|
slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
$('button.reslugify').click(function() {
|
||||||
|
slug_field.val(slugify(slug_source.val(), (slug_length ? slug_length : 50)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bulk edit nullification
|
// Bulk edit nullification
|
||||||
|
16
netbox/project-static/js/rack_elevations.js
Normal file
16
netbox/project-static/js/rack_elevations.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Toggle the display of device images within an SVG rack elevation
|
||||||
|
$('button.toggle-images').click(function() {
|
||||||
|
var selected = $(this).attr('selected');
|
||||||
|
var rack_front = $("#rack_front");
|
||||||
|
var rack_rear = $("#rack_rear");
|
||||||
|
if (selected) {
|
||||||
|
$('.device-image', rack_front.contents()).addClass('hidden');
|
||||||
|
$('.device-image', rack_rear.contents()).addClass('hidden');
|
||||||
|
} else {
|
||||||
|
$('.device-image', rack_front.contents()).removeClass('hidden');
|
||||||
|
$('.device-image', rack_rear.contents()).removeClass('hidden');
|
||||||
|
}
|
||||||
|
$(this).attr('selected', !selected);
|
||||||
|
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||||
|
return false;
|
||||||
|
});
|
@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
|||||||
role = DynamicModelMultipleChoiceField(
|
role = DynamicModelMultipleChoiceField(
|
||||||
queryset=SecretRole.objects.all(),
|
queryset=SecretRole.objects.all(),
|
||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
required=True,
|
required=False,
|
||||||
widget=APISelectMultiple(
|
widget=APISelectMultiple(
|
||||||
api_url="/api/secrets/secret-roles/",
|
api_url="/api/secrets/secret-roles/",
|
||||||
value_field="slug",
|
value_field="slug",
|
||||||
|
@ -302,8 +302,8 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
|||||||
Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the
|
Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the
|
||||||
ciphertext; this string is stored as plain text in the database.
|
ciphertext; this string is stored as plain text in the database.
|
||||||
|
|
||||||
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
|
A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
|
||||||
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
|
a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
|
||||||
"""
|
"""
|
||||||
device = models.ForeignKey(
|
device = models.ForeignKey(
|
||||||
to='dcim.Device',
|
to='dcim.Device',
|
||||||
@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
|||||||
blank=True
|
blank=True
|
||||||
)
|
)
|
||||||
ciphertext = models.BinaryField(
|
ciphertext = models.BinaryField(
|
||||||
max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded
|
max_length=65568, # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding
|
||||||
editable=False
|
editable=False
|
||||||
)
|
)
|
||||||
hash = models.CharField(
|
hash = models.CharField(
|
||||||
@ -388,10 +388,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
|||||||
else:
|
else:
|
||||||
pad_length = 0
|
pad_length = 0
|
||||||
|
|
||||||
# Python 2 compatibility
|
|
||||||
if sys.version_info[0] < 3:
|
|
||||||
header = chr(len(s) >> 8) + chr(len(s) % 256)
|
|
||||||
else:
|
|
||||||
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
|
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
|
||||||
|
|
||||||
return header + s + os.urandom(pad_length)
|
return header + s + os.urandom(pad_length)
|
||||||
|
@ -85,14 +85,19 @@ class UserKeyTestCase(TestCase):
|
|||||||
|
|
||||||
class SecretTestCase(TestCase):
|
class SecretTestCase(TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
# Generate a random key for encryption/decryption of secrets
|
||||||
|
cls.secret_key = generate_random_key()
|
||||||
|
|
||||||
def test_01_encrypt_decrypt(self):
|
def test_01_encrypt_decrypt(self):
|
||||||
"""
|
"""
|
||||||
Test basic encryption and decryption functionality using a random master key.
|
Test basic encryption and decryption functionality using a random master key.
|
||||||
"""
|
"""
|
||||||
plaintext = string.printable * 2
|
plaintext = string.printable * 2
|
||||||
secret_key = generate_random_key()
|
|
||||||
s = Secret(plaintext=plaintext)
|
s = Secret(plaintext=plaintext)
|
||||||
s.encrypt(secret_key)
|
s.encrypt(self.secret_key)
|
||||||
|
|
||||||
# Ensure plaintext is deleted upon encryption
|
# Ensure plaintext is deleted upon encryption
|
||||||
self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
|
self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
|
||||||
@ -112,7 +117,7 @@ class SecretTestCase(TestCase):
|
|||||||
self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
|
self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
|
||||||
|
|
||||||
# Test decryption
|
# Test decryption
|
||||||
s.decrypt(secret_key)
|
s.decrypt(self.secret_key)
|
||||||
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
|
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
|
||||||
|
|
||||||
def test_02_ciphertext_uniqueness(self):
|
def test_02_ciphertext_uniqueness(self):
|
||||||
@ -120,15 +125,45 @@ class SecretTestCase(TestCase):
|
|||||||
Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
|
Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
|
||||||
"""
|
"""
|
||||||
plaintext = "1234567890abcdef"
|
plaintext = "1234567890abcdef"
|
||||||
secret_key = generate_random_key()
|
|
||||||
ivs = []
|
ivs = []
|
||||||
ciphertexts = []
|
ciphertexts = []
|
||||||
for i in range(1, 51):
|
for i in range(1, 51):
|
||||||
s = Secret(plaintext=plaintext)
|
s = Secret(plaintext=plaintext)
|
||||||
s.encrypt(secret_key)
|
s.encrypt(self.secret_key)
|
||||||
ivs.append(s.ciphertext[0:16])
|
ivs.append(s.ciphertext[0:16])
|
||||||
ciphertexts.append(s.ciphertext[16:32])
|
ciphertexts.append(s.ciphertext[16:32])
|
||||||
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
|
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
|
||||||
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
|
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
|
||||||
duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
|
duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
|
||||||
self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
|
self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
|
||||||
|
|
||||||
|
def test_minimum_length(self):
|
||||||
|
"""
|
||||||
|
Test enforcement of the minimum length for ciphertexts.
|
||||||
|
"""
|
||||||
|
plaintext = 'A' # One-byte plaintext
|
||||||
|
secret = Secret(plaintext=plaintext)
|
||||||
|
secret.encrypt(self.secret_key)
|
||||||
|
|
||||||
|
# 16B IV + 2B length + 1B secret + 61B padding = 80 bytes
|
||||||
|
self.assertEqual(len(secret.ciphertext), 80)
|
||||||
|
self.assertIsNone(secret.plaintext)
|
||||||
|
|
||||||
|
secret.decrypt(self.secret_key)
|
||||||
|
self.assertEqual(secret.plaintext, plaintext)
|
||||||
|
|
||||||
|
def test_maximum_length(self):
|
||||||
|
"""
|
||||||
|
Test encrypting a plaintext value of the maximum length.
|
||||||
|
"""
|
||||||
|
plaintext = '0123456789abcdef' * 4096
|
||||||
|
plaintext = plaintext[:65535] # 65,535 chars
|
||||||
|
secret = Secret(plaintext=plaintext)
|
||||||
|
secret.encrypt(self.secret_key)
|
||||||
|
|
||||||
|
# 16B IV + 2B length + 65535B secret + 15B padding = 65568 bytes
|
||||||
|
self.assertEqual(len(secret.ciphertext), 65568)
|
||||||
|
self.assertIsNone(secret.plaintext)
|
||||||
|
|
||||||
|
secret.decrypt(self.secret_key)
|
||||||
|
self.assertEqual(secret.plaintext, plaintext)
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
|
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
|
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -31,7 +31,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Changelog</a>
|
<a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -119,7 +119,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
|
<a href="{% url 'dcim:device_changelog' pk=device.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
|
<a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -109,6 +109,30 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Front Image</td>
|
||||||
|
<td>
|
||||||
|
{% if devicetype.front_image %}
|
||||||
|
<a href="{{ devicetype.front_image.url }}">
|
||||||
|
<img src="{{ devicetype.front_image.url }}" alt="{{ devicetype.front_image.name }}" class="img-responsive" />
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Rear Image</td>
|
||||||
|
<td>
|
||||||
|
{% if devicetype.rear_image %}
|
||||||
|
<a href="{{ devicetype.rear_image.url }}">
|
||||||
|
<img src="{{ devicetype.rear_image.url }}" alt="{{ devicetype.rear_image.name }}" class="img-responsive" />
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Instances</td>
|
<td>Instances</td>
|
||||||
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
|
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
|
||||||
|
@ -14,6 +14,13 @@
|
|||||||
{% render_field form.subdevice_role %}
|
{% render_field form.subdevice_role %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading"><strong>Rack Images</strong></div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% render_field form.front_image %}
|
||||||
|
{% render_field form.rear_image %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% if form.custom_fields %}
|
{% if form.custom_fields %}
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
{% load helpers %}
|
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" id="rack_{{ face }}"></object>
|
||||||
|
<div class="text-center text-small">
|
||||||
<div class="rack_frame">
|
<a href="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" id="rack_{{ face }}">
|
||||||
|
<i class="fa fa-download"></i> Save SVG
|
||||||
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Changelog</a>
|
<a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Changelog</a>
|
<a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -121,18 +121,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel panel-default">
|
{% include 'inc/custom_fields_panel.html' with obj=powerfeed %}
|
||||||
<div class="panel-heading">
|
{% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %}
|
||||||
<strong>Comments</strong>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body rendered-markdown">
|
|
||||||
{% if powerfeed.comments %}
|
|
||||||
{{ powerfeed.comments|gfm }}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">None</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
@ -162,6 +152,18 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<strong>Comments</strong>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body rendered-markdown">
|
||||||
|
{% if powerfeed.comments %}
|
||||||
|
{{ powerfeed.comments|gfm }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Changelog</a>
|
<a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
{% load buttons %}
|
{% load buttons %}
|
||||||
{% load custom_links %}
|
{% load custom_links %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<div class="row noprint">
|
<div class="row noprint">
|
||||||
@ -45,6 +46,9 @@
|
|||||||
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
|
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
|
||||||
{% include 'inc/created_updated.html' with obj=rack %}
|
{% include 'inc/created_updated.html' with obj=rack %}
|
||||||
<div class="pull-right noprint">
|
<div class="pull-right noprint">
|
||||||
|
<button class="btn btn-sm btn-default toggle-images" selected="selected">
|
||||||
|
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
|
||||||
|
</button>
|
||||||
{% custom_links rack %}
|
{% custom_links rack %}
|
||||||
</div>
|
</div>
|
||||||
<ul class="nav nav-tabs">
|
<ul class="nav nav-tabs">
|
||||||
@ -53,7 +57,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
|
<a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -368,9 +372,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block javascript %}
|
{% block javascript %}
|
||||||
<script type="text/javascript">
|
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
|
||||||
$(function() {
|
|
||||||
$('[data-toggle="popover"]').popover()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
{% extends '_base.html' %}
|
{% extends '_base.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="btn-group pull-right noprint" role="group">
|
<div class="btn-group pull-right noprint" role="group">
|
||||||
|
<button class="btn btn-default toggle-images" selected="selected">
|
||||||
|
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
|
||||||
|
</button>
|
||||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
||||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||||
</div>
|
</div>
|
||||||
@ -41,9 +45,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block javascript %}
|
{% block javascript %}
|
||||||
<script type="text/javascript">
|
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
|
||||||
$(function() {
|
|
||||||
$('[data-toggle="popover"]').popover()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
|
<a href="{% url 'dcim:site_changelog' slug=site.slug %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% extends base_template %}
|
{% extends base_template %}
|
||||||
|
|
||||||
{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %}
|
{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Change Log{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if obj %}<h1>{{ obj }}</h1>{% endif %}
|
{% if obj %}<h1>{{ obj }}</h1>{% endif %}
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<div class="row noprint">
|
<div class="row noprint">
|
||||||
<div class="col-sm-8 col-md-9">
|
<div class="col-sm-8 col-md-9">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li>
|
<li><a href="{% url 'extras:objectchange_list' %}">Change Log</a></li>
|
||||||
{% if objectchange.related_object.get_absolute_url %}
|
{% if objectchange.related_object.get_absolute_url %}
|
||||||
<li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
|
<li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
|
||||||
{% elif objectchange.changed_object.get_absolute_url %}
|
{% elif objectchange.changed_object.get_absolute_url %}
|
||||||
@ -83,6 +83,35 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<strong>Difference</strong>
|
||||||
|
<div class="btn-group btn-group-xs pull-right noprint">
|
||||||
|
<a {% if prev_change %}href="{% url 'extras:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
|
||||||
|
<span class="fa fa-chevron-left" aria-hidden="true"></span> Previous
|
||||||
|
</a>
|
||||||
|
<a {% if next_change %}href="{% url 'extras:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
|
||||||
|
Next <span class="fa fa-chevron-right" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
{% if diff_added == diff_removed %}
|
||||||
|
<span class="text-muted" style="margin-left: 10px;">
|
||||||
|
{% if objectchange.action == 'create' %}
|
||||||
|
Object created
|
||||||
|
{% elif objectchange.action == 'delete' %}
|
||||||
|
Object deleted
|
||||||
|
{% else %}
|
||||||
|
No changes
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<pre style="background-color: #ffdce0;">{{ diff_removed|render_json }}</pre>
|
||||||
|
<pre style="background-color: #cdffd8;">{{ diff_added|render_json }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
|
<a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<strong>Changelog</strong>
|
<strong>Change Log</strong>
|
||||||
</div>
|
</div>
|
||||||
{% if changelog and perms.extras.view_objectchange %}
|
{% if changelog and perms.extras.view_objectchange %}
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
|
<a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
|
<a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -69,7 +69,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
|
<a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -55,7 +55,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
|
<a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -46,7 +46,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>
|
<a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Changelog</a>
|
<a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Changelog</a>
|
<a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Changelog</a>
|
<a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.extras.view_objectchange %}
|
{% if perms.extras.view_objectchange %}
|
||||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||||
<a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
|
<a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Change Log</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -56,8 +56,11 @@ class NaturalOrderingField(models.CharField):
|
|||||||
"""
|
"""
|
||||||
Generate a naturalized value from the target field
|
Generate a naturalized value from the target field
|
||||||
"""
|
"""
|
||||||
value = getattr(model_instance, self.target_field)
|
original_value = getattr(model_instance, self.target_field)
|
||||||
return self.naturalize_function(value, max_length=self.max_length)
|
naturalized_value = self.naturalize_function(original_value, max_length=self.max_length)
|
||||||
|
setattr(model_instance, self.attname, naturalized_value)
|
||||||
|
|
||||||
|
return naturalized_value
|
||||||
|
|
||||||
def deconstruct(self):
|
def deconstruct(self):
|
||||||
kwargs = super().deconstruct()[3] # Pass kwargs from CharField
|
kwargs = super().deconstruct()[3] # Pass kwargs from CharField
|
||||||
|
@ -132,6 +132,13 @@ class SmallTextarea(forms.Textarea):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SlugWidget(forms.TextInput):
|
||||||
|
"""
|
||||||
|
Subclass TextInput and add a slug regeneration button next to the form field.
|
||||||
|
"""
|
||||||
|
template_name = 'widgets/sluginput.html'
|
||||||
|
|
||||||
|
|
||||||
class ColorSelect(forms.Select):
|
class ColorSelect(forms.Select):
|
||||||
"""
|
"""
|
||||||
Extends the built-in Select widget to colorize each <option>.
|
Extends the built-in Select widget to colorize each <option>.
|
||||||
@ -534,7 +541,8 @@ class SlugField(forms.SlugField):
|
|||||||
def __init__(self, slug_source='name', *args, **kwargs):
|
def __init__(self, slug_source='name', *args, **kwargs):
|
||||||
label = kwargs.pop('label', "Slug")
|
label = kwargs.pop('label', "Slug")
|
||||||
help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
|
help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
|
||||||
super().__init__(label=label, help_text=help_text, *args, **kwargs)
|
widget = kwargs.pop('widget', SlugWidget)
|
||||||
|
super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
|
||||||
self.widget.attrs['slug-source'] = slug_source
|
self.widget.attrs['slug-source'] = slug_source
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,7 +7,8 @@ INTERFACE_NAME_REGEX = r'(^(?P<type>[^\d\.:]+)?)' \
|
|||||||
r'((?P<subposition>\d+)/)?' \
|
r'((?P<subposition>\d+)/)?' \
|
||||||
r'((?P<id>\d+))?' \
|
r'((?P<id>\d+))?' \
|
||||||
r'(:(?P<channel>\d+))?' \
|
r'(:(?P<channel>\d+))?' \
|
||||||
r'(.(?P<vc>\d+)$)?'
|
r'(\.(?P<vc>\d+))?' \
|
||||||
|
r'(?P<remainder>.*)$'
|
||||||
|
|
||||||
|
|
||||||
def naturalize(value, max_length, integer_places=8):
|
def naturalize(value, max_length, integer_places=8):
|
||||||
@ -50,7 +51,7 @@ def naturalize_interface(value, max_length):
|
|||||||
:param value: The value to be naturalized
|
:param value: The value to be naturalized
|
||||||
:param max_length: The maximum length of the returned string. Characters beyond this length will be stripped.
|
:param max_length: The maximum length of the returned string. Characters beyond this length will be stripped.
|
||||||
"""
|
"""
|
||||||
output = []
|
output = ''
|
||||||
match = re.search(INTERFACE_NAME_REGEX, value)
|
match = re.search(INTERFACE_NAME_REGEX, value)
|
||||||
if match is None:
|
if match is None:
|
||||||
return value
|
return value
|
||||||
@ -60,21 +61,25 @@ def naturalize_interface(value, max_length):
|
|||||||
for part_name in ('slot', 'subslot', 'position', 'subposition'):
|
for part_name in ('slot', 'subslot', 'position', 'subposition'):
|
||||||
part = match.group(part_name)
|
part = match.group(part_name)
|
||||||
if part is not None:
|
if part is not None:
|
||||||
output.append(part.rjust(4, '0'))
|
output += part.rjust(4, '0')
|
||||||
else:
|
else:
|
||||||
output.append('9999')
|
output += '9999'
|
||||||
|
|
||||||
# Append the type, if any.
|
# Append the type, if any.
|
||||||
if match.group('type') is not None:
|
if match.group('type') is not None:
|
||||||
output.append(match.group('type'))
|
output += match.group('type')
|
||||||
|
|
||||||
# Finally, append any remaining fields, left-padding to six digits each.
|
# Append any remaining fields, left-padding to six digits each.
|
||||||
for part_name in ('id', 'channel', 'vc'):
|
for part_name in ('id', 'channel', 'vc'):
|
||||||
part = match.group(part_name)
|
part = match.group(part_name)
|
||||||
if part is not None:
|
if part is not None:
|
||||||
output.append(part.rjust(6, '0'))
|
output += part.rjust(6, '0')
|
||||||
else:
|
else:
|
||||||
output.append('000000')
|
output += '000000'
|
||||||
|
|
||||||
ret = ''.join(output)
|
# Finally, naturalize any remaining text and append it
|
||||||
return ret[:max_length]
|
if match.group('remainder') is not None and len(output) < max_length:
|
||||||
|
remainder = naturalize(match.group('remainder'), max_length - len(output))
|
||||||
|
output += remainder
|
||||||
|
|
||||||
|
return output[:max_length]
|
||||||
|
8
netbox/utilities/templates/widgets/sluginput.html
Normal file
8
netbox/utilities/templates/widgets/sluginput.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<div class="input-group">
|
||||||
|
{% include "django/forms/widgets/input.html" %}
|
||||||
|
<span class="input-group-btn">
|
||||||
|
<button class="btn btn-default reslugify" type="button" title="Regenerate slug">
|
||||||
|
<i class="fa fa-refresh"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
@ -9,8 +9,8 @@ class NaturalizationTestCase(TestCase):
|
|||||||
"""
|
"""
|
||||||
def test_naturalize(self):
|
def test_naturalize(self):
|
||||||
|
|
||||||
data = (
|
|
||||||
# Original, naturalized
|
# Original, naturalized
|
||||||
|
data = (
|
||||||
('abc', 'abc'),
|
('abc', 'abc'),
|
||||||
('123', '00000123'),
|
('123', '00000123'),
|
||||||
('abc123', 'abc00000123'),
|
('abc123', 'abc00000123'),
|
||||||
@ -21,15 +21,16 @@ class NaturalizationTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for origin, naturalized in data:
|
for origin, naturalized in data:
|
||||||
self.assertEqual(naturalize(origin, max_length=50), naturalized)
|
self.assertEqual(naturalize(origin, max_length=100), naturalized)
|
||||||
|
|
||||||
def test_naturalize_max_length(self):
|
def test_naturalize_max_length(self):
|
||||||
self.assertEqual(naturalize('abc123def456', max_length=10), 'abc0000012')
|
self.assertEqual(naturalize('abc123def456', max_length=10), 'abc0000012')
|
||||||
|
|
||||||
def test_naturalize_interface(self):
|
def test_naturalize_interface(self):
|
||||||
|
|
||||||
data = (
|
|
||||||
# Original, naturalized
|
# Original, naturalized
|
||||||
|
data = (
|
||||||
|
# IOS/JunOS-style
|
||||||
('Gi', '9999999999999999Gi000000000000000000'),
|
('Gi', '9999999999999999Gi000000000000000000'),
|
||||||
('Gi1', '9999999999999999Gi000001000000000000'),
|
('Gi1', '9999999999999999Gi000001000000000000'),
|
||||||
('Gi1/2', '0001999999999999Gi000002000000000000'),
|
('Gi1/2', '0001999999999999Gi000002000000000000'),
|
||||||
@ -40,10 +41,16 @@ class NaturalizationTestCase(TestCase):
|
|||||||
('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
|
('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
|
||||||
('Gi1:2', '9999999999999999Gi000001000002000000'),
|
('Gi1:2', '9999999999999999Gi000001000002000000'),
|
||||||
('Gi1:2.3', '9999999999999999Gi000001000002000003'),
|
('Gi1:2.3', '9999999999999999Gi000001000002000003'),
|
||||||
|
# Generic
|
||||||
|
('Interface 1', '9999999999999999Interface 000001000000000000'),
|
||||||
|
('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'),
|
||||||
|
('Interface 99', '9999999999999999Interface 000099000000000000'),
|
||||||
|
('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'),
|
||||||
|
('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'),
|
||||||
)
|
)
|
||||||
|
|
||||||
for origin, naturalized in data:
|
for origin, naturalized in data:
|
||||||
self.assertEqual(naturalize_interface(origin, max_length=50), naturalized)
|
self.assertEqual(naturalize_interface(origin, max_length=100), naturalized)
|
||||||
|
|
||||||
def test_naturalize_interface_max_length(self):
|
def test_naturalize_interface_max_length(self):
|
||||||
self.assertEqual(naturalize_interface('Gi1/2/3', max_length=20), '0001000299999999Gi00')
|
self.assertEqual(naturalize_interface('Gi1/2/3', max_length=20), '0001000299999999Gi00')
|
||||||
|
@ -222,3 +222,19 @@ def querydict_to_dict(querydict):
|
|||||||
key: querydict.get(key) if len(value) == 1 and key != 'pk' else querydict.getlist(key)
|
key: querydict.get(key) if len(value) == 1 and key != 'pk' else querydict.getlist(key)
|
||||||
for key, value in querydict.lists()
|
for key, value in querydict.lists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def shallow_compare_dict(source_dict, destination_dict, exclude=None):
|
||||||
|
"""
|
||||||
|
Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
|
||||||
|
the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
|
||||||
|
"""
|
||||||
|
difference = {}
|
||||||
|
|
||||||
|
for key in destination_dict:
|
||||||
|
if source_dict.get(key) != destination_dict[key]:
|
||||||
|
if isinstance(exclude, (list, tuple)) and key in exclude:
|
||||||
|
continue
|
||||||
|
difference[key] = destination_dict[key]
|
||||||
|
|
||||||
|
return difference
|
||||||
|
@ -656,9 +656,8 @@ class BulkEditView(GetReturnURLMixin, View):
|
|||||||
try:
|
try:
|
||||||
model_field = model._meta.get_field(name)
|
model_field = model._meta.get_field(name)
|
||||||
except FieldDoesNotExist:
|
except FieldDoesNotExist:
|
||||||
# The form field is used to modify a field rather than set its value directly,
|
# This form field is used to modify a field rather than set its value directly
|
||||||
# so we skip it.
|
model_field = None
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle nullification
|
# Handle nullification
|
||||||
if name in form.nullable_fields and name in nullified_fields:
|
if name in form.nullable_fields and name in nullified_fields:
|
||||||
|
Loading…
Reference in New Issue
Block a user