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

Merge pull request #4216 from netbox-community/develop

Release v2.7.7
Jeremy Stretch 6 лет назад
Родитель
Сommit
5092641157
100 измененных файлов с 752 добавлено и 1139 удалено
  1. 9 0
      .github/ISSUE_TEMPLATE/config.yml
  2. 4 0
      base_requirements.txt
  3. 5 3
      docs/additional-features/reports.md
  4. 3 3
      docs/configuration/required-settings.md
  5. 36 0
      docs/release-notes/version-2.7.md
  6. 1 1
      netbox/circuits/tables.py
  7. 0 3
      netbox/circuits/views.py
  8. 9 1
      netbox/dcim/api/serializers.py
  9. 7 1
      netbox/dcim/api/views.py
  10. 2 0
      netbox/dcim/choices.py
  11. 192 0
      netbox/dcim/elevations.py
  12. 53 19
      netbox/dcim/forms.py
  13. 0 19
      netbox/dcim/managers.py
  14. 20 0
      netbox/dcim/migrations/0097_interfacetemplate_type_other.py
  15. 23 0
      netbox/dcim/migrations/0098_devicetype_images.py
  16. 57 177
      netbox/dcim/models/__init__.py
  17. 11 10
      netbox/dcim/tables.py
  18. 14 24
      netbox/dcim/views.py
  19. 0 20
      netbox/extras/apps.py
  20. 1 1
      netbox/extras/tables.py
  21. 27 2
      netbox/extras/views.py
  22. 10 0
      netbox/ipam/api/views.py
  23. 15 1
      netbox/ipam/lookups.py
  24. 3 2
      netbox/ipam/managers.py
  25. 3 3
      netbox/ipam/tables.py
  26. 1 7
      netbox/ipam/views.py
  27. 2 0
      netbox/media/devicetype-images/.gitignore
  28. 1 1
      netbox/netbox/settings.py
  29. 1 17
      netbox/project-static/css/base.css
  30. 4 2
      netbox/project-static/css/rack_elevation.css
  31. 20 11
      netbox/project-static/js/forms.js
  32. 16 0
      netbox/project-static/js/rack_elevations.js
  33. 1 1
      netbox/secrets/forms.py
  34. 4 8
      netbox/secrets/models.py
  35. 1 1
      netbox/secrets/tables.py
  36. 40 5
      netbox/secrets/tests/test_models.py
  37. 1 2
      netbox/secrets/views.py
  38. 1 1
      netbox/templates/circuits/circuit.html
  39. 0 21
      netbox/templates/circuits/circuit_list.html
  40. 0 18
      netbox/templates/circuits/circuittype_list.html
  41. 1 1
      netbox/templates/circuits/provider.html
  42. 0 21
      netbox/templates/circuits/provider_list.html
  43. 1 1
      netbox/templates/dcim/cable.html
  44. 0 20
      netbox/templates/dcim/cable_list.html
  45. 0 17
      netbox/templates/dcim/consoleport_list.html
  46. 0 17
      netbox/templates/dcim/consoleserverport_list.html
  47. 1 1
      netbox/templates/dcim/device.html
  48. 21 18
      netbox/templates/dcim/device_list.html
  49. 0 17
      netbox/templates/dcim/devicebay_list.html
  50. 0 18
      netbox/templates/dcim/devicerole_list.html
  51. 25 1
      netbox/templates/dcim/devicetype.html
  52. 7 0
      netbox/templates/dcim/devicetype_edit.html
  53. 0 21
      netbox/templates/dcim/devicetype_list.html
  54. 0 17
      netbox/templates/dcim/frontport_list.html
  55. 0 24
      netbox/templates/dcim/inc/device_table.html
  56. 1 4
      netbox/templates/dcim/inc/rack_elevation.html
  57. 1 1
      netbox/templates/dcim/interface.html
  58. 0 17
      netbox/templates/dcim/interface_list.html
  59. 0 21
      netbox/templates/dcim/inventoryitem_list.html
  60. 0 18
      netbox/templates/dcim/manufacturer_list.html
  61. 0 18
      netbox/templates/dcim/platform_list.html
  62. 15 13
      netbox/templates/dcim/powerfeed.html
  63. 0 21
      netbox/templates/dcim/powerfeed_list.html
  64. 0 17
      netbox/templates/dcim/poweroutlet_list.html
  65. 1 1
      netbox/templates/dcim/powerpanel.html
  66. 0 21
      netbox/templates/dcim/powerpanel_list.html
  67. 0 17
      netbox/templates/dcim/powerport_list.html
  68. 6 6
      netbox/templates/dcim/rack.html
  69. 5 5
      netbox/templates/dcim/rack_elevation_list.html
  70. 0 21
      netbox/templates/dcim/rack_list.html
  71. 0 21
      netbox/templates/dcim/rackgroup_list.html
  72. 0 14
      netbox/templates/dcim/rackreservation_list.html
  73. 0 18
      netbox/templates/dcim/rackrole_list.html
  74. 0 17
      netbox/templates/dcim/rearport_list.html
  75. 0 21
      netbox/templates/dcim/region_list.html
  76. 1 1
      netbox/templates/dcim/site.html
  77. 0 21
      netbox/templates/dcim/site_list.html
  78. 0 18
      netbox/templates/dcim/virtualchassis_list.html
  79. 0 19
      netbox/templates/extras/configcontext_list.html
  80. 2 2
      netbox/templates/extras/object_changelog.html
  81. 30 1
      netbox/templates/extras/objectchange.html
  82. 6 17
      netbox/templates/extras/objectchange_list.html
  83. 1 1
      netbox/templates/extras/tag.html
  84. 0 14
      netbox/templates/extras/tag_list.html
  85. 1 1
      netbox/templates/home.html
  86. 1 1
      netbox/templates/inc/nav_menu.html
  87. 1 1
      netbox/templates/ipam/aggregate.html
  88. 10 27
      netbox/templates/ipam/aggregate_list.html
  89. 1 1
      netbox/templates/ipam/inc/service.html
  90. 1 1
      netbox/templates/ipam/ipaddress.html
  91. 0 21
      netbox/templates/ipam/ipaddress_list.html
  92. 1 1
      netbox/templates/ipam/prefix.html
  93. 2 19
      netbox/templates/ipam/prefix_list.html
  94. 9 22
      netbox/templates/ipam/rir_list.html
  95. 0 18
      netbox/templates/ipam/role_list.html
  96. 0 17
      netbox/templates/ipam/service_list.html
  97. 1 1
      netbox/templates/ipam/vlan.html
  98. 0 21
      netbox/templates/ipam/vlan_list.html
  99. 0 21
      netbox/templates/ipam/vlangroup_list.html
  100. 1 1
      netbox/templates/ipam/vrf.html

+ 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

+ 4 - 0
base_requirements.txt

@@ -22,6 +22,10 @@ django-filter
 # https://github.com/django-mptt/django-mptt
 # https://github.com/django-mptt/django-mptt
 django-mptt
 django-mptt
 
 
+# Context managers for PostgreSQL advisory locks
+# https://github.com/Xof/django-pglocks
+django-pglocks
+
 # Prometheus metrics library for Django
 # Prometheus metrics library for Django
 # https://github.com/korfuri/django-prometheus
 # https://github.com/korfuri/django-prometheus
 django-prometheus
 django-prometheus

+ 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.
 Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
 
 
 ```
 ```
-from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE
+from dcim.choices import DeviceStatusChoices
+from dcim.constants import CONNECTION_STATUS_PLANNED
 from dcim.models import ConsolePort, Device, PowerPort
 from dcim.models import ConsolePort, Device, PowerPort
 from extras.reports import Report
 from extras.reports import Report
 
 
@@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report):
     def test_console_connection(self):
     def test_console_connection(self):
 
 
         # Check that every console port for every active device has a connection defined.
         # Check that every console port for every active device has a connection defined.
-        for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
+        active = DeviceStatusChoices.STATUS_ACTIVE
+        for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
             if console_port.connected_endpoint is None:
             if console_port.connected_endpoint is None:
                 self.log_failure(
                 self.log_failure(
                     console_port.device,
                     console_port.device,
@@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report):
     def test_power_connections(self):
     def test_power_connections(self):
 
 
         # Check that every active device has at least two connected power supplies.
         # Check that every active device has at least two connected power supplies.
-        for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
+        for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
             connected_ports = 0
             connected_ports = 0
             for power_port in PowerPort.objects.filter(device=device):
             for power_port in PowerPort.objects.filter(device=device):
                 if power_port.connected_endpoint is not None:
                 if power_port.connected_endpoint is not None:

+ 3 - 3
docs/configuration/required-settings.md

@@ -80,11 +80,11 @@ REDIS = {
 }
 }
 ```
 ```
 
 
-!!! note:
+!!! note
     If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have
     If you are upgrading from a version prior to v2.7, please note that the Redis connection configuration settings have
     changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary
     changed. Manual modification to bring the `REDIS` section inline with the above specification is necessary
 
 
-!!! warning:
+!!! note
     It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
     It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
     same Redis instance for both may result in webhook processing data being lost during cache flushing events.
     same Redis instance for both may result in webhook processing data being lost during cache flushing events.
 
 
@@ -124,7 +124,7 @@ REDIS = {
 }
 }
 ```
 ```
 
 
-!!! note:
+!!! note
     It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible
     It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible
     for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via 
     for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via 
     `SENTINELS`/`SENTINEL_SERVICE`.
     `SENTINELS`/`SENTINEL_SERVICE`.

