Просмотр исходного кода

Merge branch 'develop' into develop-2.8

Jeremy Stretch 6 лет назад
Родитель
Сommit
28e3b7af18
69 измененных файлов с 722 добавлено и 357 удалено
  1. 9 0
      .github/ISSUE_TEMPLATE/config.yml
  2. 5 3
      docs/additional-features/reports.md
  3. 33 1
      docs/release-notes/version-2.7.md
  4. 1 1
      netbox/circuits/tables.py
  5. 9 1
      netbox/dcim/api/serializers.py
  6. 7 1
      netbox/dcim/api/views.py
  7. 2 0
      netbox/dcim/choices.py
  8. 3 3
      netbox/dcim/constants.py
  9. 204 0
      netbox/dcim/elevations.py
  10. 10 4
      netbox/dcim/forms.py
  11. 0 19
      netbox/dcim/managers.py
  12. 20 0
      netbox/dcim/migrations/0097_interfacetemplate_type_other.py
  13. 23 0
      netbox/dcim/migrations/0098_devicetype_images.py
  14. 1 1
      netbox/dcim/migrations/0099_mptt_remove_indexes.py
  15. 57 177
      netbox/dcim/models/__init__.py
  16. 11 10
      netbox/dcim/tables.py
  17. 2 1
      netbox/dcim/views.py
  18. 10 3
      netbox/extras/api/serializers.py
  19. 1 1
      netbox/extras/tables.py
  20. 10 10
      netbox/extras/tests/test_api.py
  21. 24 0
      netbox/extras/views.py
  22. 11 1
      netbox/ipam/lookups.py
  23. 3 2
      netbox/ipam/managers.py
  24. 3 3
      netbox/ipam/tables.py
  25. 2 0
      netbox/media/devicetype-images/.gitignore
  26. 2 18
      netbox/project-static/css/base.css
  27. 4 2
      netbox/project-static/css/rack_elevation.css
  28. 10 4
      netbox/project-static/js/forms.js
  29. 16 0
      netbox/project-static/js/rack_elevations.js
  30. 1 1
      netbox/secrets/forms.py
  31. 4 8
      netbox/secrets/models.py
  32. 1 1
      netbox/secrets/tables.py
  33. 40 5
      netbox/secrets/tests/test_models.py
  34. 1 1
      netbox/templates/circuits/circuit.html
  35. 1 1
      netbox/templates/circuits/provider.html
  36. 1 1
      netbox/templates/dcim/cable.html
  37. 1 1
      netbox/templates/dcim/device.html
  38. 25 1
      netbox/templates/dcim/devicetype.html
  39. 7 0
      netbox/templates/dcim/devicetype_edit.html
  40. 5 6
      netbox/templates/dcim/inc/rack_elevation.html
  41. 1 1
      netbox/templates/dcim/interface.html
  42. 15 13
      netbox/templates/dcim/powerfeed.html
  43. 1 1
      netbox/templates/dcim/powerpanel.html
  44. 6 6
      netbox/templates/dcim/rack.html
  45. 5 5
      netbox/templates/dcim/rack_elevation_list.html
  46. 1 1
      netbox/templates/dcim/site.html
  47. 2 2
      netbox/templates/extras/object_changelog.html
  48. 30 1
      netbox/templates/extras/objectchange.html
  49. 1 1
      netbox/templates/extras/tag.html
  50. 1 1
      netbox/templates/home.html
  51. 1 1
      netbox/templates/ipam/aggregate.html
  52. 1 1
      netbox/templates/ipam/inc/service.html
  53. 1 1
      netbox/templates/ipam/ipaddress.html
  54. 1 1
      netbox/templates/ipam/prefix.html
  55. 1 1
      netbox/templates/ipam/vlan.html
  56. 1 1
      netbox/templates/ipam/vrf.html
  57. 1 1
      netbox/templates/secrets/secret.html
  58. 1 1
      netbox/templates/tenancy/tenant.html
  59. 1 1
      netbox/templates/virtualization/cluster.html
  60. 1 1
      netbox/templates/virtualization/virtualmachine.html
  61. 1 1
      netbox/tenancy/tables.py
  62. 5 2
      netbox/utilities/fields.py
  63. 9 1
      netbox/utilities/forms.py
  64. 15 10
      netbox/utilities/ordering.py
  65. 8 0
      netbox/utilities/templates/widgets/sluginput.html
  66. 11 4
      netbox/utilities/tests/test_ordering.py
  67. 16 0
      netbox/utilities/utils.py
  68. 2 3
      netbox/utilities/views.py
  69. 2 2
      netbox/virtualization/tables.py

