Преглед на файлове

Merge pull request #6143 from netbox-community/develop

Release v2.10.9
Jeremy Stretch преди 4 години
родител
ревизия
b493d739bd

+ 6 - 0
docs/additional-features/custom-fields.md

@@ -39,6 +39,12 @@ Each custom selection field must have at least two choices. These are specified
 
 
 If a default value is specified for a selection field, it must exactly match one of the provided choices.
 If a default value is specified for a selection field, it must exactly match one of the provided choices.
 
 
+## Custom Fields in Templates
+
+Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`).
+
+For example, a custom field named `foo123` on the Site model is accessible on an instance as `{{ site.cf.foo123 }}`.
+
 ## Custom Fields and the REST API
 ## Custom Fields and the REST API
 
 
 When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined:
 When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined:

+ 8 - 0
docs/additional-features/export-templates.md

@@ -18,6 +18,14 @@ Height: {{ rack.u_height }}U
 
 
 To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
 To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
 
 
+If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
+```
+{% for server in queryset %}
+{% set data = server.get_config_context() %}
+{{ data.syslog }}
+{% endfor %}
+```
+
 A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
 A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
 
 
 ## Example
 ## Example

+ 7 - 0
docs/additional-features/napalm.md

@@ -2,6 +2,13 @@
 
 
 NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
 NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
 
 
+The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met:
+
+* Device status is "Active"
+* A primary IP has been assigned to the device
+* A platform with a NAPALM driver has been assigned
+* The authenticated user has the `dcim.napalm_read_device` permission
+
 !!! note
 !!! note
     To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
     To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
 
 

+ 1 - 1
docs/administration/permissions.md

@@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
 | ----------- | ----------- |
 | ----------- | ----------- |
 | `{"status": "active"}` | Status is active |
 | `{"status": "active"}` | Status is active |
 | `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
 | `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
-| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing |
+| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
 | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
 | `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
 | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
 | `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
 | `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
 | `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |

+ 1 - 0
docs/configuration/required-settings.md

@@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
 * `PASSWORD` - Redis password (if set)
 * `PASSWORD` - Redis password (if set)
 * `DATABASE` - Numeric database ID
 * `DATABASE` - Numeric database ID
 * `SSL` - Use SSL connection to Redis
 * `SSL` - Use SSL connection to Redis
+* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended)
 
 
 An example configuration is provided below:
 An example configuration is provided below:
 
 

+ 23 - 0
docs/release-notes/version-2.10.md

@@ -1,5 +1,28 @@
 # NetBox v2.10
 # NetBox v2.10
 
 
+## v2.10.9 (2021-04-12)
+
+### Enhancements
+
+* [#5526](https://github.com/netbox-community/netbox/issues/5526) - Add MAC address search field to VM interfaces list
+* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view
+* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color
+* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant
+* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis
+
+### Bug Fixes
+
+* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations
+* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer
+* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission
+* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint
+* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values
+* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses
+* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent
+* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects
+
+---
+
 ## v2.10.8 (2021-03-26)
 ## v2.10.8 (2021-03-26)
 
 
 ### Bug Fixes
 ### Bug Fixes

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

@@ -779,7 +779,7 @@ class CablePathSerializer(serializers.ModelSerializer):
 
 
 class InterfaceConnectionSerializer(ValidatedModelSerializer):
 class InterfaceConnectionSerializer(ValidatedModelSerializer):
     interface_a = serializers.SerializerMethodField()
     interface_a = serializers.SerializerMethodField()
-    interface_b = NestedInterfaceSerializer(source='connected_endpoint')
+    interface_b = NestedInterfaceSerializer(source='_path.destination')
     connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
     connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
 
 
     class Meta:
     class Meta:

+ 3 - 0
netbox/dcim/api/views.py

@@ -2,6 +2,7 @@ import socket
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import F
 from django.db.models import F
 from django.http import HttpResponseForbidden, HttpResponse
 from django.http import HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
@@ -580,6 +581,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
     queryset = Interface.objects.prefetch_related('device', '_path').filter(
     queryset = Interface.objects.prefetch_related('device', '_path').filter(
         # Avoid duplicate connections by only selecting the lower PK in a connected pair
         # Avoid duplicate connections by only selecting the lower PK in a connected pair
+        _path__destination_type__app_label='dcim',
+        _path__destination_type__model='interface',
         _path__destination_id__isnull=False,
         _path__destination_id__isnull=False,
         pk__lt=F('_path__destination_id')
         pk__lt=F('_path__destination_id')
     )
     )

+ 2 - 2
netbox/dcim/forms.py

@@ -868,7 +868,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
         nullable_fields = []
         nullable_fields = []
 
 
 
 
-class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
+class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = RackReservation
     model = RackReservation
     field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
     field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
     q = forms.CharField(
     q = forms.CharField(
@@ -3966,7 +3966,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo
             })
             })
 
 
 
 
-class CableFilterForm(BootstrapMixin, forms.Form):
+class CableFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Cable
     model = Cable
     q = forms.CharField(
     q = forms.CharField(
         required=False,
         required=False,

+ 4 - 4
netbox/dcim/models/device_components.py

@@ -478,6 +478,10 @@ class BaseInterface(models.Model):
 
 
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
+    @property
+    def count_ipaddresses(self):
+        return self.ip_addresses.count()
+
 
 
 @extras_features('export_templates', 'webhooks', 'custom_links')
 @extras_features('export_templates', 'webhooks', 'custom_links')
 class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
 class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
@@ -615,10 +619,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
     def is_lag(self):
     def is_lag(self):
         return self.type == InterfaceTypeChoices.TYPE_LAG
         return self.type == InterfaceTypeChoices.TYPE_LAG
 
 
-    @property
-    def count_ipaddresses(self):
-        return self.ip_addresses.count()
-
 
 
 #
 #
 # Pass-through ports
 # Pass-through ports

+ 6 - 0
netbox/dcim/models/racks.py

@@ -111,6 +111,12 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        # An MPTT model cannot be its own parent
+        if self.pk and self.parent_id == self.pk:
+            raise ValidationError({
+                "parent": "Cannot assign self as parent."
+            })
+
         # Parent RackGroup (if any) must belong to the same Site
         # Parent RackGroup (if any) must belong to the same Site
         if self.parent and self.parent.site != self.site:
         if self.parent and self.parent.site != self.site:
             raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
             raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")

+ 10 - 0
netbox/dcim/models/sites.py

@@ -7,6 +7,7 @@ from timezone_field import TimeZoneField
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from django.core.exceptions import ValidationError
 from dcim.fields import ASNField
 from dcim.fields import ASNField
 from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
 from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
@@ -87,6 +88,15 @@ class Region(MPTTModel, ChangeLoggedModel):
             object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
             object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
         )
         )
 
 
+    def clean(self):
+        super().clean()
+
+        # An MPTT model cannot be its own parent
+        if self.pk and self.parent_id == self.pk:
+            raise ValidationError({
+                "parent": "Cannot assign self as parent."
+            })
+
 
 
 #
 #
 # Sites
 # Sites

+ 35 - 18
netbox/dcim/tables/devices.py

@@ -230,6 +230,11 @@ class CableTerminationTable(BaseTable):
     cable = tables.Column(
     cable = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    cable_color = ColorColumn(
+        accessor='cable.color',
+        orderable=False,
+        verbose_name='Cable Color'
+    )
     cable_peer = tables.TemplateColumn(
     cable_peer = tables.TemplateColumn(
         accessor='_cable_peer',
         accessor='_cable_peer',
         template_code=CABLETERMINATION,
         template_code=CABLETERMINATION,
@@ -255,7 +260,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = (
         fields = (
-            'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags',
+            'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection',
+            'tags',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
@@ -274,7 +280,8 @@ class DeviceConsolePortTable(ConsolePortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         model = ConsolePort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
+            'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
+            'actions'
         )
         )
         default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
         default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
         row_attrs = {
         row_attrs = {
@@ -289,7 +296,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags')
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection',
+            'tags',
+        )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 
 
@@ -308,7 +318,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         model = ConsoleServerPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
+            'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
+            'actions'
         )
         )
         default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
         default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
         row_attrs = {
         row_attrs = {
@@ -325,7 +336,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
         model = PowerPort
         model = PowerPort
         fields = (
         fields = (
             'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable',
             'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable',
-            'cable_peer', 'connection', 'tags',
+            'cable_color', 'cable_peer', 'connection', 'tags',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
 
 
@@ -345,8 +356,8 @@ class DevicePowerPortTable(PowerPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         model = PowerPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer',
-            'connection', 'tags', 'actions',
+            'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_color',
+            'cable_peer', 'connection', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@@ -368,8 +379,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
         fields = (
         fields = (
-            'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer',
-            'connection', 'tags',
+            'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_color',
+            'cable_peer', 'connection', 'tags',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
 
 
@@ -388,8 +399,8 @@ class DevicePowerOutletTable(PowerOutletTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         model = PowerOutlet
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection',
-            'tags', 'actions',
+            'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_color',
+            'cable_peer', 'connection', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
             'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@@ -424,7 +435,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
         model = Interface
         model = Interface
         fields = (
         fields = (
             'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
             'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
-            'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
+            'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
+            'tagged_vlans',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
 
 
@@ -450,7 +462,8 @@ class DeviceInterfaceTable(InterfaceTable):
         model = Interface
         model = Interface
         fields = (
         fields = (
             'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
             'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
-            'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
+            'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
+            'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
             'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
@@ -477,7 +490,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
             'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
             'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
-            'cable_peer', 'tags',
+            'cable_color', 'cable_peer', 'tags',
         )
         )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
 
 
@@ -497,8 +510,8 @@ class DeviceFrontPortTable(FrontPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         model = FrontPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
-            'tags', 'actions',
+            'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_color',
+            'cable_peer', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
             'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
@@ -516,7 +529,10 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         model = RearPort
-        fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags')
+        fields = (
+            'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_color', 'cable_peer',
+            'tags',
+        )
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
         default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
 
 
 
 
@@ -535,7 +551,8 @@ class DeviceRearPortTable(RearPortTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         model = RearPort
         fields = (
         fields = (
-            'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions',
+            'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_color', 'cable_peer', 'tags',
+            'actions',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
             'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',

+ 1 - 2
netbox/dcim/tables/power.py

@@ -4,7 +4,6 @@ from django_tables2.utils import Accessor
 from dcim.models import PowerFeed, PowerPanel
 from dcim.models import PowerFeed, PowerPanel
 from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
 from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
 from .devices import CableTerminationTable
 from .devices import CableTerminationTable
-from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
 
 
 __all__ = (
 __all__ = (
     'PowerFeedTable',
     'PowerFeedTable',
@@ -69,7 +68,7 @@ class PowerFeedTable(CableTerminationTable):
         model = PowerFeed
         model = PowerFeed
         fields = (
         fields = (
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
-            'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags',
+            'max_utilization', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', 'tags',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

+ 3 - 2
netbox/dcim/views.py

@@ -342,10 +342,11 @@ class RackView(generic.ObjectView):
     queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
     queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        # Get 0U and child devices located within the rack
+        # Get 0U devices located within the rack
         nonracked_devices = Device.objects.filter(
         nonracked_devices = Device.objects.filter(
             rack=instance,
             rack=instance,
-            position__isnull=True
+            position__isnull=True,
+            parent_bay__isnull=True
         ).prefetch_related('device_type__manufacturer')
         ).prefetch_related('device_type__manufacturer')
 
 
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
         peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)

+ 1 - 1
netbox/extras/models/models.py

@@ -140,7 +140,7 @@ class Webhook(models.Model):
         ret = {}
         ret = {}
         data = render_jinja2(self.additional_headers, context)
         data = render_jinja2(self.additional_headers, context)
         for line in data.splitlines():
         for line in data.splitlines():
-            header, value = line.split(':')
+            header, value = line.split(':', 1)
             ret[header.strip()] = value.strip()
             ret[header.strip()] = value.strip()
         return ret
         return ret
 
 

+ 8 - 20
netbox/ipam/tables.py

@@ -109,18 +109,6 @@ VLAN_MEMBER_TAGGED = """
 {% endif %}
 {% endif %}
 """
 """
 
 
-TENANT_LINK = """
-{% if record.tenant %}
-    <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
-{% elif record.vrf.tenant %}
-    <a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
-{% elif object.tenant %}
-    <a href="{% url 'tenancy:tenant' slug=object.tenant.slug %}" title="{{ object.tenant.description }}">{{ object.tenant }}</a>
-{% else %}
-    &mdash;
-{% endif %}
-"""
-
 
 
 #
 #
 # VRFs
 # VRFs
@@ -210,8 +198,8 @@ class AggregateTable(BaseTable):
     prefix = tables.LinkColumn(
     prefix = tables.LinkColumn(
         verbose_name='Aggregate'
         verbose_name='Aggregate'
     )
     )
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     )
     date_added = tables.DateColumn(
     date_added = tables.DateColumn(
         format="Y-m-d",
         format="Y-m-d",
@@ -281,8 +269,8 @@ class PrefixTable(BaseTable):
         template_code=VRF_LINK,
         template_code=VRF_LINK,
         verbose_name='VRF'
         verbose_name='VRF'
     )
     )
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     )
     site = tables.Column(
     site = tables.Column(
         linkify=True
         linkify=True
@@ -349,8 +337,8 @@ class IPAddressTable(BaseTable):
         default=AVAILABLE_LABEL
         default=AVAILABLE_LABEL
     )
     )
     role = ChoiceFieldColumn()
     role = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     )
     assigned_object = tables.Column(
     assigned_object = tables.Column(
         linkify=True,
         linkify=True,
@@ -430,8 +418,8 @@ class InterfaceIPAddressTable(BaseTable):
         verbose_name='VRF'
         verbose_name='VRF'
     )
     )
     status = ChoiceFieldColumn()
     status = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     )
     actions = ButtonsColumn(
     actions = ButtonsColumn(
         model=IPAddress
         model=IPAddress

+ 6 - 0
netbox/netbox/configuration.example.py

@@ -34,6 +34,9 @@ REDIS = {
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 0,
         'DATABASE': 0,
         'SSL': False,
         'SSL': False,
+        # Set this to True to skip TLS certificate verification
+        # This can expose the connection to attacks, be careful
+        # 'INSECURE_SKIP_TLS_VERIFY': False,
     },
     },
     'caching': {
     'caching': {
         'HOST': 'localhost',
         'HOST': 'localhost',
@@ -44,6 +47,9 @@ REDIS = {
         'PASSWORD': '',
         'PASSWORD': '',
         'DATABASE': 1,
         'DATABASE': 1,
         'SSL': False,
         'SSL': False,
+        # Set this to True to skip TLS certificate verification
+        # This can expose the connection to attacks, be careful
+        # 'INSECURE_SKIP_TLS_VERIFY': False,
     }
     }
 }
 }
 
 

+ 12 - 16
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.10.8'
+VERSION = '2.10.9'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -215,6 +215,7 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
 TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
 TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
 TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
 TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
 TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
 TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
+TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
 
 
 # Caching
 # Caching
 if 'caching' not in REDIS:
 if 'caching' not in REDIS:
@@ -233,6 +234,7 @@ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default'
 CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
 CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
 CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
 CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
 CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
 CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
+CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
 
 
 
 
 #
 #
@@ -398,21 +400,14 @@ if CACHING_REDIS_USING_SENTINEL:
         'password': CACHING_REDIS_PASSWORD,
         'password': CACHING_REDIS_PASSWORD,
     }
     }
 else:
 else:
-    if CACHING_REDIS_SSL:
-        REDIS_CACHE_CON_STRING = 'rediss://'
-    else:
-        REDIS_CACHE_CON_STRING = 'redis://'
-
-    if CACHING_REDIS_PASSWORD:
-        REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
-
-    REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
-        REDIS_CACHE_CON_STRING,
-        CACHING_REDIS_HOST,
-        CACHING_REDIS_PORT,
-        CACHING_REDIS_DATABASE
-    )
-    CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
+    CACHEOPS_REDIS = {
+        'host': CACHING_REDIS_HOST,
+        'port': CACHING_REDIS_PORT,
+        'db': CACHING_REDIS_DATABASE,
+        'password': CACHING_REDIS_PASSWORD,
+        'ssl': CACHING_REDIS_SSL,
+        'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required',
+    }
 
 
 if not CACHE_TIMEOUT:
 if not CACHE_TIMEOUT:
     CACHEOPS_ENABLED = False
     CACHEOPS_ENABLED = False
@@ -560,6 +555,7 @@ else:
         'DB': TASKS_REDIS_DATABASE,
         'DB': TASKS_REDIS_DATABASE,
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'SSL': TASKS_REDIS_SSL,
         'SSL': TASKS_REDIS_SSL,
+        'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
         'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
         'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
     }
     }
 
 

+ 11 - 10
netbox/templates/dcim/device/base.html

@@ -153,16 +153,17 @@
                 </li>
                 </li>
             {% endif %}
             {% endif %}
         {% endwith %}
         {% endwith %}
-        {% if perms.dcim.napalm_read_device %}
-            {% if object.status != 'active' %}
-                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
-            {% elif not object.platform %}
-                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
-            {% elif not object.platform.napalm_driver %}
-                {% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
-            {% else %}
-                {% include 'dcim/inc/device_napalm_tabs.html' %}
-            {% endif %}
+        {% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %}
+            {# NAPALM-enabled tabs #}
+            <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
+            </li>
+            <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
+            </li>
+            <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
+            </li>
         {% endif %}
         {% endif %}
         {% if perms.extras.view_configcontext %}
         {% if perms.extras.view_configcontext %}
             <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
             <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>

+ 0 - 15
netbox/templates/dcim/inc/device_napalm_tabs.html

@@ -1,15 +0,0 @@
-{% if not disabled_message %}
-    <li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
-    </li>
-    <li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
-    </li>
-    <li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
-        <a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
-    </li>
-{% else %}
-    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
-    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
-    <li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
-{% endif %}

+ 46 - 43
netbox/templates/generic/object_list.html

@@ -1,6 +1,7 @@
 {% extends 'base.html' %}
 {% extends 'base.html' %}
 {% load buttons %}
 {% load buttons %}
 {% load helpers %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 {% load static %}
 {% load static %}
 
 
 {% block content %}
 {% block content %}
@@ -28,54 +29,56 @@
                 {% block sidebar %}{% endblock %}
                 {% block sidebar %}{% endblock %}
             </div>
             </div>
         {% endif %}
         {% endif %}
+        <div class="table-responsive">
         {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
         {% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
-        {% if permissions.change or permissions.delete %}
-            <form method="post" class="form form-horizontal">
-                {% csrf_token %}
-                <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
-                {% if table.paginator.num_pages > 1 %}
-                    <div id="select_all_box" class="hidden panel panel-default noprint">
-                        <div class="panel-body">
-                            <div class="checkbox-inline">
-                                <label for="select_all">
-                                    <input type="checkbox" id="select_all" name="_all" />
-                                    Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
-                                </label>
-                            </div>
-                            <div class="pull-right">
-                                {% if bulk_edit_url and permissions.change %}
-                                    <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
-                                        <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
-                                    </button>
-                                {% endif %}
-                                {% if bulk_delete_url and permissions.delete %}
-                                    <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
-                                        <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
-                                    </button>
-                                {% endif %}
+            {% if permissions.change or permissions.delete %}
+                <form method="post" class="form form-horizontal">
+                    {% csrf_token %}
+                    <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
+                    {% if table.paginator.num_pages > 1 %}
+                        <div id="select_all_box" class="hidden panel panel-default noprint">
+                            <div class="panel-body">
+                                <div class="checkbox-inline">
+                                    <label for="select_all">
+                                        <input type="checkbox" id="select_all" name="_all" />
+                                        Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
+                                    </label>
+                                </div>
+                                <div class="pull-right">
+                                    {% if bulk_edit_url and permissions.change %}
+                                        <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
+                                            <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
+                                        </button>
+                                    {% endif %}
+                                    {% if bulk_delete_url and permissions.delete %}
+                                        <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
+                                            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
+                                        </button>
+                                    {% endif %}
+                                </div>
                             </div>
                             </div>
                         </div>
                         </div>
-                    </div>
-                {% endif %}
-                {% include table_template|default:'responsive_table.html' %}
-                <div class="pull-left noprint">
-                    {% block bulk_buttons %}{% endblock %}
-                    {% if bulk_edit_url and permissions.change %}
-                        <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
-                            <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
-                        </button>
-                    {% endif %}
-                    {% if bulk_delete_url and permissions.delete %}
-                        <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
-                            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
-                        </button>
                     {% endif %}
                     {% endif %}
-                </div>
-            </form>
-        {% else %}
-            {% include table_template|default:'responsive_table.html' %}
-        {% endif %}
+                    {% render_table table 'inc/table.html' %}
+                    <div class="pull-left noprint">
+                        {% block bulk_buttons %}{% endblock %}
+                        {% if bulk_edit_url and permissions.change %}
+                            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
+                                <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
+                            </button>
+                        {% endif %}
+                        {% if bulk_delete_url and permissions.delete %}
+                            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
+                                <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
+                            </button>
+                        {% endif %}
+                    </div>
+                </form>
+            {% else %}
+                {% render_table table 'inc/table.html' %}
+            {% endif %}
         {% endwith %}
         {% endwith %}
+        </div>
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         <div class="clearfix"></div>
         <div class="clearfix"></div>
     </div>
     </div>

+ 6 - 16
netbox/templates/users/api_tokens.html

@@ -11,12 +11,8 @@
                     <div class="panel-heading">
                     <div class="panel-heading">
                         <div class="pull-right noprint">
                         <div class="pull-right noprint">
                             <a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
                             <a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
-                            {% if perms.users.change_token %}
-                                <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
-                            {% endif %}
-                            {% if perms.users.delete_token %}
-                                <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
-                            {% endif %}
+                            <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
+                            <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
                         </div>
                         </div>
                         <i class="mdi mdi-key"></i>
                         <i class="mdi mdi-key"></i>
                         <samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp>
                         <samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp>
@@ -55,16 +51,10 @@
             {% empty %}
             {% empty %}
                 <p>You do not have any API tokens.</p>
                 <p>You do not have any API tokens.</p>
             {% endfor %}
             {% endfor %}
-            {% if perms.users.add_token %}
-                <a href="{% url 'user:token_add' %}" class="btn btn-primary">
-                    <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-                    Add a token
-                </a>
-            {% else %}
-                <div class="alert alert-info text-center" role="alert">
-                    You do not have permission to create new API tokens. If needed, ask an administrator to enable token creation for your account or an assigned group.
-                </div>
-            {% endif %}
+            <a href="{% url 'user:token_add' %}" class="btn btn-primary">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
+                Add a token
+            </a>
         </div>
         </div>
     </div>
     </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/tenancy/models.py

@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
@@ -74,6 +75,15 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
             object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
             object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
         )
         )
 
 
+    def clean(self):
+        super().clean()
+
+        # An MPTT model cannot be its own parent
+        if self.pk and self.parent_id == self.pk:
+            raise ValidationError({
+                "parent": "Cannot assign self as parent."
+            })
+
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Tenant(ChangeLoggedModel, CustomFieldModel):
 class Tenant(ChangeLoggedModel, CustomFieldModel):

+ 5 - 9
netbox/users/views.py

@@ -6,7 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.models import update_last_login
 from django.contrib.auth.signals import user_logged_in
 from django.contrib.auth.signals import user_logged_in
-from django.http import HttpResponseForbidden, HttpResponseRedirect
+from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.decorators import method_decorator
 from django.utils.decorators import method_decorator
@@ -282,13 +282,9 @@ class TokenEditView(LoginRequiredMixin, View):
 
 
     def get(self, request, pk=None):
     def get(self, request, pk=None):
 
 
-        if pk is not None:
-            if not request.user.has_perm('users.change_token'):
-                return HttpResponseForbidden()
+        if pk:
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
         else:
         else:
-            if not request.user.has_perm('users.add_token'):
-                return HttpResponseForbidden()
             token = Token(user=request.user)
             token = Token(user=request.user)
 
 
         form = TokenForm(instance=token)
         form = TokenForm(instance=token)
@@ -302,11 +298,11 @@ class TokenEditView(LoginRequiredMixin, View):
 
 
     def post(self, request, pk=None):
     def post(self, request, pk=None):
 
 
-        if pk is not None:
+        if pk:
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
             form = TokenForm(request.POST, instance=token)
             form = TokenForm(request.POST, instance=token)
         else:
         else:
-            token = Token()
+            token = Token(user=request.user)
             form = TokenForm(request.POST)
             form = TokenForm(request.POST)
 
 
         if form.is_valid():
         if form.is_valid():
@@ -314,7 +310,7 @@ class TokenEditView(LoginRequiredMixin, View):
             token.user = request.user
             token.user = request.user
             token.save()
             token.save()
 
 
-            msg = "Modified token {}".format(token) if pk else "Created token {}".format(token)
+            msg = f"Modified token {token}" if pk else f"Created token {token}"
             messages.success(request, msg)
             messages.success(request, msg)
 
 
             if '_addanother' in request.POST:
             if '_addanother' in request.POST:

+ 3 - 3
netbox/utilities/utils.py

@@ -224,12 +224,12 @@ def prepare_cloned_fields(instance):
         field = instance._meta.get_field(field_name)
         field = instance._meta.get_field(field_name)
         field_value = field.value_from_object(instance)
         field_value = field.value_from_object(instance)
 
 
-        # Swap out False with URL-friendly value
+        # Pass False as null for boolean fields
         if field_value is False:
         if field_value is False:
-            field_value = ''
+            params.append((field_name, ''))
 
 
         # Omit empty values
         # Omit empty values
-        if field_value not in (None, ''):
+        elif field_value not in (None, ''):
             params.append((field_name, field_value))
             params.append((field_name, field_value))
 
 
     # Copy tags
     # Copy tags

+ 2 - 1
netbox/virtualization/api/serializers.py

@@ -109,12 +109,13 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
         required=False,
         required=False,
         many=True
         many=True
     )
     )
+    count_ipaddresses = serializers.IntegerField(read_only=True)
 
 
     class Meta:
     class Meta:
         model = VMInterface
         model = VMInterface
         fields = [
         fields = [
             'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
             'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
-            'untagged_vlan', 'tagged_vlans', 'tags',
+            'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
         ]
         ]
 
 
     def validate(self, data):
     def validate(self, data):

+ 1 - 1
netbox/virtualization/api/views.py

@@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
 
 
 class VMInterfaceViewSet(ModelViewSet):
 class VMInterfaceViewSet(ModelViewSet):
     queryset = VMInterface.objects.prefetch_related(
     queryset = VMInterface.objects.prefetch_related(
-        'virtual_machine', 'tags', 'tagged_vlans'
+        'virtual_machine', 'tags', 'tagged_vlans', 'ip_addresses',
     )
     )
     serializer_class = serializers.VMInterfaceSerializer
     serializer_class = serializers.VMInterfaceSerializer
     filterset_class = filters.VMInterfaceFilterSet
     filterset_class = filters.VMInterfaceFilterSet

+ 4 - 0
netbox/virtualization/forms.py

@@ -786,6 +786,10 @@ class VMInterfaceFilterForm(forms.Form):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    mac_address = forms.CharField(
+        required=False,
+        label='MAC address'
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 4 - 4
requirements.txt

@@ -1,4 +1,4 @@
-Django==3.1.7
+Django==3.1.8
 django-cacheops==5.1
 django-cacheops==5.1
 django-cors-headers==3.7.0
 django-cors-headers==3.7.0
 django-debug-toolbar==3.2
 django-debug-toolbar==3.2
@@ -6,17 +6,17 @@ django-filter==2.4.0
 django-mptt==0.12.0
 django-mptt==0.12.0
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-prometheus==2.1.0
-django-rq==2.4.0
+django-rq==2.4.1
 django-tables2==2.3.4
 django-tables2==2.3.4
 django-taggit==1.3.0
 django-taggit==1.3.0
 django-timezone-field==4.1.2
 django-timezone-field==4.1.2
 djangorestframework==3.12.4
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
 drf-yasg[validation]==1.20.0
-gunicorn==20.0.4
+gunicorn==20.1.0
 Jinja2==2.11.3
 Jinja2==2.11.3
 Markdown==3.3.4
 Markdown==3.3.4
 netaddr==0.8.0
 netaddr==0.8.0
-Pillow==8.1.2
+Pillow==8.2.0
 psycopg2-binary==2.8.6
 psycopg2-binary==2.8.6
 pycryptodome==3.10.1
 pycryptodome==3.10.1
 PyYAML==5.4.1
 PyYAML==5.4.1