diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md index 91bf0337994..2c3c723a234 100644 --- a/docs/additional-features/custom-fields.md +++ b/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: diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index 1e0611f0692..c9a7eea812f 100644 --- a/docs/additional-features/export-templates.md +++ b/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 diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index d2d69fc3cf2..957a5a214a9 100644 --- a/docs/additional-features/napalm.md +++ b/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. diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index c66c65543b3..c7c8996dca2 100644 --- a/docs/administration/permissions.md +++ b/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 | diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index dba8cdc8c68..3158fc73a72 100644 --- a/docs/configuration/required-settings.md +++ b/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: diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 7031f7fb8c4..d2ce5748403 100644 --- a/docs/release-notes/version-2.10.md +++ b/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 diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index dc730488cf9..73059cad3b2 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -779,7 +779,7 @@ def get_path(self, obj): 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: diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ae39f6ad02d..3533f02307e 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,6 +2,7 @@ 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') ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9b9760ad1db..77061d55627 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -868,7 +868,7 @@ class Meta: 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 @@ def clean(self): }) -class CableFilterForm(BootstrapMixin, forms.Form): +class CableFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Cable q = forms.CharField( required=False, diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 40063234fae..1b997ec07f2 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -478,6 +478,10 @@ def save(self, *args, **kwargs): 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 @@ def is_wireless(self): def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG - @property - def count_ipaddresses(self): - return self.ip_addresses.count() - # # Pass-through ports diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index dfaf7da61dc..0bd7e5afdc2 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -111,6 +111,12 @@ def to_objectchange(self, action): 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})") diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 923b33124f8..9087509050a 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,6 +7,7 @@ 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 @@ def to_objectchange(self, action): 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 diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 52f4449af76..311e7aea56b 100644 --- a/netbox/dcim/tables/devices.py +++ b/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 Meta(DeviceComponentTable.Meta): 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 Meta(DeviceComponentTable.Meta): 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 Meta(DeviceComponentTable.Meta): 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 Meta(DeviceComponentTable.Meta): 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', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ae5c2a5c816..55f2f868fdd 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -4,7 +4,6 @@ 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 Meta(BaseTable.Meta): 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', diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ffdbf933f6d..735db9abb6f 100644 --- a/netbox/dcim/views.py +++ b/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) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 4917a7e44ef..759d3789615 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -140,7 +140,7 @@ def render_headers(self, context): 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 diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 04a5d130c09..b16e180bd26 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -109,18 +109,6 @@ {% endif %} """ -TENANT_LINK = """ -{% if record.tenant %} - {{ record.tenant }} -{% elif record.vrf.tenant %} - {{ record.vrf.tenant }}* -{% elif object.tenant %} - {{ object.tenant }} -{% else %} - — -{% 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 diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 0dadb55bc1d..c40e280dd7b 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -34,6 +34,9 @@ '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 @@ '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, } } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a97a8ec748f..d647c0a0e5d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ # Environment setup # -VERSION = '2.10.8' +VERSION = '2.10.9' # Hostname HOSTNAME = platform.node() @@ -215,6 +215,7 @@ def _setting(name, default=None): 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 @@ def _setting(name, default=None): 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 @@ def _setting(name, default=None): '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 @@ def _setting(name, default=None): '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, } diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 8f488b28496..f2f8202a177 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -153,16 +153,17 @@

{{ object }}

{% 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 #} + + + {% endif %} {% if perms.extras.view_configcontext %} - - -{% else %} - - - -{% endif %} diff --git a/netbox/templates/generic/object_list.html b/netbox/templates/generic/object_list.html index 738cbca49bd..b3b9944bbf3 100644 --- a/netbox/templates/generic/object_list.html +++ b/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 title %}{{ content_type.model_class|meta:"verbose_name_plural"|bett {% block sidebar %}{% endblock %} {% endif %} +
{% 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 %} -
- {% csrf_token %} - - {% if table.paginator.num_pages > 1 %} -
diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 04e7cb23d2c..f1477329333 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -11,12 +11,8 @@
Copy - {% if perms.users.change_token %} - Edit - {% endif %} - {% if perms.users.delete_token %} - Delete - {% endif %} + Edit + Delete
{{ token.key }} @@ -55,16 +51,10 @@ {% empty %}

You do not have any API tokens.

{% endfor %} - {% if perms.users.add_token %} - - - Add a token - - {% else %} - - {% endif %} + + + Add a token +
{% endblock %} diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 3ba644c0969..dbb8c4835af 100644 --- a/netbox/tenancy/models.py +++ b/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 @@ def to_objectchange(self, action): 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): diff --git a/netbox/users/views.py b/netbox/users/views.py index a6d28ecd222..cf7ed6430d5 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -6,7 +6,7 @@ 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 @@ def get(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) 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 @@ def post(self, request, pk=None): 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: diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index d76b469b268..b7c5564eeb1 100644 --- a/netbox/utilities/utils.py +++ b/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 diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 518b7086c0b..a6e75e506f7 100644 --- a/netbox/virtualization/api/serializers.py +++ b/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): diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 586ad502815..1bc40c2de7b 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -80,7 +80,7 @@ def get_serializer_class(self): 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 diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index edacb3e0730..20d0e4ad8e7 100644 --- a/netbox/virtualization/forms.py +++ b/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) diff --git a/requirements.txt b/requirements.txt index 6c41dcee00f..e913a8f8110 100644 --- a/requirements.txt +++ b/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