diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 15ac1fc04..7cf199e21 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,5 +1,21 @@ # NetBox v2.10 +## v2.10.1 (2020-12-15) + +### Bug Fixes + +* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces +* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value +* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable +* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception +* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception +* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception +* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device +* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields +* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint + +--- + ## v2.10.0 (2020-12-14) **NOTE:** This release completely removes support for embedded graphs. diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index ad497ee5f..ef5a944e2 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,4 +1,5 @@ from django.db.models import Prefetch +from django.db.models.functions import Coalesce from rest_framework.routers import APIRootView from circuits import filters @@ -24,7 +25,7 @@ class CircuitsRootView(APIRootView): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.prefetch_related('tags').annotate( - circuit_count=get_subquery(Circuit, 'provider') + circuit_count=Coalesce(get_subquery(Circuit, 'provider'), 0) ) serializer_class = serializers.ProviderSerializer filterset_class = filters.ProviderFilterSet @@ -36,7 +37,7 @@ class ProviderViewSet(CustomFieldModelViewSet): class CircuitTypeViewSet(ModelViewSet): queryset = CircuitType.objects.annotate( - circuit_count=get_subquery(Circuit, 'type') + circuit_count=Coalesce(get_subquery(Circuit, 'type'), 0) ) serializer_class = serializers.CircuitTypeSerializer filterset_class = filters.CircuitTypeFilterSet diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 653c881a9..a237b8805 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -139,7 +139,7 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A ).first() - if termination_a and termination_a.connected_endpoint: + if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'): termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') # Z-side termination @@ -148,7 +148,7 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z ).first() - if termination_z and termination_z.connected_endpoint: + if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'): termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') return { diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 071174c76..db36c3176 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -3,6 +3,7 @@ from collections import OrderedDict from django.conf import settings from django.db.models import F +from django.db.models.functions import Coalesce from django.http import HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -119,12 +120,12 @@ class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'tags' ).annotate( - device_count=get_subquery(Device, 'site'), - rack_count=get_subquery(Rack, 'site'), - prefix_count=get_subquery(Prefix, 'site'), - vlan_count=get_subquery(VLAN, 'site'), - circuit_count=get_subquery(Circuit, 'terminations__site'), - virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'), + device_count=Coalesce(get_subquery(Device, 'site'), 0), + rack_count=Coalesce(get_subquery(Rack, 'site'), 0), + prefix_count=Coalesce(get_subquery(Prefix, 'site'), 0), + vlan_count=Coalesce(get_subquery(VLAN, 'site'), 0), + circuit_count=Coalesce(get_subquery(Circuit, 'terminations__site'), 0), + virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'cluster__site'), 0), ) serializer_class = serializers.SiteSerializer filterset_class = filters.SiteFilterSet @@ -152,7 +153,7 @@ class RackGroupViewSet(ModelViewSet): class RackRoleViewSet(ModelViewSet): queryset = RackRole.objects.annotate( - rack_count=get_subquery(Rack, 'role') + rack_count=Coalesce(get_subquery(Rack, 'role'), 0) ) serializer_class = serializers.RackRoleSerializer filterset_class = filters.RackRoleFilterSet @@ -166,8 +167,8 @@ class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( 'site', 'group__site', 'role', 'tenant', 'tags' ).annotate( - device_count=get_subquery(Device, 'rack'), - powerfeed_count=get_subquery(PowerFeed, 'rack') + device_count=Coalesce(get_subquery(Device, 'rack'), 0), + powerfeed_count=Coalesce(get_subquery(PowerFeed, 'rack'), 0) ) serializer_class = serializers.RackSerializer filterset_class = filters.RackFilterSet @@ -240,9 +241,9 @@ class RackReservationViewSet(ModelViewSet): class ManufacturerViewSet(ModelViewSet): queryset = Manufacturer.objects.annotate( - devicetype_count=get_subquery(DeviceType, 'manufacturer'), - inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), - platform_count=get_subquery(Platform, 'manufacturer') + devicetype_count=Coalesce(get_subquery(DeviceType, 'manufacturer'), 0), + inventoryitem_count=Coalesce(get_subquery(InventoryItem, 'manufacturer'), 0), + platform_count=Coalesce(get_subquery(Platform, 'manufacturer'), 0) ) serializer_class = serializers.ManufacturerSerializer filterset_class = filters.ManufacturerFilterSet @@ -254,7 +255,7 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( - device_count=get_subquery(Device, 'device_type') + device_count=Coalesce(get_subquery(Device, 'device_type'), 0) ) serializer_class = serializers.DeviceTypeSerializer filterset_class = filters.DeviceTypeFilterSet @@ -318,8 +319,8 @@ class DeviceBayTemplateViewSet(ModelViewSet): class DeviceRoleViewSet(ModelViewSet): queryset = DeviceRole.objects.annotate( - device_count=get_subquery(Device, 'device_role'), - virtualmachine_count=get_subquery(VirtualMachine, 'role') + device_count=Coalesce(get_subquery(Device, 'device_role'), 0), + virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'role'), 0) ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filters.DeviceRoleFilterSet @@ -331,8 +332,8 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.annotate( - device_count=get_subquery(Device, 'platform'), - virtualmachine_count=get_subquery(VirtualMachine, 'platform') + device_count=Coalesce(get_subquery(Device, 'platform'), 0), + virtualmachine_count=Coalesce(get_subquery(VirtualMachine, 'platform'), 0) ) serializer_class = serializers.PlatformSerializer filterset_class = filters.PlatformFilterSet @@ -596,7 +597,7 @@ class CableViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet): queryset = VirtualChassis.objects.prefetch_related('tags').annotate( - member_count=get_subquery(Device, 'virtual_chassis') + member_count=Coalesce(get_subquery(Device, 'virtual_chassis'), 0) ) serializer_class = serializers.VirtualChassisSerializer filterset_class = filters.VirtualChassisFilterSet @@ -610,7 +611,7 @@ class PowerPanelViewSet(ModelViewSet): queryset = PowerPanel.objects.prefetch_related( 'site', 'rack_group' ).annotate( - powerfeed_count=get_subquery(PowerFeed, 'power_panel') + powerfeed_count=Coalesce(get_subquery(PowerFeed, 'power_panel'), 0) ) serializer_class = serializers.PowerPanelSerializer filterset_class = filters.PowerPanelFilterSet diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ca7602bf2..cb2aa10e6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2839,7 +2839,7 @@ class InterfaceBulkCreateForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode' + 'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode' ]), BootstrapMixin, AddRemoveTagsForm, @@ -2855,6 +2855,15 @@ class InterfaceBulkEditForm( disabled=True, widget=forms.HiddenInput() ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Management only' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7a9f2dc2c..6a530bb49 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -147,7 +147,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return instance def __str__(self): - return self.label or '#{}'.format(self._pk) + pk = self.pk or self._pk + return self.label or f'#{pk}' def get_absolute_url(self): return reverse('dcim:cable', args=[self.pk]) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 97a4f3705..91ec8776c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -302,6 +302,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_rack_elevations(self): + """ + Test viewing the list of rack elevations. + """ + response = self.client.get(reverse('dcim:rack_elevation_list')) + self.assertHttpStatus(response, 200) + class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Manufacturer diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 38077c89a..fcd9add7c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db.models.functions import Coalesce from django.http import Http404 from django_rq.queues import get_connection from rest_framework import status @@ -102,7 +103,7 @@ class ExportTemplateViewSet(ModelViewSet): class TagViewSet(ModelViewSet): queryset = Tag.objects.annotate( - tagged_items=get_subquery(TaggedItem, 'tag') + tagged_items=Coalesce(get_subquery(TaggedItem, 'tag'), 0) ) serializer_class = serializers.TagSerializer filterset_class = filters.TagFilterSet diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index eee54076b..932d07a4d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -46,13 +46,13 @@ class CustomFieldModelForm(forms.ModelForm): # Annotate the field in the list of CustomField form fields self.custom_fields.append(field_name) - def save(self, commit=True): + def clean(self): # Save custom field data on instance for cf_name in self.custom_fields: self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name) - return super().save(commit) + return super().clean() class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index be06eea8a..6f4c5f9e1 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,6 +1,6 @@ import re from collections import OrderedDict -from datetime import datetime +from datetime import datetime, date from django import forms from django.contrib.contenttypes.models import ContentType @@ -317,10 +317,11 @@ class CustomField(models.Model): # Validate date if self.type == CustomFieldTypeChoices.TYPE_DATE: - try: - datetime.strptime(value, '%Y-%m-%d') - except ValueError: - raise ValidationError("Date values must be in the format YYYY-MM-DD.") + if type(value) is not date: + try: + datetime.strptime(value, '%Y-%m-%d') + except ValueError: + raise ValidationError("Date values must be in the format YYYY-MM-DD.") # Validate selected choice if self.type == CustomFieldTypeChoices.TYPE_SELECT: diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9d09bbe03..fb38edf46 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_yasg.utils import swagger_auto_schema @@ -32,8 +33,8 @@ class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.prefetch_related('tenant').prefetch_related( 'import_targets', 'export_targets', 'tags' ).annotate( - ipaddress_count=get_subquery(IPAddress, 'vrf'), - prefix_count=get_subquery(Prefix, 'vrf') + ipaddress_count=Coalesce(get_subquery(IPAddress, 'vrf'), 0), + prefix_count=Coalesce(get_subquery(Prefix, 'vrf'), 0) ) serializer_class = serializers.VRFSerializer filterset_class = filters.VRFFilterSet @@ -55,7 +56,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet): class RIRViewSet(ModelViewSet): queryset = RIR.objects.annotate( - aggregate_count=get_subquery(Aggregate, 'rir') + aggregate_count=Coalesce(get_subquery(Aggregate, 'rir'), 0) ) serializer_class = serializers.RIRSerializer filterset_class = filters.RIRFilterSet @@ -77,8 +78,8 @@ class AggregateViewSet(CustomFieldModelViewSet): class RoleViewSet(ModelViewSet): queryset = Role.objects.annotate( - prefix_count=get_subquery(Prefix, 'role'), - vlan_count=get_subquery(VLAN, 'role') + prefix_count=Coalesce(get_subquery(Prefix, 'role'), 0), + vlan_count=Coalesce(get_subquery(VLAN, 'role'), 0) ) serializer_class = serializers.RoleSerializer filterset_class = filters.RoleFilterSet @@ -272,7 +273,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(ModelViewSet): queryset = VLANGroup.objects.prefetch_related('site').annotate( - vlan_count=get_subquery(VLAN, 'group') + vlan_count=Coalesce(get_subquery(VLAN, 'group'), 0) ) serializer_class = serializers.VLANGroupSerializer filterset_class = filters.VLANGroupFilterSet @@ -286,7 +287,7 @@ class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'tags' ).annotate( - prefix_count=get_subquery(Prefix, 'vlan') + prefix_count=Coalesce(get_subquery(Prefix, 'vlan'), 0) ) serializer_class = serializers.VLANSerializer filterset_class = filters.VLANFilterSet diff --git a/netbox/netbox/api/metadata.py b/netbox/netbox/api/metadata.py index 1d0397e4d..bc4ecf871 100644 --- a/netbox/netbox/api/metadata.py +++ b/netbox/netbox/api/metadata.py @@ -1,10 +1,45 @@ +from django.core.exceptions import PermissionDenied +from django.http import Http404 from django.utils.encoding import force_str +from rest_framework import exceptions from rest_framework.metadata import SimpleMetadata +from rest_framework.request import clone_request from netbox.api import ContentTypeField -class ContentTypeMetadata(SimpleMetadata): +class BulkOperationMetadata(SimpleMetadata): + + def determine_actions(self, request, view): + """ + Replace the stock determine_actions() method to assess object permissions only + when viewing a specific object. This is necessary to support OPTIONS requests + with bulk update in place (see #5470). + """ + actions = {} + for method in {'PUT', 'POST'} & set(view.allowed_methods): + view.request = clone_request(request, method) + try: + # Test global permissions + if hasattr(view, 'check_permissions'): + view.check_permissions(view.request) + # Test object permissions (if viewing a specific object) + if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'): + view.get_object() + except (exceptions.APIException, PermissionDenied, Http404): + pass + else: + # If user has appropriate permissions for the view, include + # appropriate metadata about the fields that should be supplied. + serializer = view.get_serializer() + actions[method] = self.get_serializer_info(serializer) + finally: + view.request = request + + return actions + + +class ContentTypeMetadata(BulkOperationMetadata): def get_field_info(self, field): field_info = super().get_field_info(field) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4021b25c6..b2269ca0e 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.10.0' +VERSION = '2.10.1' # Hostname HOSTNAME = platform.node() @@ -467,6 +467,7 @@ REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), + 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( 'netbox.api.authentication.TokenPermissions', diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 1153b0508..617da5c6e 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,6 +1,7 @@ import base64 from Crypto.PublicKey import RSA +from django.db.models.functions import Coalesce from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated @@ -35,7 +36,7 @@ class SecretsRootView(APIRootView): class SecretRoleViewSet(ModelViewSet): queryset = SecretRole.objects.annotate( - secret_count=get_subquery(Secret, 'role') + secret_count=Coalesce(get_subquery(Secret, 'role'), 0) ) serializer_class = serializers.SecretRoleSerializer filterset_class = filters.SecretRoleFilterSet diff --git a/netbox/templates/dcim/inc/cable_toggle_buttons.html b/netbox/templates/dcim/inc/cable_toggle_buttons.html index 74fb5eb36..98e4efd94 100644 --- a/netbox/templates/dcim/inc/cable_toggle_buttons.html +++ b/netbox/templates/dcim/inc/cable_toggle_buttons.html @@ -10,7 +10,7 @@ {% endif %} {% endif %} {% if perms.dcim.delete_cable %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index a8e04e9dc..c0a2ff22a 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -9,18 +9,18 @@ {% include 'responsive_table.html' %}