diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index 63dbd8b9e9..c90b8c083a 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -47,6 +47,8 @@ If set, this gives the default value that will be used for the field if no input The `default` is not applied during partial update operations. In the partial update case only fields that are provided in the incoming data will have a validated value returned. +If you're using [Model Serializer](https://www.django-rest-framework.org/api-guide/serializers/#modelserializer) and the corresponding `Model` field declares a `default`, it will be used as the serializer field default. This does not apply to relational fields such as `ForeignKey`: a model default on a relational field is expressed as a primary key value rather than a model instance, so it is not propagated and only causes the generated serializer field to be not required. + May be set to a function or other callable, in which case the value will be evaluated each time it is used. When called, it will receive no arguments. If the callable has a `requires_context = True` attribute, then the serializer field will be passed as an argument. For example: diff --git a/rest_framework/metadata.py b/rest_framework/metadata.py index 364ca5b14d..b120517c40 100644 --- a/rest_framework/metadata.py +++ b/rest_framework/metadata.py @@ -11,6 +11,7 @@ from django.utils.encoding import force_str from rest_framework import exceptions, serializers +from rest_framework.fields import empty from rest_framework.request import clone_request from rest_framework.utils.field_mapping import ClassLookupDict @@ -133,6 +134,10 @@ def get_field_info(self, field): if value is not None and value != '': field_info[attr] = force_str(value, strings_only=True) + default = getattr(field, 'default', None) + if default is not None and default is not empty and not callable(default): + field_info['default'] = default + if getattr(field, 'child', None): field_info['child'] = self.get_field_info(field.child) elif getattr(field, 'fields', None): diff --git a/rest_framework/utils/field_mapping.py b/rest_framework/utils/field_mapping.py index d35caca0c7..f26b2df71c 100644 --- a/rest_framework/utils/field_mapping.py +++ b/rest_framework/utils/field_mapping.py @@ -133,6 +133,9 @@ def get_field_kwargs(field_name, model_field): if model_field.has_default() or model_field.blank or model_field.null: kwargs['required'] = False + if model_field.has_default(): + kwargs['default'] = model_field.default + if model_field.blank and (isinstance(model_field, (models.CharField, models.TextField))): kwargs['allow_blank'] = True diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 98ea2d3d9f..e750d562de 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -153,6 +153,22 @@ class Meta: assert data['properties']['ro_field']['nullable'], "ro_field nullable must be true" assert data['properties']['ro_field']['readOnly'], "ro_field read_only must be true" + def test_model_field_default_in_schema(self): + class DefaultsModel(models.Model): + default_field = models.IntegerField(default=0) + callable_default_field = models.UUIDField(default=uuid.uuid4) + + class Serializer(serializers.ModelSerializer): + class Meta: + model = DefaultsModel + fields = ["default_field", "callable_default_field"] + + inspector = AutoSchema() + + data = inspector.map_serializer(Serializer()) + assert data['properties']['default_field']['default'] == 0, "model default must appear in schema" + assert 'default' not in data['properties']['callable_default_field'], "callable default must not appear in schema" + def test_primary_key_related_field(self): class PrimaryKeyRelatedFieldSerializer(serializers.Serializer): basic = serializers.PrimaryKeyRelatedField(queryset=BasicModel.objects.all()) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 1bdc8697c4..a7ce2a2630 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -331,6 +331,16 @@ def test_decimal_field_info_type(self): assert field_info['max_digits'] == 18 assert field_info['decimal_places'] == 4 + def test_default_value_field_info(self): + options = metadata.SimpleMetadata() + field_info = options.get_field_info(serializers.IntegerField(default=42)) + assert field_info['default'] == 42 + + def test_callable_default_field_info(self): + options = metadata.SimpleMetadata() + field_info = options.get_field_info(serializers.IntegerField(default=list)) + assert 'default' not in field_info + class TestModelSerializerMetadata(TestCase): def test_read_only_primary_key_related_field(self): diff --git a/tests/test_model_serializer.py b/tests/test_model_serializer.py index 0e291a2de8..811917d069 100644 --- a/tests/test_model_serializer.py +++ b/tests/test_model_serializer.py @@ -11,6 +11,7 @@ import re import sys import tempfile +import uuid import pytest from django.core.exceptions import ImproperlyConfigured @@ -176,7 +177,7 @@ class Meta: TestSerializer\(\): auto_field = IntegerField\(read_only=True\) big_integer_field = BigIntegerField\(.*\) - boolean_field = BooleanField\(required=False\) + boolean_field = BooleanField\(default=False, required=False\) char_field = CharField\(max_length=100\) comma_separated_integer_field = CharField\(max_length=100, validators=\[\]\) date_field = DateField\(\) @@ -185,7 +186,7 @@ class Meta: email_field = EmailField\(max_length=100\) float_field = FloatField\(\) integer_field = IntegerField\(.*\) - null_boolean_field = BooleanField\(allow_null=True, required=False\) + null_boolean_field = BooleanField\(allow_null=True, default=False, required=False\) positive_integer_field = IntegerField\(.*\) positive_small_integer_field = IntegerField\(.*\) slug_field = SlugField\(allow_unicode=False, max_length=100\) @@ -212,13 +213,126 @@ class Meta: length_limit_field = CharField\(max_length=12, min_length=3\) blank_field = CharField\(allow_blank=True, max_length=10, required=False\) null_field = IntegerField\(allow_null=True,.*required=False\) - default_field = IntegerField\(.*required=False\) + default_field = IntegerField\(default=0,.*required=False\) descriptive_field = IntegerField\(help_text='Some help text', label='A label'.*\) choices_field = ChoiceField\(choices=(?:\[|\()\('red', 'Red'\), \('blue', 'Blue'\), \('green', 'Green'\)(?:\]|\))\) text_choices_field = ChoiceField\(choices=(?:\[|\()\('red', 'Red'\), \('blue', 'Blue'\), \('green', 'Green'\)(?:\]|\))\) """) assert re.search(expected, repr(TestSerializer())) is not None + def test_default_value_used_when_field_omitted(self): + """ + Model field defaults should be propagated to the serializer field, + so that omitted fields fall back to the model default on validation. + """ + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = FieldOptionsModel + fields = ('value_limit_field', 'default_field') + + serializer = TestSerializer(data={'value_limit_field': 5}) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == {'value_limit_field': 5, 'default_field': 0} + + def test_default_value_skipped_on_partial_update(self): + """ + Propagated model defaults should not be applied on partial updates. + """ + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = FieldOptionsModel + fields = ('value_limit_field', 'default_field') + + instance = FieldOptionsModel(value_limit_field=5, default_field=10) + serializer = TestSerializer(instance, data={'value_limit_field': 6}, partial=True) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == {'value_limit_field': 6} + + def test_default_value_used_on_full_update(self): + """ + Propagated model defaults should be applied on non-partial updates. + """ + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = FieldOptionsModel + fields = ('value_limit_field', 'default_field') + + instance = FieldOptionsModel(value_limit_field=5, default_field=10) + serializer = TestSerializer(instance, data={'value_limit_field': 6}) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == {'value_limit_field': 6, 'default_field': 0} + + def test_relational_field_default_not_propagated(self): + """ + Model defaults on relational fields are not propagated, since a + serializer field default is used as-is in `validated_data` while a + foreign key model default is expressed as a primary key value + rather than a model instance. + """ + class RelationalDefaultTargetModel(models.Model): + name = models.CharField(max_length=10) + + class RelationalDefaultSourceModel(models.Model): + target = models.ForeignKey( + RelationalDefaultTargetModel, on_delete=models.CASCADE, default=1 + ) + + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = RelationalDefaultSourceModel + fields = ('target',) + + field = TestSerializer().fields['target'] + assert field.default is serializers.empty + assert field.required is False + + def test_callable_default_executed_on_validation(self): + """ + Callable model defaults (e.g. `uuid.uuid4`) should be propagated as + callables and evaluated each time the field is omitted from the input. + """ + class CallableDefaultModel(models.Model): + uuid_field = models.UUIDField(default=uuid.uuid4) + name = models.CharField(max_length=10) + + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = CallableDefaultModel + fields = ('uuid_field', 'name') + + assert TestSerializer().fields['uuid_field'].default is uuid.uuid4 + + first = TestSerializer(data={'name': 'foo'}) + assert first.is_valid(), first.errors + assert isinstance(first.validated_data['uuid_field'], uuid.UUID) + + second = TestSerializer(data={'name': 'bar'}) + assert second.is_valid(), second.errors + assert second.validated_data['uuid_field'] != first.validated_data['uuid_field'] + + def test_choices_field_with_default(self): + """ + A choices field with a model default should fall back to that default + and still validate against the choices when omitted (#7469). + """ + class ChoicesDefaultModel(models.Model): + color = models.CharField(max_length=10, choices=COLOR_CHOICES, default='red') + + class TestSerializer(serializers.ModelSerializer): + class Meta: + model = ChoicesDefaultModel + fields = ('color',) + + assert isinstance(TestSerializer().fields['color'], ChoiceField) + + serializer = TestSerializer(data={}) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == {'color': 'red'} + + serializer = TestSerializer(data={'color': 'blue'}) + assert serializer.is_valid(), serializer.errors + assert serializer.validated_data == {'color': 'blue'} + def test_nullable_boolean_field_choices(self): class NullableBooleanChoicesModel(models.Model): CHECKLIST_OPTIONS = (