Explorar o código

Fixes #4083: Permit nullifying applicable choice fields via API requests

Jeremy Stretch %!s(int64=6) %!d(string=hai) anos
pai
achega
7388fa3556

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

@@ -11,6 +11,7 @@
 ## Bug Fixes
 ## Bug Fixes
 
 
 * [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices
 * [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices
+* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests
 * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
 * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
 * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
 * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
 * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form
 * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form

+ 18 - 10
netbox/dcim/api/serializers.py

@@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     status = ChoiceField(choices=RackStatusChoices, required=False)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
     role = NestedRackRoleSerializer(required=False, allow_null=True)
-    type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True)
+    type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
     width = ChoiceField(choices=RackWidthChoices, required=False)
     width = ChoiceField(choices=RackWidthChoices, required=False)
-    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False)
+    outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
     powerfeed_count = serializers.IntegerField(read_only=True)
@@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
 
 
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
     manufacturer = NestedManufacturerSerializer()
     manufacturer = NestedManufacturerSerializer()
-    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True)
+    subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
     tags = TagListSerializerField(required=False)
     tags = TagListSerializerField(required=False)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
 
 
@@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
 
 
@@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
 
 
@@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
 
 
@@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     power_port = PowerPortTemplateSerializer(
     power_port = PowerPortTemplateSerializer(
@@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
     )
     )
     feed_leg = ChoiceField(
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
-        required=False,
-        allow_null=True
+        allow_blank=True,
+        required=False
     )
     )
 
 
     class Meta:
     class Meta:
@@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
     rack = NestedRackSerializer(required=False, allow_null=True)
     rack = NestedRackSerializer(required=False, allow_null=True)
-    face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True)
+    face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     status = ChoiceField(choices=DeviceStatusChoices, required=False)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip = NestedIPAddressSerializer(read_only=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
     primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
@@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=ConsolePortTypeChoices,
         choices=ConsolePortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerOutletTypeChoices,
         choices=PowerOutletTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     power_port = NestedPowerPortSerializer(
     power_port = NestedPowerPortSerializer(
@@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     )
     )
     feed_leg = ChoiceField(
     feed_leg = ChoiceField(
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
-        required=False,
-        allow_null=True
+        allow_blank=True,
+        required=False
     )
     )
     cable = NestedCableSerializer(
     cable = NestedCableSerializer(
         read_only=True
         read_only=True
@@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(
     type = ChoiceField(
         choices=PowerPortTypeChoices,
         choices=PowerPortTypeChoices,
+        allow_blank=True,
         required=False
         required=False
     )
     )
     cable = NestedCableSerializer(read_only=True)
     cable = NestedCableSerializer(read_only=True)
@@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     type = ChoiceField(choices=InterfaceTypeChoices, required=False)
     type = ChoiceField(choices=InterfaceTypeChoices, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
@@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer):
     termination_a = serializers.SerializerMethodField(read_only=True)
     termination_a = serializers.SerializerMethodField(read_only=True)
     termination_b = serializers.SerializerMethodField(read_only=True)
     termination_b = serializers.SerializerMethodField(read_only=True)
     status = ChoiceField(choices=CableStatusChoices, required=False)
     status = ChoiceField(choices=CableStatusChoices, required=False)
-    length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True)
+    length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
 
 
     class Meta:
     class Meta:
         model = Cable
         model = Cable

+ 2 - 2
netbox/ipam/api/serializers.py

@@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     vrf = NestedVRFSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
-    role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True)
+    role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
@@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer):
 class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
 class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
     device = NestedDeviceSerializer(required=False, allow_null=True)
     device = NestedDeviceSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
     virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
-    protocol = ChoiceField(choices=ServiceProtocolChoices)
+    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
     ipaddresses = SerializedPKRelatedField(
     ipaddresses = SerializedPKRelatedField(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         serializer=NestedIPAddressSerializer,
         serializer=NestedIPAddressSerializer,

+ 19 - 2
netbox/utilities/api.py

@@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission):
 
 
 class ChoiceField(Field):
 class ChoiceField(Field):
     """
     """
-    Represent a ChoiceField as {'value': <DB value>, 'label': <string>}.
+    Represent a ChoiceField as {'value': <DB value>, 'label': <string>}. Accepts a single value on write.
+
+    :param choices: An iterable of choices in the form (value, key).
+    :param allow_blank: Allow blank values in addition to the listed choices.
     """
     """
-    def __init__(self, choices, **kwargs):
+    def __init__(self, choices, allow_blank=False, **kwargs):
         self.choiceset = choices
         self.choiceset = choices
+        self.allow_blank = allow_blank
         self._choices = dict()
         self._choices = dict()
 
 
         # Unpack grouped choices
         # Unpack grouped choices
@@ -77,6 +81,15 @@ class ChoiceField(Field):
 
 
         super().__init__(**kwargs)
         super().__init__(**kwargs)
 
 
+    def validate_empty_values(self, data):
+        # Convert null to an empty string unless allow_null == True
+        if data is None:
+            if self.allow_null:
+                return True, None
+            else:
+                data = ''
+        return super().validate_empty_values(data)
+
     def to_representation(self, obj):
     def to_representation(self, obj):
         if obj is '':
         if obj is '':
             return None
             return None
@@ -93,6 +106,10 @@ class ChoiceField(Field):
         return data
         return data
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
+        if data is '':
+            if self.allow_blank:
+                return data
+            raise ValidationError("This field may not be blank.")
 
 
         # Provide an explicit error message if the request is trying to write a dict or list
         # Provide an explicit error message if the request is trying to write a dict or list
         if isinstance(data, (dict, list)):
         if isinstance(data, (dict, list)):

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

@@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
 class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
     virtual_machine = NestedVirtualMachineSerializer()
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
-    mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
+    mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),