+ 36 - 0
docs/release-notes/version-2.7.md

@@ -1,3 +1,39 @@
+# 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
+
+---
+
 # v2.7.6 (2020-02-13)
 # v2.7.6 (2020-02-13)
 
 
 ## Bug Fixes
 ## Bug Fixes

+ 1 - 1
netbox/circuits/tables.py

@@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Circuit, CircuitType, Provider
 from .models import Circuit, CircuitType, Provider
 
 
 CIRCUITTYPE_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.circuit.change_circuittype %}
 {% if perms.circuit.change_circuittype %}

+ 0 - 3
netbox/circuits/views.py

@@ -29,7 +29,6 @@ class ProviderListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ProviderFilterSet
     filterset = filters.ProviderFilterSet
     filterset_form = forms.ProviderFilterForm
     filterset_form = forms.ProviderFilterForm
     table = tables.ProviderDetailTable
     table = tables.ProviderDetailTable
-    template_name = 'circuits/provider_list.html'
 
 
 
 
 class ProviderView(PermissionRequiredMixin, View):
 class ProviderView(PermissionRequiredMixin, View):
@@ -107,7 +106,6 @@ class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'circuits.view_circuittype'
     permission_required = 'circuits.view_circuittype'
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
     table = tables.CircuitTypeTable
     table = tables.CircuitTypeTable
-    template_name = 'circuits/circuittype_list.html'
 
 
 
 
 class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
 class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -151,7 +149,6 @@ class CircuitListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.CircuitFilterSet
     filterset = filters.CircuitFilterSet
     filterset_form = forms.CircuitFilterForm
     filterset_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
-    template_name = 'circuits/circuit_list.html'
 
 
 
 
 class CircuitView(PermissionRequiredMixin, View):
 class CircuitView(PermissionRequiredMixin, View):

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

@@ -186,6 +186,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
     unit_height = serializers.IntegerField(
     unit_height = serializers.IntegerField(
         default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
         default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
     )
     )
+    legend_width = serializers.IntegerField(
+        default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
+    )
     exclude = serializers.IntegerField(
     exclude = serializers.IntegerField(
         required=False,
         required=False,
         default=None
         default=None
@@ -194,6 +197,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
         required=False,
         required=False,
         default=True
         default=True
     )
     )
+    include_images = serializers.BooleanField(
+        required=False,
+        default=True
+    )
 
 
 
 
 #
 #
@@ -220,7 +227,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
             'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
             'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
-            'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
+            'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated', 'device_count',
         ]
         ]
 
 
 
 

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

@@ -220,7 +220,13 @@ class RackViewSet(CustomFieldModelViewSet):
 
 
         if data['render'] == 'svg':
         if data['render'] == 'svg':
             # Render and return the elevation as an SVG drawing with the correct content type
             # Render and return the elevation as an SVG drawing with the correct content type
-            drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height'])
+            drawing = rack.get_elevation_svg(
+                face=data['face'],
+                unit_width=data['unit_width'],
+                unit_height=data['unit_height'],
+                legend_width=data['legend_width'],
+                include_images=data['include_images']
+            )
             return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
             return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
 
 
         else:
         else:

+ 2 - 0
netbox/dcim/choices.py

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

+ 192 - 0
netbox/dcim/elevations.py

@@ -0,0 +1,192 @@
+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
+
+
+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', '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
+        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.stretch()
+            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.front_image:
+            url = device.device_type.rear_image.url
+            image = drawing.image(href=url, insert=start, size=end, class_='device-image')
+            image.stretch()
+            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, unit_height * self.rack.u_height)
+        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 + 2)
+            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
+            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.rack,
+                    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, 1), (unit_width - 1, self.rack.u_height * unit_height - 2), class_='rack'))
+
+        return drawing

+ 53 - 19
netbox/dcim/forms.py

@@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class RackGroupForm(BootstrapMixin, forms.ModelForm):
 class RackGroupForm(BootstrapMixin, forms.ModelForm):
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/"
             api_url="/api/dcim/sites/"
         )
         )
@@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
     class Meta:
         model = DeviceType
         model = DeviceType
         fields = [
         fields = [
-            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
-            'tags',
+            'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+            'front_image', 'rear_image', 'comments', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'subdevice_role': StaticSelect2()
             'subdevice_role': StaticSelect2()
@@ -2764,6 +2763,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     device = forms.ModelChoiceField(
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
+        disabled=True,
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
     )
     )
     type = forms.ChoiceField(
     type = forms.ChoiceField(
@@ -2821,6 +2821,12 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 
 
 class InterfaceFilterForm(DeviceComponentFilterForm):
 class InterfaceFilterForm(DeviceComponentFilterForm):
     model = Interface
     model = Interface
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
@@ -2832,7 +2838,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
         widget=APISelect(
         widget=APISelect(
             api_url="/api/ipam/vlans/",
             api_url="/api/ipam/vlans/",
             display_field='display_name',
             display_field='display_name',
-            full=True
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
         )
         )
     )
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -2842,7 +2851,10 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vlans/",
             api_url="/api/ipam/vlans/",
             display_field='display_name',
             display_field='display_name',
-            full=True
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
         )
         )
     )
     )
     tags = TagField(
     tags = TagField(
@@ -2871,18 +2883,20 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit LAG choices to interfaces belonging to this device (or VC master)
         if self.is_bound:
         if self.is_bound:
             device = Device.objects.get(pk=self.data['device'])
             device = Device.objects.get(pk=self.data['device'])
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[device, device.get_vc_master()],
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
         else:
         else:
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[self.instance.device, self.instance.device.get_vc_master()],
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
+            device = self.instance.device
+
+        # Limit LAG choices to interfaces belonging to this device (or VC master)
+        self.fields['lag'].queryset = Interface.objects.filter(
+            device__in=[device, device.get_vc_master()],
+            type=InterfaceTypeChoices.TYPE_LAG
+        )
+
+        # Add current site to VLANs query params
+        self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
+        self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
 
 
 
 
 class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
 class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
@@ -2942,7 +2956,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
         widget=APISelect(
         widget=APISelect(
             api_url="/api/ipam/vlans/",
             api_url="/api/ipam/vlans/",
             display_field='display_name',
             display_field='display_name',
-            full=True
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
         )
         )
     )
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -2951,7 +2968,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vlans/",
             api_url="/api/ipam/vlans/",
             display_field='display_name',
             display_field='display_name',
-            full=True
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
         )
         )
     )
     )
 
 
@@ -2967,6 +2987,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
             type=InterfaceTypeChoices.TYPE_LAG
             type=InterfaceTypeChoices.TYPE_LAG
         )
         )
 
 
+        # Add current site to VLANs query params
+        self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
+        self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+
 
 
 class InterfaceCSVForm(forms.ModelForm):
 class InterfaceCSVForm(forms.ModelForm):
     device = FlexibleModelChoiceField(
     device = FlexibleModelChoiceField(
@@ -3043,6 +3067,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
     device = forms.ModelChoiceField(
     device = forms.ModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         required=False,
         required=False,
+        disabled=True,
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
     )
     )
     type = forms.ChoiceField(
     type = forms.ChoiceField(
@@ -3090,7 +3115,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         widget=APISelect(
         widget=APISelect(
             api_url="/api/ipam/vlans/",
             api_url="/api/ipam/vlans/",
             display_field='display_name',
             display_field='display_name',
-            full=True
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
         )
         )
     )
     )
     tagged_vlans = DynamicModelMultipleChoiceField(
     tagged_vlans = DynamicModelMultipleChoiceField(
@@ -3099,7 +3127,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/ipam/vlans/",
             api_url="/api/ipam/vlans/",
             display_field='display_name',
             display_field='display_name',
-            full=True
+            full=True,
+            additional_query_params={
+                'site_id': 'null',
+            },
         )
         )
     )
     )
 
 
@@ -3118,6 +3149,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
                 device__in=[device, device.get_vc_master()],
                 device__in=[device, device.get_vc_master()],
                 type=InterfaceTypeChoices.TYPE_LAG
                 type=InterfaceTypeChoices.TYPE_LAG
             )
             )
+
+            # Add current site to VLANs query params
+            self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
+            self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
         else:
         else:
             self.fields['lag'].choices = ()
             self.fields['lag'].choices = ()
             self.fields['lag'].widget.attrs['disabled'] = True
             self.fields['lag'].widget.attrs['disabled'] = True
@@ -4494,7 +4529,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
 class PowerPanelForm(BootstrapMixin, forms.ModelForm):
 class PowerPanelForm(BootstrapMixin, forms.ModelForm):
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
-        required=False,
         widget=APISelect(
         widget=APISelect(
             api_url="/api/dcim/sites/",
             api_url="/api/dcim/sites/",
             filter_for={
             filter_for={

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

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

@@ -1,7 +1,6 @@
 from collections import OrderedDict
 from collections import OrderedDict
 from itertools import count, groupby
 from itertools import count, groupby
 
 
-import svgwrite
 import yaml
 import yaml
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
@@ -13,7 +12,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Count, F, ProtectedError, Sum
 from django.db.models import Count, F, ProtectedError, Sum
 from django.urls import reverse
 from django.urls import reverse
-from django.utils.http import urlencode
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
@@ -21,10 +19,11 @@ from timezone_field import TimeZoneField
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import ASNField
 from dcim.fields import ASNField
+from dcim.elevations import RackElevationSVG
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
-from utilities.utils import foreground_color, to_meters
+from utilities.utils import to_meters
 from .device_component_templates import (
 from .device_component_templates import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
     PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
     PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
@@ -350,180 +349,7 @@ class RackRole(ChangeLoggedModel):
         )
         )
 
 
 
 
-class RackElevationHelperMixin:
-    """
-    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', '#f0f0f0')
-        RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7')
-
-        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.
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
     Each Rack is assigned to a Site and (optionally) a RackGroup.
     Each Rack is assigned to a Site and (optionally) a RackGroup.
@@ -835,6 +661,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
                 reserved_units[u] = r
                 reserved_units[u] = r
         return reserved_units
         return reserved_units
 
 
+    def get_elevation_svg(
+            self,
+            face=DeviceFaceChoices.FACE_FRONT,
+            unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
+            unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
+            legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
+            include_images=True
+    ):
+        """
+        Return an SVG of the rack elevation
+
+        :param face: Enum of [front, rear] representing the desired side of the rack elevation to render
+        :param unit_width: Width in pixels for the rendered drawing
+        :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
+            height of the elevation
+        :param legend_width: Width of the unit legend, in pixels
+        :param include_images: Embed front/rear device images where available
+        """
+        elevation = RackElevationSVG(self, include_images=include_images)
+
+        return elevation.render(face, unit_width, unit_height, legend_width)
+
     def get_0u_devices(self):
     def get_0u_devices(self):
         return self.devices.filter(position=0)
         return self.devices.filter(position=0)
 
 
@@ -1025,6 +873,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         help_text='Parent devices house child devices in device bays. Leave blank '
         help_text='Parent devices house child devices in device bays. Leave blank '
                   'if this device type is neither a parent nor a child.'
                   'if this device type is neither a parent nor a child.'
     )
     )
+    front_image = models.ImageField(
+        upload_to='devicetype-images',
+        blank=True
+    )
+    rear_image = models.ImageField(
+        upload_to='devicetype-images',
+        blank=True
+    )
     comments = models.TextField(
     comments = models.TextField(
         blank=True
         blank=True
     )
     )
@@ -1056,6 +912,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
         # Save a copy of u_height for validation in clean()
         # Save a copy of u_height for validation in clean()
         self._original_u_height = self.u_height
         self._original_u_height = self.u_height
 
 
+        # Save references to the original front/rear images
+        self._original_front_image = self.front_image
+        self._original_rear_image = self.rear_image
+
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:devicetype', args=[self.pk])
         return reverse('dcim:devicetype', args=[self.pk])
 
 
@@ -1175,6 +1035,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
                 'u_height': "Child device types must be 0U."
                 'u_height': "Child device types must be 0U."
             })
             })
 
 
+    def save(self, *args, **kwargs):
+        ret = super().save(*args, **kwargs)
+
+        # Delete any previously uploaded image files that are no longer in use
+        if self.front_image != self._original_front_image:
+            self._original_front_image.delete(save=False)
+        if self.rear_image != self._original_rear_image:
+            self._original_rear_image.delete(save=False)
+
+        return ret
+
+    def delete(self, *args, **kwargs):
+        super().delete(*args, **kwargs)
+
+        # Delete any uploaded image files
+        if self.front_image:
+            self.front_image.delete(save=False)
+        if self.rear_image:
+            self.rear_image.delete(save=False)
+
     @property
     @property
     def display_name(self):
     def display_name(self):
         return '{} {}'.format(self.manufacturer.name, self.model)
         return '{} {}'.format(self.manufacturer.name, self.model)

+ 11 - 10
netbox/dcim/tables.py

@@ -41,7 +41,7 @@ DEVICE_LINK = """
 """
 """
 
 
 REGION_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_region %}
 {% if perms.dcim.change_region %}
@@ -50,7 +50,7 @@ REGION_ACTIONS = """
 """
 """
 
 
 RACKGROUP_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>
     <i class="fa fa-history"></i>
 </a>
 </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">
 <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 = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_rackrole %}
 {% if perms.dcim.change_rackrole %}
@@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """
 """
 """
 
 
 RACKRESERVATION_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_rackreservation %}
 {% if perms.dcim.change_rackreservation %}
@@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """
 """
 """
 
 
 MANUFACTURER_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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_manufacturer %}
 {% if perms.dcim.change_manufacturer %}
@@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """
 """
 """
 
 
 DEVICEROLE_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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_devicerole %}
 {% if perms.dcim.change_devicerole %}
@@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """
 """
 """
 
 
 PLATFORM_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_platform %}
 {% if perms.dcim.change_platform %}
@@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """
 """
 """
 
 
 VIRTUALCHASSIS_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.dcim.change_virtualchassis %}
 {% if perms.dcim.change_virtualchassis %}
@@ -795,11 +795,12 @@ class InterfaceTable(BaseTable):
 class InterfaceDetailTable(DeviceComponentDetailTable):
 class InterfaceDetailTable(DeviceComponentDetailTable):
     parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
     parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
     name = tables.LinkColumn()
     name = tables.LinkColumn()
+    enabled = BooleanColumn()
 
 
     class Meta(InterfaceTable.Meta):
     class Meta(InterfaceTable.Meta):
         order_by = ('parent', 'name')
         order_by = ('parent', 'name')
-        fields = ('pk', 'parent', 'name', 'type', 'description', 'cable')
-        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):
 class FrontPortTable(BaseTable):

+ 14 - 24
netbox/dcim/views.py

@@ -31,6 +31,7 @@ from utilities.views import (
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .choices import DeviceFaceChoices
 from .choices import DeviceFaceChoices
+from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -152,7 +153,6 @@ class RegionListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RegionFilterSet
     filterset = filters.RegionFilterSet
     filterset_form = forms.RegionFilterForm
     filterset_form = forms.RegionFilterForm
     table = tables.RegionTable
     table = tables.RegionTable
-    template_name = 'dcim/region_list.html'
 
 
 
 
 class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
 class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -191,7 +191,6 @@ class SiteListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.SiteFilterSet
     filterset = filters.SiteFilterSet
     filterset_form = forms.SiteFilterForm
     filterset_form = forms.SiteFilterForm
     table = tables.SiteTable
     table = tables.SiteTable
-    template_name = 'dcim/site_list.html'
 
 
 
 
 class SiteView(PermissionRequiredMixin, View):
 class SiteView(PermissionRequiredMixin, View):
@@ -271,7 +270,6 @@ class RackGroupListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RackGroupFilterSet
     filterset = filters.RackGroupFilterSet
     filterset_form = forms.RackGroupFilterForm
     filterset_form = forms.RackGroupFilterForm
     table = tables.RackGroupTable
     table = tables.RackGroupTable
-    template_name = 'dcim/rackgroup_list.html'
 
 
 
 
 class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
 class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -308,7 +306,6 @@ class RackRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_rackrole'
     permission_required = 'dcim.view_rackrole'
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     queryset = RackRole.objects.annotate(rack_count=Count('racks'))
     table = tables.RackRoleTable
     table = tables.RackRoleTable
-    template_name = 'dcim/rackrole_list.html'
 
 
 
 
 class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -350,7 +347,6 @@ class RackListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RackFilterSet
     filterset = filters.RackFilterSet
     filterset_form = forms.RackFilterForm
     filterset_form = forms.RackFilterForm
     table = tables.RackDetailTable
     table = tables.RackDetailTable
-    template_name = 'dcim/rack_list.html'
 
 
 
 
 class RackElevationListView(PermissionRequiredMixin, View):
 class RackElevationListView(PermissionRequiredMixin, View):
@@ -474,7 +470,7 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RackReservationFilterSet
     filterset = filters.RackReservationFilterSet
     filterset_form = forms.RackReservationFilterForm
     filterset_form = forms.RackReservationFilterForm
     table = tables.RackReservationTable
     table = tables.RackReservationTable
-    template_name = 'dcim/rackreservation_list.html'
+    action_buttons = ()
 
 
 
 
 class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
 class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -533,7 +529,6 @@ class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
         platform_count=Count('platforms', distinct=True),
         platform_count=Count('platforms', distinct=True),
     )
     )
     table = tables.ManufacturerTable
     table = tables.ManufacturerTable
-    template_name = 'dcim/manufacturer_list.html'
 
 
 
 
 class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
 class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -571,7 +566,6 @@ class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceTypeFilterSet
     filterset = filters.DeviceTypeFilterSet
     filterset_form = forms.DeviceTypeFilterForm
     filterset_form = forms.DeviceTypeFilterForm
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
-    template_name = 'dcim/devicetype_list.html'
 
 
 
 
 class DeviceTypeView(PermissionRequiredMixin, View):
 class DeviceTypeView(PermissionRequiredMixin, View):
@@ -995,7 +989,6 @@ class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_devicerole'
     permission_required = 'dcim.view_devicerole'
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     table = tables.DeviceRoleTable
     table = tables.DeviceRoleTable
-    template_name = 'dcim/devicerole_list.html'
 
 
 
 
 class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -1031,7 +1024,6 @@ class PlatformListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'dcim.view_platform'
     permission_required = 'dcim.view_platform'
     queryset = Platform.objects.all()
     queryset = Platform.objects.all()
     table = tables.PlatformTable
     table = tables.PlatformTable
-    template_name = 'dcim/platform_list.html'
 
 
 
 
 class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
 class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -1190,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         device = get_object_or_404(Device, pk=pk)
         device = get_object_or_404(Device, pk=pk)
-        interfaces = device.vc_interfaces.connectable().prefetch_related(
+        interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
             '_connected_interface__device'
             '_connected_interface__device'
         )
         )
 
 
@@ -1292,7 +1284,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsolePortFilterSet
     filterset = filters.ConsolePortFilterSet
     filterset_form = forms.ConsolePortFilterForm
     filterset_form = forms.ConsolePortFilterForm
     table = tables.ConsolePortDetailTable
     table = tables.ConsolePortDetailTable
-    template_name = 'dcim/consoleport_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1345,7 +1337,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConsoleServerPortFilterSet
     filterset = filters.ConsoleServerPortFilterSet
     filterset_form = forms.ConsoleServerPortFilterForm
     filterset_form = forms.ConsoleServerPortFilterForm
     table = tables.ConsoleServerPortDetailTable
     table = tables.ConsoleServerPortDetailTable
-    template_name = 'dcim/consoleserverport_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1410,7 +1402,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPortFilterSet
     filterset = filters.PowerPortFilterSet
     filterset_form = forms.PowerPortFilterForm
     filterset_form = forms.PowerPortFilterForm
     table = tables.PowerPortDetailTable
     table = tables.PowerPortDetailTable
-    template_name = 'dcim/powerport_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1463,7 +1455,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerOutletFilterSet
     filterset = filters.PowerOutletFilterSet
     filterset_form = forms.PowerOutletFilterForm
     filterset_form = forms.PowerOutletFilterForm
     table = tables.PowerOutletDetailTable
     table = tables.PowerOutletDetailTable
-    template_name = 'dcim/poweroutlet_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1528,7 +1520,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InterfaceFilterSet
     filterset = filters.InterfaceFilterSet
     filterset_form = forms.InterfaceFilterForm
     filterset_form = forms.InterfaceFilterForm
     table = tables.InterfaceDetailTable
     table = tables.InterfaceDetailTable
-    template_name = 'dcim/interface_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class InterfaceView(PermissionRequiredMixin, View):
 class InterfaceView(PermissionRequiredMixin, View):
@@ -1630,7 +1622,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.FrontPortFilterSet
     filterset = filters.FrontPortFilterSet
     filterset_form = forms.FrontPortFilterForm
     filterset_form = forms.FrontPortFilterForm
     table = tables.FrontPortDetailTable
     table = tables.FrontPortDetailTable
-    template_name = 'dcim/frontport_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1695,7 +1687,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.RearPortFilterSet
     filterset = filters.RearPortFilterSet
     filterset_form = forms.RearPortFilterForm
     filterset_form = forms.RearPortFilterForm
     table = tables.RearPortDetailTable
     table = tables.RearPortDetailTable
-    template_name = 'dcim/rearport_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1762,7 +1754,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.DeviceBayFilterSet
     filterset = filters.DeviceBayFilterSet
     filterset_form = forms.DeviceBayFilterForm
     filterset_form = forms.DeviceBayFilterForm
     table = tables.DeviceBayDetailTable
     table = tables.DeviceBayDetailTable
-    template_name = 'dcim/devicebay_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
@@ -1961,7 +1953,7 @@ class CableListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.CableFilterSet
     filterset = filters.CableFilterSet
     filterset_form = forms.CableFilterForm
     filterset_form = forms.CableFilterForm
     table = tables.CableTable
     table = tables.CableTable
-    template_name = 'dcim/cable_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class CableView(PermissionRequiredMixin, View):
 class CableView(PermissionRequiredMixin, View):
@@ -2233,7 +2225,7 @@ class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.InventoryItemFilterSet
     filterset = filters.InventoryItemFilterSet
     filterset_form = forms.InventoryItemFilterForm
     filterset_form = forms.InventoryItemFilterForm
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
-    template_name = 'dcim/inventoryitem_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
 class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
@@ -2289,7 +2281,7 @@ class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
     table = tables.VirtualChassisTable
     table = tables.VirtualChassisTable
     filterset = filters.VirtualChassisFilterSet
     filterset = filters.VirtualChassisFilterSet
     filterset_form = forms.VirtualChassisFilterForm
     filterset_form = forms.VirtualChassisFilterForm
-    template_name = 'dcim/virtualchassis_list.html'
+    action_buttons = ('export',)
 
 
 
 
 class VirtualChassisCreateView(PermissionRequiredMixin, View):
 class VirtualChassisCreateView(PermissionRequiredMixin, View):
@@ -2533,7 +2525,6 @@ class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerPanelFilterSet
     filterset = filters.PowerPanelFilterSet
     filterset_form = forms.PowerPanelFilterForm
     filterset_form = forms.PowerPanelFilterForm
     table = tables.PowerPanelTable
     table = tables.PowerPanelTable
-    template_name = 'dcim/powerpanel_list.html'
 
 
 
 
 class PowerPanelView(PermissionRequiredMixin, View):
 class PowerPanelView(PermissionRequiredMixin, View):
@@ -2602,7 +2593,6 @@ class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.PowerFeedFilterSet
     filterset = filters.PowerFeedFilterSet
     filterset_form = forms.PowerFeedFilterForm
     filterset_form = forms.PowerFeedFilterForm
     table = tables.PowerFeedTable
     table = tables.PowerFeedTable
-    template_name = 'dcim/powerfeed_list.html'
 
 
 
 
 class PowerFeedView(PermissionRequiredMixin, View):
 class PowerFeedView(PermissionRequiredMixin, View):

+ 0 - 20
netbox/extras/apps.py

@@ -1,28 +1,8 @@
 from django.apps import AppConfig
 from django.apps import AppConfig
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
-import redis
 
 
 
 
 class ExtrasConfig(AppConfig):
 class ExtrasConfig(AppConfig):
     name = "extras"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
-
         import extras.signals
         import extras.signals
-
-        # Check that we can connect to the configured Redis database.
-        try:
-            rs = redis.Redis(
-                host=settings.WEBHOOKS_REDIS_HOST,
-                port=settings.WEBHOOKS_REDIS_PORT,
-                db=settings.WEBHOOKS_REDIS_DATABASE,
-                password=settings.WEBHOOKS_REDIS_PASSWORD or None,
-                ssl=settings.WEBHOOKS_REDIS_SSL,
-            )
-            rs.ping()
-        except redis.exceptions.ConnectionError:
-            raise ImproperlyConfigured(
-                "Unable to connect to the Redis database. Check that the Redis configuration has been defined in "
-                "configuration.py."
-            )

+ 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
 from .models import ConfigContext, ObjectChange, Tag, TaggedItem
 
 
 TAG_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.taggit.change_tag %}
 {% if perms.taggit.change_tag %}

+ 27 - 2
netbox/extras/views.py

@@ -12,6 +12,7 @@ from django_tables2 import RequestConfig
 
 
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
+from utilities.utils import shallow_compare_dict
 from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
 from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
 from . import filters, forms
 from . import filters, forms
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
 from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
@@ -34,7 +35,7 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.TagFilterSet
     filterset = filters.TagFilterSet
     filterset_form = forms.TagFilterForm
     filterset_form = forms.TagFilterForm
     table = TagTable
     table = TagTable
-    template_name = 'extras/tag_list.html'
+    action_buttons = ()
 
 
 
 
 class TagView(PermissionRequiredMixin, View):
 class TagView(PermissionRequiredMixin, View):
@@ -111,7 +112,7 @@ class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ConfigContextFilterSet
     filterset = filters.ConfigContextFilterSet
     filterset_form = forms.ConfigContextFilterForm
     filterset_form = forms.ConfigContextFilterForm
     table = ConfigContextTable
     table = ConfigContextTable
-    template_name = 'extras/configcontext_list.html'
+    action_buttons = ('add',)
 
 
 
 
 class ConfigContextView(PermissionRequiredMixin, View):
 class ConfigContextView(PermissionRequiredMixin, View):
@@ -191,6 +192,7 @@ class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
     filterset_form = forms.ObjectChangeFilterForm
     filterset_form = forms.ObjectChangeFilterForm
     table = ObjectChangeTable
     table = ObjectChangeTable
     template_name = 'extras/objectchange_list.html'
     template_name = 'extras/objectchange_list.html'
+    action_buttons = ('export',)
 
 
 
 
 class ObjectChangeView(PermissionRequiredMixin, View):
 class ObjectChangeView(PermissionRequiredMixin, View):
@@ -206,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View):
             orderable=False
             orderable=False
         )
         )
 
 
+        objectchanges = ObjectChange.objects.filter(
+            changed_object_type=objectchange.changed_object_type,
+            changed_object_id=objectchange.changed_object_id,
+        )
+
+        next_change = objectchanges.filter(time__gt=objectchange.time).order_by('time').first()
+        prev_change = objectchanges.filter(time__lt=objectchange.time).order_by('-time').first()
+
+        if prev_change:
+            diff_added = shallow_compare_dict(
+                prev_change.object_data,
+                objectchange.object_data,
+                exclude=['last_updated'],
+            )
+            diff_removed = {x: prev_change.object_data.get(x) for x in diff_added}
+        else:
+            # No previous change; this is the initial change that added the object
+            diff_added = diff_removed = objectchange.object_data
+
         return render(request, 'extras/objectchange.html', {
         return render(request, 'extras/objectchange.html', {
             'objectchange': objectchange,
             'objectchange': objectchange,
+            'diff_added': diff_added,
+            'diff_removed': diff_removed,
+            'next_change': next_change,
+            'prev_change': prev_change,
             'related_changes_table': related_changes_table,
             'related_changes_table': related_changes_table,
             'related_changes_count': related_changes.count()
             'related_changes_count': related_changes.count()
         })
         })

+ 10 - 0
netbox/ipam/api/views.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf import settings
 from django.db.models import Count
 from django.db.models import Count
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
+from django_pglocks import advisory_lock
 from rest_framework import status
 from rest_framework import status
 from rest_framework.decorators import action
 from rest_framework.decorators import action
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.exceptions import PermissionDenied
@@ -10,6 +11,7 @@ from extras.api.views import CustomFieldModelViewSet
 from ipam import filters
 from ipam import filters
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from utilities.api import FieldChoicesViewSet, ModelViewSet
 from utilities.api import FieldChoicesViewSet, ModelViewSet
+from utilities.constants import ADVISORY_LOCK_KEYS
 from utilities.utils import get_subquery
 from utilities.utils import get_subquery
 from . import serializers
 from . import serializers
 
 
@@ -86,9 +88,13 @@ class PrefixViewSet(CustomFieldModelViewSet):
     filterset_class = filters.PrefixFilterSet
     filterset_class = filters.PrefixFilterSet
 
 
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
     @action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
+    @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
     def available_prefixes(self, request, pk=None):
     def available_prefixes(self, request, pk=None):
         """
         """
         A convenience method for returning available child prefixes within a parent.
         A convenience method for returning available child prefixes within a parent.
+
+        The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
+        invoked in parallel, which results in a race condition where multiple insertions can occur.
         """
         """
         prefix = get_object_or_404(Prefix, pk=pk)
         prefix = get_object_or_404(Prefix, pk=pk)
         available_prefixes = prefix.get_available_prefixes()
         available_prefixes = prefix.get_available_prefixes()
@@ -180,11 +186,15 @@ class PrefixViewSet(CustomFieldModelViewSet):
             return Response(serializer.data)
             return Response(serializer.data)
 
 
     @action(detail=True, url_path='available-ips', methods=['get', 'post'])
     @action(detail=True, url_path='available-ips', methods=['get', 'post'])
+    @advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
     def available_ips(self, request, pk=None):
     def available_ips(self, request, pk=None):
         """
         """
         A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
         A convenience method for returning available IP addresses within a prefix. By default, the number of IPs
         returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed,
         returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed,
         however results will not be paginated.
         however results will not be paginated.
+
+        The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
+        invoked in parallel, which results in a race condition where multiple insertions can occur.
         """
         """
         prefix = get_object_or_404(Prefix, pk=pk)
         prefix = get_object_or_404(Prefix, pk=pk)
 
 

+ 15 - 1
netbox/ipam/lookups.py

@@ -154,10 +154,24 @@ class NetHostContained(Lookup):
         return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
         return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
 
 
 
 
+#
+# Transforms
+#
+
 class NetMaskLength(Transform):
 class NetMaskLength(Transform):
-    lookup_name = 'net_mask_length'
     function = 'MASKLEN'
     function = 'MASKLEN'
+    lookup_name = 'net_mask_length'
 
 
     @property
     @property
     def output_field(self):
     def output_field(self):
         return IntegerField()
         return IntegerField()
+
+
+class Host(Transform):
+    function = 'HOST'
+    lookup_name = 'host'
+
+
+class Inet(Transform):
+    function = 'INET'
+    lookup_name = 'inet'

+ 3 - 2
netbox/ipam/managers.py

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

+ 3 - 3
netbox/ipam/tables.py

@@ -26,7 +26,7 @@ RIR_UTILIZATION = """
 """
 """
 
 
 RIR_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.ipam.change_rir %}
 {% if perms.ipam.change_rir %}
@@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """
 """
 """
 
 
 ROLE_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.ipam.change_role %}
 {% if perms.ipam.change_role %}
@@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """
 """
 """
 
 
 VLANGROUP_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% with next_vid=record.get_next_available_vid %}
 {% with next_vid=record.get_next_available_vid %}

+ 1 - 7
netbox/ipam/views.py

@@ -118,7 +118,6 @@ class VRFListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.VRFFilterSet
     filterset = filters.VRFFilterSet
     filterset_form = forms.VRFFilterForm
     filterset_form = forms.VRFFilterForm
     table = tables.VRFTable
     table = tables.VRFTable
-    template_name = 'ipam/vrf_list.html'
 
 
 
 
 class VRFView(PermissionRequiredMixin, View):
 class VRFView(PermissionRequiredMixin, View):
@@ -293,7 +292,6 @@ class AggregateListView(PermissionRequiredMixin, ObjectListView):
     queryset = Aggregate.objects.prefetch_related('rir').annotate(
     queryset = Aggregate.objects.prefetch_related('rir').annotate(
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
         child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
     )
     )
-
     filterset = filters.AggregateFilterSet
     filterset = filters.AggregateFilterSet
     filterset_form = forms.AggregateFilterForm
     filterset_form = forms.AggregateFilterForm
     table = tables.AggregateDetailTable
     table = tables.AggregateDetailTable
@@ -411,7 +409,6 @@ class RoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'ipam.view_role'
     permission_required = 'ipam.view_role'
     queryset = Role.objects.all()
     queryset = Role.objects.all()
     table = tables.RoleTable
     table = tables.RoleTable
-    template_name = 'ipam/role_list.html'
 
 
 
 
 class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -644,7 +641,6 @@ class IPAddressListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.IPAddressFilterSet
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     filterset_form = forms.IPAddressFilterForm
     table = tables.IPAddressDetailTable
     table = tables.IPAddressDetailTable
-    template_name = 'ipam/ipaddress_list.html'
 
 
 
 
 class IPAddressView(PermissionRequiredMixin, View):
 class IPAddressView(PermissionRequiredMixin, View):
@@ -817,7 +813,6 @@ class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.VLANGroupFilterSet
     filterset = filters.VLANGroupFilterSet
     filterset_form = forms.VLANGroupFilterForm
     filterset_form = forms.VLANGroupFilterForm
     table = tables.VLANGroupTable
     table = tables.VLANGroupTable
-    template_name = 'ipam/vlangroup_list.html'
 
 
 
 
 class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
 class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -893,7 +888,6 @@ class VLANListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.VLANFilterSet
     filterset = filters.VLANFilterSet
     filterset_form = forms.VLANFilterForm
     filterset_form = forms.VLANFilterForm
     table = tables.VLANDetailTable
     table = tables.VLANDetailTable
-    template_name = 'ipam/vlan_list.html'
 
 
 
 
 class VLANView(PermissionRequiredMixin, View):
 class VLANView(PermissionRequiredMixin, View):
@@ -989,7 +983,7 @@ class ServiceListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.ServiceFilterSet
     filterset = filters.ServiceFilterSet
     filterset_form = forms.ServiceFilterForm
     filterset_form = forms.ServiceFilterForm
     table = tables.ServiceTable
     table = tables.ServiceTable
-    template_name = 'ipam/service_list.html'
+    action_buttons = ('export',)
 
 
 
 
 class ServiceView(PermissionRequiredMixin, View):
 class ServiceView(PermissionRequiredMixin, View):

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

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

+ 1 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.7.6'
+VERSION = '2.7.7'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 1 - 17
netbox/project-static/css/base.css

@@ -179,26 +179,10 @@ nav ul.pagination {
 
 
 /* Racks */
 /* Racks */
 div.rack_header {
 div.rack_header {
-    margin-left: 36px;
+    margin-left: 30px;
     text-align: center;
     text-align: center;
     width: 230px;
     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;
-}
 
 
 /* Devices */
 /* Devices */
 table.component-list td.subtable {
 table.component-list td.subtable {

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

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

+ 20 - 11
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
         return s.substring(0, num_chars);           // Trim to first num_chars chars
     }
     }
     var slug_field = $('#id_slug');
     var slug_field = $('#id_slug');
-    slug_field.change(function() {
-        $(this).attr('_changed', true);
-    });
     if (slug_field) {
     if (slug_field) {
         var slug_source = $('#id_' + slug_field.attr('slug-source'));
         var slug_source = $('#id_' + slug_field.attr('slug-source'));
         var slug_length = slug_field.attr('maxlength');
         var slug_length = slug_field.attr('maxlength');
+        if (slug_field.val()) {
+            slug_field.attr('_changed', true);
+        }
+        slug_field.change(function() {
+            $(this).attr('_changed', true);
+        });
         slug_source.on('keyup change', function() {
         slug_source.on('keyup change', function() {
             if (slug_field && !slug_field.attr('_changed')) {
             if (slug_field && !slug_field.attr('_changed')) {
                 slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
                 slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
             }
             }
-        })
+        });
+        $('button.reslugify').click(function() {
+            slug_field.val(slugify(slug_source.val(), (slug_length ? slug_length : 50)));
+        });
     }
     }
 
 
     // Bulk edit nullification
     // Bulk edit nullification
@@ -190,15 +196,18 @@ $(document).ready(function() {
                 $.each(element.attributes, function(index, attr){
                 $.each(element.attributes, function(index, attr){
                     if (attr.name.includes("data-additional-query-param-")){
                     if (attr.name.includes("data-additional-query-param-")){
                         var param_name = attr.name.split("data-additional-query-param-")[1];
                         var param_name = attr.name.split("data-additional-query-param-")[1];
-                        if (param_name in parameters) {
-                            if (Array.isArray(parameters[param_name])) {
-                                parameters[param_name].push(attr.value)
+
+                        $.each($.parseJSON(attr.value), function(index, value) {
+                            if (param_name in parameters) {
+                                if (Array.isArray(parameters[param_name])) {
+                                    parameters[param_name].push(value);
+                                } else {
+                                    parameters[param_name] = [parameters[param_name], value];
+                                }
                             } else {
                             } else {
-                                parameters[param_name] = [parameters[param_name], attr.value]
+                                parameters[param_name] = value;
                             }
                             }
-                        } else {
-                            parameters[param_name] = attr.value;
-                        }
+                        });
                     }
                     }
                 });
                 });
 
 

+ 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(
     role = DynamicModelMultipleChoiceField(
         queryset=SecretRole.objects.all(),
         queryset=SecretRole.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
-        required=True,
+        required=False,
         widget=APISelectMultiple(
         widget=APISelectMultiple(
             api_url="/api/secrets/secret-roles/",
             api_url="/api/secrets/secret-roles/",
             value_field="slug",
             value_field="slug",

+ 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
     Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the
     ciphertext; this string is stored as plain text in the database.
     ciphertext; this string is stored as plain text in the database.
 
 
-    A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
-    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(
     device = models.ForeignKey(
         to='dcim.Device',
         to='dcim.Device',
@@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         blank=True
         blank=True
     )
     )
     ciphertext = models.BinaryField(
     ciphertext = models.BinaryField(
-        max_length=65568,  # 16B IV + 2B pad length + {62-65550}B padded
+        max_length=65568,  # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding
         editable=False
         editable=False
     )
     )
     hash = models.CharField(
     hash = models.CharField(
@@ -388,11 +388,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
         else:
         else:
             pad_length = 0
             pad_length = 0
 
 
-        # Python 2 compatibility
-        if sys.version_info[0] < 3:
-            header = chr(len(s) >> 8) + chr(len(s) % 256)
-        else:
-            header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
+        header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
 
 
         return header + s + os.urandom(pad_length)
         return header + s + os.urandom(pad_length)
 
 

+ 1 - 1
netbox/secrets/tables.py

@@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import SecretRole, Secret
 from .models import SecretRole, Secret
 
 
 SECRETROLE_ACTIONS = """
 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>
     <i class="fa fa-history"></i>
 </a>
 </a>
 {% if perms.secrets.change_secretrole %}
 {% if perms.secrets.change_secretrole %}

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

@@ -85,14 +85,19 @@ class UserKeyTestCase(TestCase):
 
 
 class SecretTestCase(TestCase):
 class SecretTestCase(TestCase):
 
 
+    @classmethod
+    def setUpTestData(cls):
+
+        # Generate a random key for encryption/decryption of secrets
+        cls.secret_key = generate_random_key()
+
     def test_01_encrypt_decrypt(self):
     def test_01_encrypt_decrypt(self):
         """
         """
         Test basic encryption and decryption functionality using a random master key.
         Test basic encryption and decryption functionality using a random master key.
         """
         """
         plaintext = string.printable * 2
         plaintext = string.printable * 2
-        secret_key = generate_random_key()
         s = Secret(plaintext=plaintext)
         s = Secret(plaintext=plaintext)
-        s.encrypt(secret_key)
+        s.encrypt(self.secret_key)
 
 
         # Ensure plaintext is deleted upon encryption
         # Ensure plaintext is deleted upon encryption
         self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
         self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
@@ -112,7 +117,7 @@ class SecretTestCase(TestCase):
         self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
         self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
 
 
         # Test decryption
         # Test decryption
-        s.decrypt(secret_key)
+        s.decrypt(self.secret_key)
         self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
         self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
 
 
     def test_02_ciphertext_uniqueness(self):
     def test_02_ciphertext_uniqueness(self):
@@ -120,15 +125,45 @@ class SecretTestCase(TestCase):
         Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
         Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
         """
         """
         plaintext = "1234567890abcdef"
         plaintext = "1234567890abcdef"
-        secret_key = generate_random_key()
         ivs = []
         ivs = []
         ciphertexts = []
         ciphertexts = []
         for i in range(1, 51):
         for i in range(1, 51):
             s = Secret(plaintext=plaintext)
             s = Secret(plaintext=plaintext)
-            s.encrypt(secret_key)
+            s.encrypt(self.secret_key)
             ivs.append(s.ciphertext[0:16])
             ivs.append(s.ciphertext[0:16])
             ciphertexts.append(s.ciphertext[16:32])
             ciphertexts.append(s.ciphertext[16:32])
         duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
         duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
         self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
         self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
         duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
         duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
         self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
         self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
+
+    def test_minimum_length(self):
+        """
+        Test enforcement of the minimum length for ciphertexts.
+        """
+        plaintext = 'A'  # One-byte plaintext
+        secret = Secret(plaintext=plaintext)
+        secret.encrypt(self.secret_key)
+
+        # 16B IV + 2B length + 1B secret + 61B padding = 80 bytes
+        self.assertEqual(len(secret.ciphertext), 80)
+        self.assertIsNone(secret.plaintext)
+
+        secret.decrypt(self.secret_key)
+        self.assertEqual(secret.plaintext, plaintext)
+
+    def test_maximum_length(self):
+        """
+        Test encrypting a plaintext value of the maximum length.
+        """
+        plaintext = '0123456789abcdef' * 4096
+        plaintext = plaintext[:65535]  # 65,535 chars
+        secret = Secret(plaintext=plaintext)
+        secret.encrypt(self.secret_key)
+
+        # 16B IV + 2B length + 65535B secret + 15B padding = 65568 bytes
+        self.assertEqual(len(secret.ciphertext), 65568)
+        self.assertIsNone(secret.plaintext)
+
+        secret.decrypt(self.secret_key)
+        self.assertEqual(secret.plaintext, plaintext)

+ 1 - 2
netbox/secrets/views.py

@@ -35,7 +35,6 @@ class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
     permission_required = 'secrets.view_secretrole'
     permission_required = 'secrets.view_secretrole'
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
     table = tables.SecretRoleTable
     table = tables.SecretRoleTable
-    template_name = 'secrets/secretrole_list.html'
 
 
 
 
 class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
 class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
@@ -73,7 +72,7 @@ class SecretListView(PermissionRequiredMixin, ObjectListView):
     filterset = filters.SecretFilterSet
     filterset = filters.SecretFilterSet
     filterset_form = forms.SecretFilterForm
     filterset_form = forms.SecretFilterForm
     table = tables.SecretTable
     table = tables.SecretTable
-    template_name = 'secrets/secret_list.html'
+    action_buttons = ('import', 'export')
 
 
 
 
 class SecretView(PermissionRequiredMixin, View):
 class SecretView(PermissionRequiredMixin, View):

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