+ 9 - 0
.github/ISSUE_TEMPLATE/config.yml

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

+ 5 - 3
docs/additional-features/reports.md

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

+ 33 - 1
docs/release-notes/version-2.7.md

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

+ 1 - 1
netbox/circuits/tables.py

@@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 
 CIRCUITTYPE_ACTIONS = """
-<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.circuit.change_circuittype %}

+ 9 - 1
netbox/dcim/api/serializers.py

@@ -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',
         ]
 
 

+ 7 - 1
netbox/dcim/api/views.py

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

+ 2 - 0
netbox/dcim/choices.py

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

+ 3 - 3
netbox/dcim/constants.py

@@ -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 - 0
netbox/dcim/elevations.py

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

+ 10 - 4
netbox/dcim/forms.py

@@ -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={

+ 0 - 19
netbox/dcim/managers.py

@@ -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 - 0
netbox/dcim/migrations/0097_interfacetemplate_type_other.py

@@ -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 - 0
netbox/dcim/migrations/0098_devicetype_images.py

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

+ 1 - 1
netbox/dcim/migrations/0097_mptt_remove_indexes.py → netbox/dcim/migrations/0099_mptt_remove_indexes.py

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

+ 57 - 177
netbox/dcim/models/__init__.py

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

+ 11 - 10
netbox/dcim/tables.py

@@ -41,7 +41,7 @@ DEVICE_LINK = """
 """
 
 REGION_ACTIONS = """
-<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_region %}
@@ -50,7 +50,7 @@ REGION_ACTIONS = """
 """
 
 RACKGROUP_ACTIONS = """
-<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
@@ -64,7 +64,7 @@ RACKGROUP_ACTIONS = """
 """
 
 RACKROLE_ACTIONS = """
-<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_rackrole %}
@@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """
 """
 
 RACKRESERVATION_ACTIONS = """
-<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_rackreservation %}
@@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """
 """
 
 MANUFACTURER_ACTIONS = """
-<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_manufacturer %}
@@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """
 """
 
 DEVICEROLE_ACTIONS = """
-<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_devicerole %}
@@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """
 """
 
 PLATFORM_ACTIONS = """
-<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_platform %}
@@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """
 """
 
 VIRTUALCHASSIS_ACTIONS = """
-<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.dcim.change_virtualchassis %}
@@ -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):

+ 2 - 1
netbox/dcim/views.py

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

+ 10 - 3
netbox/extras/api/serializers.py

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

+ 1 - 1
netbox/extras/tables.py

@@ -5,7 +5,7 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
 from .models import ConfigContext, ObjectChange, Tag, TaggedItem
 
 TAG_ACTIONS = """
-<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.taggit.change_tag %}

+ 10 - 10
netbox/extras/tests/test_api.py

@@ -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 %}',
         }

+ 24 - 0
netbox/extras/views.py

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

+ 11 - 1
netbox/ipam/lookups.py

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

+ 3 - 2
netbox/ipam/managers.py

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

+ 3 - 3
netbox/ipam/tables.py

@@ -26,7 +26,7 @@ RIR_UTILIZATION = """
 """
 
 RIR_ACTIONS = """
-<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.ipam.change_rir %}
@@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """
 """
 
 ROLE_ACTIONS = """
-<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.ipam.change_role %}
@@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """
 """
 
 VLANGROUP_ACTIONS = """
-<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% with next_vid=record.get_next_available_vid %}

+ 2 - 0
netbox/media/devicetype-images/.gitignore

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

+ 2 - 18
netbox/project-static/css/base.css

@@ -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 */

+ 4 - 2
netbox/project-static/css/rack_elevation.css

@@ -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;
+}

+ 10 - 4
netbox/project-static/js/forms.js

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

+ 16 - 0
netbox/project-static/js/rack_elevations.js

@@ -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;
+});

+ 1 - 1
netbox/secrets/forms.py

@@ -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",

+ 4 - 8
netbox/secrets/models.py

@@ -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,11 +388,7 @@ 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])
+        header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
 
         return header + s + os.urandom(pad_length)
 

+ 1 - 1
netbox/secrets/tables.py

@@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import SecretRole, Secret
 
 SECRETROLE_ACTIONS = """
