Merge branch 'develop' into develop-2.8

This commit is contained in:
Jeremy Stretch 2020-02-21 15:26:55 -05:00
commit 28e3b7af18
69 changed files with 722 additions and 357 deletions

9
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View 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

View File

@ -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.
```
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 extras.reports import Report
@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report):
def test_console_connection(self):
# 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:
self.log_failure(
console_port.device,
@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report):
def test_power_connections(self):
# 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
for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None:

View File

@ -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
* [#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
* [#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
* [#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
* [#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
---

View File

@ -186,6 +186,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
unit_height = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
)
exclude = serializers.IntegerField(
required=False,
default=None
@ -194,6 +197,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
required=False,
default=True
)
include_images = serializers.BooleanField(
required=False,
default=True
)
#
@ -220,7 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
model = DeviceType
fields = [
'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',
]

View File

@ -193,7 +193,13 @@ class RackViewSet(CustomFieldModelViewSet):
if data['render'] == 'svg':
# 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')
else:

View File

@ -195,6 +195,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_DE9 = 'de-9'
TYPE_DB25 = 'db-25'
TYPE_RJ11 = 'rj-11'
TYPE_RJ12 = 'rj-12'
TYPE_RJ45 = 'rj-45'
TYPE_USB_A = 'usb-a'
@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
('Serial', (
(TYPE_DE9, 'DE-9'),
(TYPE_DB25, 'DB-25'),
(TYPE_RJ11, 'RJ-11'),
(TYPE_RJ12, 'RJ-12'),
(TYPE_RJ45, 'RJ-45'),
)),

View File

@ -9,10 +9,10 @@ from .choices import InterfaceTypeChoices
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
#

204
netbox/dcim/elevations.py Normal file
View 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

View File

@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/"
)
@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
class Meta:
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
'tags',
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
'subdevice_role': StaticSelect2()
@ -2764,6 +2763,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField(
@ -2821,6 +2821,12 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
@ -3061,6 +3067,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField(
@ -4522,7 +4529,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/",
filter_for={

View File

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

View 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
),
]

View 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'),
),
]

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0096_interface_ordering'),
('dcim', '0098_devicetype_images'),
]
operations = [

View File

@ -1,7 +1,6 @@
from collections import OrderedDict
from itertools import count, groupby
import svgwrite
import yaml
from django.conf import settings
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.models import Count, F, ProtectedError, Sum
from django.urls import reverse
from django.utils.http import urlencode
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
@ -21,10 +19,11 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel
from utilities.utils import foreground_color, to_meters
from utilities.utils import to_meters
from .device_component_templates import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
@ -350,180 +349,7 @@ class RackRole(ChangeLoggedModel):
)
class RackElevationHelperMixin:
"""
Utility class that renders rack elevations. Contains helper methods for rendering elevations as a list of
rack units represented as dictionaries, or an SVG of the elevation.
"""
@staticmethod
def _add_gradient(drawing, id_, color):
gradient = drawing.linearGradient(
start=('0', '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):
class Rack(ChangeLoggedModel, CustomFieldModel):
"""
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.
@ -835,6 +661,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
reserved_units[u] = r
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):
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 '
'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(
blank=True
)
@ -1056,6 +912,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
# Save a copy of u_height for validation in clean()
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):
return reverse('dcim:devicetype', args=[self.pk])
@ -1175,6 +1035,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
'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
def display_name(self):
return '{} {}'.format(self.manufacturer.name, self.model)

View File

@ -795,11 +795,12 @@ class InterfaceTable(BaseTable):
class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
enabled = BooleanColumn()
class Meta(InterfaceTable.Meta):
order_by = ('parent', 'name')
fields = ('pk', 'parent', 'name', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'type', 'description', 'cable')
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
class FrontPortTable(BaseTable):

View File

@ -31,6 +31,7 @@ from utilities.views import (
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@ -1181,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
def get(self, request, 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'
)

View File

@ -40,10 +40,14 @@ class GraphSerializer(ValidatedModelSerializer):
class RenderedGraphSerializer(serializers.ModelSerializer):
embed_url = serializers.SerializerMethodField()
embed_link = serializers.SerializerMethodField()
embed_url = serializers.SerializerMethodField(
read_only=True
)
embed_link = serializers.SerializerMethodField(
read_only=True
)
type = ContentTypeField(
queryset=ContentType.objects.all()
read_only=True
)
class Meta:
@ -62,6 +66,9 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
#
class ExportTemplateSerializer(ValidatedModelSerializer):
content_type = ContentTypeField(
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
)
template_language = ChoiceField(
choices=TemplateLanguageChoices,
default=TemplateLanguageChoices.LANGUAGE_JINJA2

View File

@ -163,17 +163,17 @@ class ExportTemplateTest(APITestCase):
super().setUp()
self.content_type = ContentType.objects.get_for_model(Device)
content_type = ContentType.objects.get_for_model(Device)
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 %}'
)
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 %}'
)
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 %}'
)
@ -194,7 +194,7 @@ class ExportTemplateTest(APITestCase):
def test_create_exporttemplate(self):
data = {
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'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.assertEqual(ExportTemplate.objects.count(), 4)
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.template_code, data['template_code'])
@ -213,17 +213,17 @@ class ExportTemplateTest(APITestCase):
data = [
{
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'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',
'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',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
@ -241,7 +241,7 @@ class ExportTemplateTest(APITestCase):
def test_update_exporttemplate(self):
data = {
'content_type': self.content_type.pk,
'content_type': 'dcim.device',
'name': 'Test Export Template X',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}

View File

@ -12,6 +12,7 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
@ -207,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View):
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', {
'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_count': related_changes.count()
})

View File

@ -164,9 +164,19 @@ class NetFamily(Transform):
class NetMaskLength(Transform):
lookup_name = 'net_mask_length'
function = 'MASKLEN'
lookup_name = 'net_mask_length'
@property
def output_field(self):
return IntegerField()
class Host(Transform):
function = 'HOST'
lookup_name = 'host'
class Inet(Transform):
function = 'INET'
lookup_name = 'inet'

View File

@ -1,5 +1,6 @@
from django.db import models
from django.db.models.expressions import RawSQL
from ipam.lookups import Host, Inet
class IPAddressManager(models.Manager):
@ -13,4 +14,4 @@ class IPAddressManager(models.Manager):
IP address as a /32 or /128.
"""
qs = super().get_queryset()
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('host')
return qs.order_by(Inet(Host('address')))

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -179,25 +179,9 @@ nav ul.pagination {
/* Racks */
div.rack_header {
margin-left: 36px;
margin-left: 32px;
text-align: center;
width: 230px;
}
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;
width: 220px;
}
/* Devices */

View File

@ -14,7 +14,7 @@ text {
background-color: #f0f0f0;
fill: none;
stroke: black;
stroke-width: 3px;
stroke-width: 2px;
}
.slot {
fill: #f7f7f7;
@ -56,7 +56,6 @@ text {
.blocked:hover+.add-device {
fill: none;
}
.unit {
margin: 0;
padding: 5px 0px;
@ -65,3 +64,6 @@ text {
font-size: 10px;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
}
.hidden {
visibility: hidden;
}

View File

@ -42,17 +42,23 @@ $(document).ready(function() {
return s.substring(0, num_chars); // Trim to first num_chars chars
}
var slug_field = $('#id_slug');
slug_field.change(function() {
$(this).attr('_changed', true);
});
if (slug_field) {
var slug_source = $('#id_' + slug_field.attr('slug-source'));
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() {
if (slug_field && !slug_field.attr('_changed')) {
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

View 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;
});

View File

@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
role = DynamicModelMultipleChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='slug',
required=True,
required=False,
widget=APISelectMultiple(
api_url="/api/secrets/secret-roles/",
value_field="slug",

View File

@ -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
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
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
"""
device = models.ForeignKey(
to='dcim.Device',
@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
blank=True
)
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
)
hash = models.CharField(
@ -388,10 +388,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
else:
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])
return header + s + os.urandom(pad_length)

View File

@ -85,14 +85,19 @@ class UserKeyTestCase(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):
"""
Test basic encryption and decryption functionality using a random master key.
"""
plaintext = string.printable * 2
secret_key = generate_random_key()
s = Secret(plaintext=plaintext)
s.encrypt(secret_key)
s.encrypt(self.secret_key)
# Ensure plaintext is deleted upon encryption
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")
# Test decryption
s.decrypt(secret_key)
s.decrypt(self.secret_key)
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
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.
"""
plaintext = "1234567890abcdef"
secret_key = generate_random_key()
ivs = []
ciphertexts = []
for i in range(1, 51):
s = Secret(plaintext=plaintext)
s.encrypt(secret_key)
s.encrypt(self.secret_key)
ivs.append(s.ciphertext[0:16])
ciphertexts.append(s.ciphertext[16:32])
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
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!")
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)

View File

@ -49,7 +49,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -54,7 +54,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -31,7 +31,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -119,7 +119,7 @@
{% endif %}
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -54,7 +54,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>
@ -109,6 +109,30 @@
{% endif %}
</td>
</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">&mdash;</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">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

View File

@ -14,6 +14,13 @@
{% render_field form.subdevice_role %}
</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 %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@ -1,7 +1,6 @@
{% load helpers %}
<div class="rack_frame">
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" id="rack_{{ face }}"></object>
<div class="text-center text-small">
<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
</a>
</div>

View File

@ -34,7 +34,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -52,7 +52,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>
@ -121,18 +121,8 @@
</tr>
</table>
</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>
{% include 'inc/custom_fields_panel.html' with obj=powerfeed %}
{% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
@ -162,6 +152,18 @@
</tr>
</table>
</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>
{% endblock %}

View File

@ -48,7 +48,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -2,6 +2,7 @@
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load static %}
{% block header %}
<div class="row noprint">
@ -45,6 +46,9 @@
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=rack %}
<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 %}
</div>
<ul class="nav nav-tabs">
@ -53,7 +57,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>
@ -368,9 +372,5 @@
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -1,8 +1,12 @@
{% extends '_base.html' %}
{% load helpers %}
{% load static %}
{% block content %}
<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='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div>
@ -41,9 +45,5 @@
{% endblock %}
{% block javascript %}
<script type="text/javascript">
$(function() {
$('[data-toggle="popover"]').popover()
})
</script>
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -60,7 +60,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -1,6 +1,6 @@
{% 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 %}
{% if obj %}<h1>{{ obj }}</h1>{% endif %}

View File

@ -7,7 +7,7 @@
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<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 %}
<li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
{% elif objectchange.changed_object.get_absolute_url %}
@ -83,6 +83,35 @@
</tr>
</table>
</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 class="col-md-7">
<div class="panel panel-default">

View File

@ -44,7 +44,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -284,7 +284,7 @@
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Changelog</strong>
<strong>Change Log</strong>
</div>
{% if changelog and perms.extras.view_objectchange %}
<div class="list-group">

View File

@ -48,7 +48,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -49,7 +49,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -69,7 +69,7 @@
{% endif %}
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -55,7 +55,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -46,7 +46,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -34,7 +34,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -49,7 +49,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -49,7 +49,7 @@
</li>
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -54,7 +54,7 @@
{% endif %}
{% if perms.extras.view_objectchange %}
<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>
{% endif %}
</ul>

View File

@ -56,8 +56,11 @@ class NaturalOrderingField(models.CharField):
"""
Generate a naturalized value from the target field
"""
value = getattr(model_instance, self.target_field)
return self.naturalize_function(value, max_length=self.max_length)
original_value = getattr(model_instance, self.target_field)
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):
kwargs = super().deconstruct()[3] # Pass kwargs from CharField

View File

@ -132,6 +132,13 @@ class SmallTextarea(forms.Textarea):
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):
"""
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):
label = kwargs.pop('label', "Slug")
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

View File

@ -7,7 +7,8 @@ INTERFACE_NAME_REGEX = r'(^(?P<type>[^\d\.:]+)?)' \
r'((?P<subposition>\d+)/)?' \
r'((?P<id>\d+))?' \
r'(:(?P<channel>\d+))?' \
r'(.(?P<vc>\d+)$)?'
r'(\.(?P<vc>\d+))?' \
r'(?P<remainder>.*)$'
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 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)
if match is None:
return value
@ -60,21 +61,25 @@ def naturalize_interface(value, max_length):
for part_name in ('slot', 'subslot', 'position', 'subposition'):
part = match.group(part_name)
if part is not None:
output.append(part.rjust(4, '0'))
output += part.rjust(4, '0')
else:
output.append('9999')
output += '9999'
# Append the type, if any.
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'):
part = match.group(part_name)
if part is not None:
output.append(part.rjust(6, '0'))
output += part.rjust(6, '0')
else:
output.append('000000')
output += '000000'
ret = ''.join(output)
return ret[:max_length]
# Finally, naturalize any remaining text and append it
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]

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

View File

@ -9,8 +9,8 @@ class NaturalizationTestCase(TestCase):
"""
def test_naturalize(self):
data = (
# Original, naturalized
data = (
('abc', 'abc'),
('123', '00000123'),
('abc123', 'abc00000123'),
@ -21,15 +21,16 @@ class NaturalizationTestCase(TestCase):
)
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):
self.assertEqual(naturalize('abc123def456', max_length=10), 'abc0000012')
def test_naturalize_interface(self):
data = (
# Original, naturalized
data = (
# IOS/JunOS-style
('Gi', '9999999999999999Gi000000000000000000'),
('Gi1', '9999999999999999Gi000001000000000000'),
('Gi1/2', '0001999999999999Gi000002000000000000'),
@ -40,10 +41,16 @@ class NaturalizationTestCase(TestCase):
('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
('Gi1:2', '9999999999999999Gi000001000002000000'),
('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:
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):
self.assertEqual(naturalize_interface('Gi1/2/3', max_length=20), '0001000299999999Gi00')

View File

@ -222,3 +222,19 @@ def querydict_to_dict(querydict):
key: querydict.get(key) if len(value) == 1 and key != 'pk' else querydict.getlist(key)
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

View File

@ -656,9 +656,8 @@ class BulkEditView(GetReturnURLMixin, View):
try:
model_field = model._meta.get_field(name)
except FieldDoesNotExist:
# The form field is used to modify a field rather than set its value directly,
# so we skip it.
continue
# This form field is used to modify a field rather than set its value directly
model_field = None
# Handle nullification
if name in form.nullable_fields and name in nullified_fields: