فهرست منبع

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.
 
+## 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
 
 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`.
 
+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`.
 
 ## 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.
 
+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
     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__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__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 |

+ 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)
 * `DATABASE` - Numeric database ID
 * `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:
 

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

@@ -1,5 +1,28 @@
 # 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)
 
 ### Bug Fixes

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

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

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

@@ -2,6 +2,7 @@ import socket
 from collections import OrderedDict
 
 from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import F
 from django.http import HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404
@@ -580,6 +581,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
     queryset = Interface.objects.prefetch_related('device', '_path').filter(
         # 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,
         pk__lt=F('_path__destination_id')
     )

+ 2 - 2
netbox/dcim/forms.py

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

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

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

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

@@ -111,6 +111,12 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
     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."
+            })
+
         # Parent RackGroup (if any) must belong to the same 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})")

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

@@ -7,6 +7,7 @@ from timezone_field import TimeZoneField
 
 from dcim.choices import *
 from dcim.constants import *
+from django.core.exceptions import ValidationError
 from dcim.fields import ASNField
 from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
 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'])
         )
 
+    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

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

@@ -230,6 +230,11 @@ class CableTerminationTable(BaseTable):
     cable = tables.Column(
         linkify=True
     )
+    cable_color = ColorColumn(
+        accessor='cable.color',
+        orderable=False,
+        verbose_name='Cable Color'
+    )
     cable_peer = tables.TemplateColumn(
         accessor='_cable_peer',
         template_code=CABLETERMINATION,
@@ -255,7 +260,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         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')
 
@@ -274,7 +280,8 @@ class DeviceConsolePortTable(ConsolePortTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsolePort
         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')
         row_attrs = {
@@ -289,7 +296,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
 
     class Meta(DeviceComponentTable.Meta):
         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')
 
 
@@ -308,7 +318,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = ConsoleServerPort
         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')
         row_attrs = {
@@ -325,7 +336,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
         model = PowerPort
         fields = (
             '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')
 
@@ -345,8 +356,8 @@ class DevicePowerPortTable(PowerPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerPort
         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 = (
             'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@@ -368,8 +379,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         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')
 
@@ -388,8 +399,8 @@ class DevicePowerOutletTable(PowerOutletTable):
     class Meta(DeviceComponentTable.Meta):
         model = PowerOutlet
         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 = (
             'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@@ -424,7 +435,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
         model = Interface
         fields = (
             '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')
 
@@ -450,7 +462,8 @@ class DeviceInterfaceTable(InterfaceTable):
         model = Interface
         fields = (
             '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 = (
             'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
@@ -477,7 +490,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
         model = FrontPort
         fields = (
             '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')
 
@@ -497,8 +510,8 @@ class DeviceFrontPortTable(FrontPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = FrontPort
         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 = (
             '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):
         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')
 
 
@@ -535,7 +551,8 @@ class DeviceRearPortTable(RearPortTable):
     class Meta(DeviceComponentTable.Meta):
         model = RearPort
         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 = (
             '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 utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
 from .devices import CableTerminationTable
-from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
 
 __all__ = (
     'PowerFeedTable',
@@ -69,7 +68,7 @@ class PowerFeedTable(CableTerminationTable):
         model = PowerFeed
         fields = (
             '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 = (
             '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')
 
     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(
             rack=instance,
-            position__isnull=True
+            position__isnull=True,
+            parent_bay__isnull=True
         ).prefetch_related('device_type__manufacturer')
 
         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 = {}
         data = render_jinja2(self.additional_headers, context)
         for line in data.splitlines():
-            header, value = line.split(':')
+            header, value = line.split(':', 1)
             ret[header.strip()] = value.strip()
         return ret
 

+ 8 - 20
netbox/ipam/tables.py

@@ -109,18 +109,6 @@ VLAN_MEMBER_TAGGED = """
 {% 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
@@ -210,8 +198,8 @@ class AggregateTable(BaseTable):
     prefix = tables.LinkColumn(
         verbose_name='Aggregate'
     )
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     date_added = tables.DateColumn(
         format="Y-m-d",
@@ -281,8 +269,8 @@ class PrefixTable(BaseTable):
         template_code=VRF_LINK,
         verbose_name='VRF'
     )
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     site = tables.Column(
         linkify=True
@@ -349,8 +337,8 @@ class IPAddressTable(BaseTable):
         default=AVAILABLE_LABEL
     )
     role = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     assigned_object = tables.Column(
         linkify=True,
@@ -430,8 +418,8 @@ class InterfaceIPAddressTable(BaseTable):
         verbose_name='VRF'
     )
     status = ChoiceFieldColumn()
-    tenant = tables.TemplateColumn(
-        template_code=TENANT_LINK
+    tenant = tables.Column(
+        linkify=True
     )
     actions = ButtonsColumn(
         model=IPAddress

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

@@ -34,6 +34,9 @@ REDIS = {
         'PASSWORD': '',
         'DATABASE': 0,
         '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': {
         'HOST': 'localhost',
@@ -44,6 +47,9 @@ REDIS = {
         'PASSWORD': '',
         'DATABASE': 1,
         '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
 #
 
-VERSION = '2.10.8'
+VERSION = '2.10.9'
 
 # Hostname
 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_DATABASE = TASKS_REDIS.get('DATABASE', 0)
 TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
+TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
 
 # Caching
 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_DATABASE = CACHING_REDIS.get('DATABASE', 0)
 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,
     }
 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:
     CACHEOPS_ENABLED = False
@@ -560,6 +555,7 @@ else:
         'DB': TASKS_REDIS_DATABASE,
         'PASSWORD': TASKS_REDIS_PASSWORD,
         'SSL': TASKS_REDIS_SSL,
+        'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
         'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
     }
 

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

@@ -153,16 +153,17 @@
                 </li>
             {% endif %}
         {% 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 %}
         {% if perms.extras.view_configcontext %}
             <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' %}
 {% load buttons %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 {% load static %}
 
 {% block content %}
@@ -28,54 +29,56 @@
                 {% block sidebar %}{% endblock %}
             </div>
         {% 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" %}
-        {% 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>
-                {% 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 %}
-                </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 %}
+        </div>
         {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
         <div class="clearfix"></div>
     </div>

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

@@ -11,12 +11,8 @@
                     <div class="panel-heading">
                         <div class="pull-right noprint">
                             <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>
                         <i class="mdi mdi-key"></i>
                         <samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp>
@@ -55,16 +51,10 @@
             {% empty %}
                 <p>You do not have any API tokens.</p>
             {% 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>
 {% endblock %}

+ 10 - 0
netbox/tenancy/models.py

@@ -1,3 +1,4 @@
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.urls import reverse
 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'])
         )
 
+    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')
 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.models import update_last_login
 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.urls import reverse
 from django.utils.decorators import method_decorator
@@ -282,13 +282,9 @@ class TokenEditView(LoginRequiredMixin, View):
 
     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)
         else:
-            if not request.user.has_perm('users.add_token'):
-                return HttpResponseForbidden()
             token = Token(user=request.user)
 
         form = TokenForm(instance=token)
@@ -302,11 +298,11 @@ class TokenEditView(LoginRequiredMixin, View):
 
     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)
             form = TokenForm(request.POST, instance=token)
         else:
-            token = Token()
+            token = Token(user=request.user)
             form = TokenForm(request.POST)
 
         if form.is_valid():
@@ -314,7 +310,7 @@ class TokenEditView(LoginRequiredMixin, View):
             token.user = request.user
             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)
 
             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_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:
-            field_value = ''
+            params.append((field_name, ''))
 
         # Omit empty values
-        if field_value not in (None, ''):
+        elif field_value not in (None, ''):
             params.append((field_name, field_value))
 
     # Copy tags

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

@@ -109,12 +109,13 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
         required=False,
         many=True
     )
+    count_ipaddresses = serializers.IntegerField(read_only=True)
 
     class Meta:
         model = VMInterface
         fields = [
             '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):

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

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

+ 4 - 0
netbox/virtualization/forms.py

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

+ 4 - 4
requirements.txt

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