-<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.secrets.change_secretrole %}

+ 40 - 5
netbox/secrets/tests/test_models.py

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

+ 1 - 1
netbox/templates/circuits/circuit.html

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

+ 1 - 1
netbox/templates/circuits/provider.html

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

+ 1 - 1
netbox/templates/dcim/cable.html

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

+ 1 - 1
netbox/templates/dcim/device.html

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

+ 25 - 1
netbox/templates/dcim/devicetype.html

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

+ 7 - 0
netbox/templates/dcim/devicetype_edit.html

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

+ 5 - 6
netbox/templates/dcim/inc/rack_elevation.html

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

+ 1 - 1
netbox/templates/dcim/interface.html

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

+ 15 - 13
netbox/templates/dcim/powerfeed.html

@@ -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 %}

+ 1 - 1
netbox/templates/dcim/powerpanel.html

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

+ 6 - 6
netbox/templates/dcim/rack.html

@@ -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 %}

+ 5 - 5
netbox/templates/dcim/rack_elevation_list.html

@@ -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 %}

+ 1 - 1
netbox/templates/dcim/site.html

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

+ 2 - 2
netbox/templates/extras/object_changelog.html

@@ -1,12 +1,12 @@
 {% 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 %}
     {% include 'panel_table.html' %}
     {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     <div class="text-muted">
-        Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
+        Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
     </div>
 {% endblock %}

+ 30 - 1
netbox/templates/extras/objectchange.html

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

+ 1 - 1
netbox/templates/extras/tag.html

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

+ 1 - 1
netbox/templates/home.html

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

+ 1 - 1
netbox/templates/ipam/aggregate.html

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

+ 1 - 1
netbox/templates/ipam/inc/service.html

@@ -14,7 +14,7 @@
     </td>
     <td>{{ service.description }}</td>
     <td class="text-right noprint">
-        <a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Changelog">
+        <a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Change log">
             <i class="fa fa-history"></i>
         </a>
         {% if perms.ipam.change_service %}

+ 1 - 1
netbox/templates/ipam/ipaddress.html

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

+ 1 - 1
netbox/templates/ipam/prefix.html

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

+ 1 - 1
netbox/templates/ipam/vlan.html

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

+ 1 - 1
netbox/templates/ipam/vrf.html

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

+ 1 - 1
netbox/templates/secrets/secret.html

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

+ 1 - 1
netbox/templates/tenancy/tenant.html

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

+ 1 - 1
netbox/templates/virtualization/cluster.html

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

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

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

+ 1 - 1
netbox/tenancy/tables.py

@@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Tenant, TenantGroup
 
 TENANTGROUP_ACTIONS = """
-<a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.tenancy.change_tenantgroup %}

+ 5 - 2
netbox/utilities/fields.py

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

+ 9 - 1
netbox/utilities/forms.py

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

+ 15 - 10
netbox/utilities/ordering.py

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

+ 8 - 0
netbox/utilities/templates/widgets/sluginput.html

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

+ 11 - 4
netbox/utilities/tests/test_ordering.py

@@ -9,8 +9,8 @@ class NaturalizationTestCase(TestCase):
     """
     def test_naturalize(self):
 
+        # Original, naturalized
         data = (
-            # Original, naturalized
             ('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):
 
+        # Original, naturalized
         data = (
-            # Original, naturalized
+            # 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')

+ 16 - 0
netbox/utilities/utils.py

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

+ 2 - 3
netbox/utilities/views.py

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

+ 2 - 2
netbox/virtualization/tables.py

@@ -7,7 +7,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 CLUSTERTYPE_ACTIONS = """
-<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.virtualization.change_clustertype %}
@@ -16,7 +16,7 @@ CLUSTERTYPE_ACTIONS = """
 """
 
 CLUSTERGROUP_ACTIONS = """
-<a href="{% url 'virtualization:clustergroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
+<a href="{% url 'virtualization:clustergroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
     <i class="fa fa-history"></i>
 </a>
 {% if perms.virtualization.change_clustergroup %}