@@ -49,7 +49,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
+                <a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 21
netbox/templates/circuits/circuit_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.circuits.add_circuit %}
-        {% add_button 'circuits:circuit_add' %}
-        {% import_button 'circuits:circuit_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Circuits{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/circuits/circuittype_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.circuits.add_circuittype %}
-        {% add_button 'circuits:circuittype_add' %}
-        {% import_button 'circuits:circuittype_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Circuit Types{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='circuits:circuittype_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -54,7 +54,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
+                <a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 21
netbox/templates/circuits/provider_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.circuits.add_provider %}
-        {% add_button 'circuits:provider_add' %}
-        {% import_button 'circuits:provider_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Providers{% endblock %}</h1>
-<div class="row">
-    <div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -31,7 +31,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Changelog</a>
+                <a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 20
netbox/templates/dcim/cable_list.html

@@ -1,20 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_cable %}
-        {% import_button 'dcim:cable_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Cables{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/consoleport_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Console Ports{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/consoleserverport_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Console Server Ports{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -119,7 +119,7 @@
         {% endif %}
         {% endif %}
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
+                <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 21 - 18
netbox/templates/dcim/device_list.html

@@ -1,21 +1,24 @@
-{% extends '_base.html' %}
-{% load buttons %}
+{% extends 'utilities/obj_list.html' %}
 
 
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_device %}
-        {% add_button 'dcim:device_add' %}
-        {% import_button 'dcim:device_import' %}
+{% block bulk_buttons %}
+    {% if perms.dcim.change_device %}
+        <div class="btn-group">
+            <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
+            </button>
+            <ul class="dropdown-menu">
+                {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
+                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
+                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
+                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
+                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
+            </ul>
+        </div>
+    {% endif %}
+    {% if perms.dcim.add_virtualchassis %}
+        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
+        </button>
     {% endif %}
     {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Devices{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
 {% endblock %}
 {% endblock %}

+ 0 - 17
netbox/templates/dcim/devicebay_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Device Bays{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicebay_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/devicerole_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_devicerole %}
-        {% add_button 'dcim:devicerole_add' %}
-        {% import_button 'dcim:devicerole_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Device Roles{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicerole_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -54,7 +54,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
+                <a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>
@@ -109,6 +109,30 @@
                         {% endif %}
                         {% endif %}
                     </td>
                     </td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Front Image</td>
+                    <td>
+                        {% if devicetype.front_image %}
+                            <a href="{{ devicetype.front_image.url }}">
+                                <img src="{{ devicetype.front_image.url }}" alt="{{ devicetype.front_image.name }}" class="img-responsive" />
+                            </a>
+                        {% else %}
+                            <span class="text-muted">&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>
                 <tr>
                     <td>Instances</td>
                     <td>Instances</td>
                     <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
                     <td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>

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

@@ -14,6 +14,13 @@
             {% render_field form.subdevice_role %}
             {% render_field form.subdevice_role %}
         </div>
         </div>
     </div>
     </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Rack Images</strong></div>
+        <div class="panel-body">
+            {% render_field form.front_image %}
+            {% render_field form.rear_image %}
+        </div>
+    </div>
     {% if form.custom_fields %}
     {% if form.custom_fields %}
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading"><strong>Custom Fields</strong></div>
             <div class="panel-heading"><strong>Custom Fields</strong></div>

+ 0 - 21
netbox/templates/dcim/devicetype_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_devicetype %}
-        {% add_button 'dcim:devicetype_add' %}
-        {% import_button 'dcim:devicetype_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Device Types{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/frontport_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Front Ports{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:frontport_bulk_edit' bulk_delete_url='dcim:frontport_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 24
netbox/templates/dcim/inc/device_table.html

@@ -1,24 +0,0 @@
-{% extends 'utilities/obj_table.html' %}
-
-{% block extra_actions %}
-    {% if perms.dcim.change_device %}
-        <div class="btn-group">
-            <button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
-            </button>
-            <ul class="dropdown-menu">
-                {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Ports</a></li>{% endif %}
-                {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Console Server Ports</a></li>{% endif %}
-                {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Ports</a></li>{% endif %}
-                {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Power Outlets</a></li>{% endif %}
-                {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
-                {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
-            </ul>
-        </div>
-    {% endif %}
-    {% if perms.dcim.add_virtualchassis %}
-        <button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
-            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
-        </button>
-    {% endif %}
-{% endblock %}

+ 1 - 4
netbox/templates/dcim/inc/rack_elevation.html

@@ -1,7 +1,4 @@
 {% load helpers %}
 {% load helpers %}
-
 <div class="rack_frame">
 <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>
 </div>

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

@@ -34,7 +34,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Changelog</a>
+                <a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 17
netbox/templates/dcim/interface_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Interfaces{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:interface_bulk_edit' bulk_delete_url='dcim:interface_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 21
netbox/templates/dcim/inventoryitem_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-{% load helpers %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_devicetype %}
-        {% import_button 'dcim:inventoryitem_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Inventory Items{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/manufacturer_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_manufacturer %}
-        {% add_button 'dcim:manufacturer_add' %}
-        {% import_button 'dcim:manufacturer_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Manufacturers{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:manufacturer_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/platform_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_platform %}
-        {% add_button 'dcim:platform_add' %}
-        {% import_button 'dcim:platform_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Platforms{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:platform_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -52,7 +52,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Changelog</a>
+                <a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>
@@ -121,18 +121,8 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
-        <div class="panel panel-default">
-            <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>
     <div class="col-md-6">
     <div class="col-md-6">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -162,6 +152,18 @@
                 </tr>
                 </tr>
             </table>
             </table>
         </div>
         </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body rendered-markdown">
+                {% if powerfeed.comments %}
+                    {{ powerfeed.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 0 - 21
netbox/templates/dcim/powerfeed_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_powerfeed %}
-        {% add_button 'dcim:powerfeed_add' %}
-        {% import_button 'dcim:powerfeed_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Power Feeds{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/poweroutlet_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Power Outlets{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:poweroutlet_bulk_edit' bulk_delete_url='dcim:poweroutlet_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -48,7 +48,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Changelog</a>
+                <a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 21
netbox/templates/dcim/powerpanel_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_powerpanel %}
-        {% add_button 'dcim:powerpanel_add' %}
-        {% import_button 'dcim:powerpanel_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Power Panels{% endblock %}</h1>
-<div class="row">
-    <div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %}
-    </div>
-    <div class="col-md-3">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/powerport_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Power Ports{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerport_bulk_edit' bulk_delete_url='dcim:powerport_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -2,6 +2,7 @@
 {% load buttons %}
 {% load buttons %}
 {% load custom_links %}
 {% load custom_links %}
 {% load helpers %}
 {% load helpers %}
+{% load static %}
 
 
 {% block header %}
 {% block header %}
     <div class="row noprint">
     <div class="row noprint">
@@ -45,6 +46,9 @@
     <h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
     <h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
     {% include 'inc/created_updated.html' with obj=rack %}
     {% include 'inc/created_updated.html' with obj=rack %}
     <div class="pull-right noprint">
     <div class="pull-right noprint">
+        <button class="btn btn-sm btn-default toggle-images" selected="selected">
+            <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
+        </button>
         {% custom_links rack %}
         {% custom_links rack %}
     </div>
     </div>
     <ul class="nav nav-tabs">
     <ul class="nav nav-tabs">
@@ -53,7 +57,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
+                <a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>
@@ -368,9 +372,5 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-<script type="text/javascript">
-$(function() {
-  $('[data-toggle="popover"]').popover()
-})
-</script>
+<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
 {% endblock %}
 {% endblock %}

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

@@ -1,8 +1,12 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load helpers %}
 {% load helpers %}
+{% load static %}
 
 
 {% block content %}
 {% block content %}
 <div class="btn-group pull-right noprint" role="group">
 <div class="btn-group pull-right noprint" role="group">
+    <button class="btn btn-default toggle-images" selected="selected">
+        <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
+    </button>
     <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
     <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
     <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
     <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
 </div>
 </div>
@@ -41,9 +45,5 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block javascript %}
 {% block javascript %}
-    <script type="text/javascript">
-    $(function() {
-        $('[data-toggle="popover"]').popover()
-    })
-    </script>
+<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
 {% endblock %}
 {% endblock %}

+ 0 - 21
netbox/templates/dcim/rack_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_rack %}
-        {% add_button 'dcim:rack_add' %}
-        {% import_button 'dcim:rack_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Racks{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rack_bulk_edit' bulk_delete_url='dcim:rack_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 21
netbox/templates/dcim/rackgroup_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_rackgroup %}
-        {% add_button 'dcim:rackgroup_add' %}
-        {% import_button 'dcim:rackgroup_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Rack Groups{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 14
netbox/templates/dcim/rackreservation_list.html

@@ -1,14 +0,0 @@
-{% extends '_base.html' %}
-{% load helpers %}
-
-{% block content %}
-<h1>{% block title %}Rack Reservations{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %}
-    </div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/rackrole_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_rackrole %}
-        {% add_button 'dcim:rackrole_add' %}
-        {% import_button 'dcim:rackrole_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Rack Roles{% endblock %}</h1>
-<div class="row">
-    <div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/dcim/rearport_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Rear Ports{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rearport_bulk_edit' bulk_delete_url='dcim:rearport_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 21
netbox/templates/dcim/region_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_region %}
-        {% add_button 'dcim:region_add' %}
-        {% import_button 'dcim:region_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Regions{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
-    </div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
-{% endblock %}

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

@@ -60,7 +60,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
+                <a href="{% url 'dcim:site_changelog' slug=site.slug %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 21
netbox/templates/dcim/site_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.dcim.add_site %}
-        {% add_button 'dcim:site_add' %}
-        {% import_button 'dcim:site_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Sites{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' bulk_delete_url='dcim:site_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 18
netbox/templates/dcim/virtualchassis_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-{% load helpers %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 19
netbox/templates/extras/configcontext_list.html

@@ -1,19 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-    <div class="pull-right noprint">
-        {% if perms.extras.add_configcontext %}
-            {% add_button 'extras:configcontext_add' %}
-        {% endif %}
-    </div>
-    <h1>{% block title %}Config Contexts{% endblock %}</h1>
-    <div class="row">
-        <div class="col-md-9">
-            {% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
-        </div>
-        <div class="col-md-3 noprint">
-            {% include 'inc/search_panel.html' %}
-        </div>
-    </div>
-{% endblock %}

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

@@ -1,12 +1,12 @@
 {% extends base_template %}
 {% extends base_template %}
 
 
-{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %}
+{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Change Log{% endblock %}
 
 
 {% block content %}
 {% block content %}
     {% if obj %}<h1>{{ obj }}</h1>{% endif %}
     {% if obj %}<h1>{{ obj }}</h1>{% endif %}
     {% include 'panel_table.html' %}
     {% include 'panel_table.html' %}
     {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     <div class="text-muted">
     <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>
     </div>
 {% endblock %}
 {% endblock %}

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

@@ -7,7 +7,7 @@
     <div class="row noprint">
     <div class="row noprint">
         <div class="col-sm-8 col-md-9">
         <div class="col-sm-8 col-md-9">
             <ol class="breadcrumb">
             <ol class="breadcrumb">
-                <li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li>
+                <li><a href="{% url 'extras:objectchange_list' %}">Change Log</a></li>
                 {% if objectchange.related_object.get_absolute_url %}
                 {% if objectchange.related_object.get_absolute_url %}
                     <li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
                     <li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
                 {% elif objectchange.changed_object.get_absolute_url %}
                 {% elif objectchange.changed_object.get_absolute_url %}
@@ -83,6 +83,35 @@
                     </tr>
                     </tr>
                 </table>
                 </table>
             </div>
             </div>
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Difference</strong>
+                    <div class="btn-group btn-group-xs pull-right noprint">
+                        <a {% if prev_change %}href="{% url 'extras:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
+                            <span class="fa fa-chevron-left" aria-hidden="true"></span> Previous
+                        </a>
+                        <a {% if next_change %}href="{% url 'extras:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
+                            Next <span class="fa fa-chevron-right" aria-hidden="true"></span>
+                        </a>
+                    </div>
+                </div>
+                <div class="panel-body">
+                    {% if diff_added == diff_removed %}
+                        <span class="text-muted" style="margin-left: 10px;">
+                            {% if objectchange.action == 'create' %}
+                                Object created
+                            {% elif objectchange.action == 'delete' %}
+                                Object deleted
+                            {% else %}
+                                No changes
+                            {% endif %}
+                        </span>
+                    {% else %}
+                        <pre style="background-color: #ffdce0;">{{ diff_removed|render_json }}</pre>
+                        <pre style="background-color: #cdffd8;">{{ diff_added|render_json }}</pre>
+                    {% endif %}
+                </div>
+            </div>
         </div>
         </div>
         <div class="col-md-7">
         <div class="col-md-7">
             <div class="panel panel-default">
             <div class="panel panel-default">

+ 6 - 17
netbox/templates/extras/objectchange_list.html

@@ -1,20 +1,9 @@
-{% extends '_base.html' %}
-{% load buttons %}
+{% extends 'utilities/obj_list.html' %}
 
 
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Changelog{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' %}
-        <div class="text-muted text-right">
-            Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
-        </div>
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
+{% block title %}Change Log{% endblock %}
+
+{% block sidebar %}
+    <div class="text-muted">
+        Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
     </div>
     </div>
-</div>
 {% endblock %}
 {% endblock %}

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

@@ -44,7 +44,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
+                <a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 14
netbox/templates/extras/tag_list.html

@@ -1,14 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<h1>{% block title %}Tags{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='extras:tag_bulk_edit' bulk_delete_url='extras:tag_bulk_delete' %}
-    </div>
-    <div class="col-md-3">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

+ 1 - 1
netbox/templates/home.html

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

+ 1 - 1
netbox/templates/inc/nav_menu.html

@@ -473,7 +473,7 @@
                     <ul class="dropdown-menu">
                     <ul class="dropdown-menu">
                         <li class="dropdown-header">Logging</li>
                         <li class="dropdown-header">Logging</li>
                         <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
                         <li{% if not perms.extras.view_objectchange %} class="disabled"{% endif %}>
-                            <a href="{% url 'extras:objectchange_list' %}">Changelog</a>
+                            <a href="{% url 'extras:objectchange_list' %}">Change Log</a>
                         </li>
                         </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Miscellaneous</li>
                         <li class="dropdown-header">Miscellaneous</li>

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

@@ -48,7 +48,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
+                <a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 10 - 27
netbox/templates/ipam/aggregate_list.html

@@ -1,31 +1,14 @@
-{% extends '_base.html' %}
-{% load buttons %}
+{% extends 'utilities/obj_list.html' %}
 {% load humanize %}
 {% load humanize %}
 
 
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.ipam.add_aggregate %}
-        {% add_button 'ipam:aggregate_add' %}
-        {% import_button 'ipam:aggregate_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Aggregates{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:aggregate_bulk_edit' bulk_delete_url='ipam:aggregate_bulk_delete' %}
-	</div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong><i class="fa fa-bar-chart"></i> Statistics</strong>
-            </div>
-            <ul class="list-group">
-                <li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
-                <li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
-            </ul>
+{% block sidebar %}
+    <div class="panel panel-default">
+        <div class="panel-heading">
+            <strong><i class="fa fa-bar-chart"></i> Statistics</strong>
         </div>
         </div>
-	</div>
-</div>
+        <ul class="list-group">
+            <li class="list-group-item">Total IPv4 IPs <span class="badge">{{ ipv4_total|intcomma }}</span></li>
+            <li class="list-group-item">Total IPv6 /64s <span class="badge">{{ ipv6_total|intcomma }}</span></li>
+        </ul>
+    </div>
 {% endblock %}
 {% endblock %}

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

@@ -14,7 +14,7 @@
     </td>
     </td>
     <td>{{ service.description }}</td>
     <td>{{ service.description }}</td>
     <td class="text-right noprint">
     <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>
             <i class="fa fa-history"></i>
         </a>
         </a>
         {% if perms.ipam.change_service %}
         {% if perms.ipam.change_service %}

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

@@ -49,7 +49,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
+                <a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 21
netbox/templates/ipam/ipaddress_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.ipam.add_ipaddress %}
-        {% add_button 'ipam:ipaddress_add' %}
-        {% import_button 'ipam:ipaddress_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}IP Addresses{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
-	</div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
-{% endblock %}

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

@@ -69,7 +69,7 @@
         {% endif %}
         {% endif %}
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
+                <a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 2 - 19
netbox/templates/ipam/prefix_list.html

@@ -1,26 +1,9 @@
-{% extends '_base.html' %}
-{% load buttons %}
+{% extends 'utilities/obj_list.html' %}
 {% load helpers %}
 {% load helpers %}
 
 
-{% block content %}
-<div class="pull-right noprint">
+{% block buttons %}
     <div class="btn-group" role="group">
     <div class="btn-group" role="group">
         <a href="{% url 'ipam:prefix_list' %}{% querystring request expand=None page=1 %}" class="btn btn-default{% if not request.GET.expand %} active{% endif %}">Collapse</a>
         <a href="{% url 'ipam:prefix_list' %}{% querystring request expand=None page=1 %}" class="btn btn-default{% if not request.GET.expand %} active{% endif %}">Collapse</a>
         <a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a>
         <a href="{% url 'ipam:prefix_list' %}{% querystring request expand='on' page=1 %}" class="btn btn-default{% if request.GET.expand %} active{% endif %}">Expand</a>
     </div>
     </div>
-    {% if perms.ipam.add_prefix %}
-        {% add_button 'ipam:prefix_add' %}
-        {% import_button 'ipam:prefix_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Prefixes{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
-	</div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
 {% endblock %}
 {% endblock %}

+ 9 - 22
netbox/templates/ipam/rir_list.html

@@ -1,9 +1,6 @@
-{% extends '_base.html' %}
-{% load buttons %}
-{% load humanize %}
+{% extends 'utilities/obj_list.html' %}
 
 
-{% block content %}
-<div class="pull-right noprint">
+{% block buttons %}
     {% if request.GET.family == '6' %}
     {% if request.GET.family == '6' %}
         <a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
         <a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
             <span class="fa fa-table" aria-hidden="true"></span>
             <span class="fa fa-table" aria-hidden="true"></span>
@@ -15,22 +12,12 @@
             IPv6 Stats
             IPv6 Stats
         </a>
         </a>
     {% endif %}
     {% endif %}
-    {% if perms.ipam.add_rir %}
-        {% add_button 'ipam:rir_add' %}
-        {% import_button 'ipam:rir_import' %}
+{% endblock %}
+
+{% block sidebar %}
+    {% if request.GET.family == '6' %}
+        <div class="alert alert-info">
+            <i class="fa fa-info-circle"></i> Numbers shown indicate /64 prefixes.
+        </div>
     {% endif %}
     {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}RIRs{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %}
-        {% if request.GET.family == '6' %}
-            <div class="alert alert-info pull-right"><strong>Note:</strong> Numbers shown indicate /64 prefixes.</div>
-        {% endif %}
-    </div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
 {% endblock %}
 {% endblock %}

+ 0 - 18
netbox/templates/ipam/role_list.html

@@ -1,18 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.ipam.add_role %}
-        {% add_button 'ipam:role_add' %}
-        {% import_button 'ipam:role_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Prefix/VLAN Roles{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-12">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:role_bulk_delete' %}
-    </div>
-</div>
-{% endblock %}

+ 0 - 17
netbox/templates/ipam/service_list.html

@@ -1,17 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}Services{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %}
-	</div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
-{% endblock %}

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

@@ -55,7 +55,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
+                <a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

+ 0 - 21
netbox/templates/ipam/vlan_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.ipam.add_vlan %}
-        {% add_button 'ipam:vlan_add' %}
-        {% import_button 'ipam:vlan_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}VLANs{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %}
-	</div>
-	<div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-	</div>
-</div>
-{% endblock %}

+ 0 - 21
netbox/templates/ipam/vlangroup_list.html

@@ -1,21 +0,0 @@
-{% extends '_base.html' %}
-{% load buttons %}
-
-{% block content %}
-<div class="pull-right noprint">
-    {% if perms.ipam.add_vlangroup %}
-        {% add_button 'ipam:vlangroup_add' %}
-        {% import_button 'ipam:vlangroup_import' %}
-    {% endif %}
-    {% export_button content_type %}
-</div>
-<h1>{% block title %}VLAN Groups{% endblock %}</h1>
-<div class="row">
-	<div class="col-md-9">
-        {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %}
-    </div>
-    <div class="col-md-3 noprint">
-		{% include 'inc/search_panel.html' %}
-    </div>
-</div>
-{% endblock %}

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

@@ -46,7 +46,7 @@
         </li>
         </li>
         {% if perms.extras.view_objectchange %}
         {% if perms.extras.view_objectchange %}
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>
+                <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Change Log</a>
             </li>
             </li>
         {% endif %}
         {% endif %}
     </ul>
     </ul>

Некоторые файлы не были показаны из-за большого количества измененных файлов