Skip to content

Commit

Permalink
[#27] Properly handle blank=True in combination with choices
Browse files Browse the repository at this point in the history
  • Loading branch information
swrichards committed Dec 9, 2024
1 parent 7cf1e39 commit 18964fa
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 16 deletions.
41 changes: 26 additions & 15 deletions django_setup_configuration/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,32 @@ def __init__(
field_info_creation_kwargs["default_factory"] = django_default
else:
inferred_default = django_default
else:
# If nullable, mark the field is optional with a default of None
if self.django_field.null:
inferred_default = None
self.python_type = self.python_type | None

# For strings that can have blank=True and null=False (the
# recommended approach), set an empty string as the default
if (
self.django_field.blank
and not self.django_field.null
and self.python_type == str
):
inferred_default = ""
self.python_type = self.python_type | None

# If nullable, mark the field is optional with a default of None...
if self.django_field.null:
self.python_type = self.python_type | None
if inferred_default is NOT_PROVIDED:
inferred_default = None

# ... otherwise, if blank, amend type to allow for the field's
# defined default value. This is mostly to handle the case
# where blank=True is set together with choices=... but without a
# default. In that case, the default empty value should be part of the
# annotation, because the base type will be a literal that might not
# include the default value as an option (think using an empty
# string to represent the absence of a text choice).
elif self.django_field.blank:
inferred_default = (
self.django_field.get_default()
if inferred_default is NOT_PROVIDED
else inferred_default
)
default_type = (
inferred_default
if inferred_default in (None, True, False)
else Literal[inferred_default]
)
self.python_type = self.python_type | default_type

field_info_creation_kwargs["annotation"] = self.python_type
if inferred_default is not NOT_PROVIDED:
Expand Down
17 changes: 17 additions & 0 deletions testapp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class TestModel(models.Model):
int_with_default = models.IntegerField(default=42)
nullable_int = models.IntegerField(null=True)
nullable_int_with_default = models.IntegerField(default=42)

nullable_str = models.CharField(null=True, blank=False, max_length=1)
nullable_and_blank_str = models.CharField(null=True, blank=False, max_length=1)
blank_str = models.CharField(null=False, blank=True, max_length=1)
Expand All @@ -23,4 +24,20 @@ class TestModel(models.Model):
str_with_choices_and_default = models.CharField(
max_length=3, choices=StrChoices.choices, default=StrChoices.bar
)
str_with_choices_and_blank = models.CharField(
max_length=3, choices=StrChoices.choices, blank=True
)
int_with_choices = models.IntegerField(choices=((1, "FOO"), (8, "BAR")))

int_with_choices_and_blank = models.IntegerField(
blank=True,
choices=((1, "FOO"), (8, "BAR")),
# Note we explicitly do not set null=False here -- in Django, this means we would
# get an IntegrityError if trying to save this with the default, but at the
# configuration model layer we'll allow that and let Django complain (assuming
# that this was unintentional anyway).
null=False,
)
int_with_choices_and_blank_and_non_choice_default = models.IntegerField(
blank=True, choices=((1, "FOO"), (8, "BAR")), default=42
)
50 changes: 49 additions & 1 deletion tests/test_django_model_ref_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ class Meta:

assert field.title == "blank str"
assert field.description is None
assert field.annotation == str | None
assert field.annotation == str | Literal[""]
assert field.default == ""
assert field.is_required() is False

Expand Down Expand Up @@ -254,3 +254,51 @@ class Config(ConfigurationModel):
assert field.annotation == bool
assert field.default == PydanticUndefined
assert field.is_required() is True


def test_str_with_choices_and_blank_allows_empty_string_in_annotation():

class Config(ConfigurationModel):
str_with_choices_and_blank = DjangoModelRef(
TestModel, "str_with_choices_and_blank"
)

field = Config.model_fields["str_with_choices_and_blank"]

assert field.title == "str with choices and blank"
assert field.description is None
assert field.annotation == Literal["foo", "bar"] | Literal[""]
assert field.default == ""
assert field.is_required() is False


def test_int_with_choices_and_blank_adds_default_in_annotation():

class Config(ConfigurationModel):
int_with_choices_and_blank = DjangoModelRef(
TestModel, "int_with_choices_and_blank"
)

field = Config.model_fields["int_with_choices_and_blank"]

assert field.title == "int with choices and blank"
assert field.description is None
assert field.annotation == Literal[1, 8] | None
assert field.default is None
assert field.is_required() is False


def test_int_with_choices_and_blank_and_non_choice_default_adds_default_in_annotation():

class Config(ConfigurationModel):
int_with_choices_and_blank_and_non_choice_default = DjangoModelRef(
TestModel, "int_with_choices_and_blank_and_non_choice_default"
)

field = Config.model_fields["int_with_choices_and_blank_and_non_choice_default"]

assert field.title == "int with choices and blank and non choice default"
assert field.description is None
assert field.annotation == Literal[1, 8] | Literal[42]
assert field.default == 42
assert field.is_required() is False

0 comments on commit 18964fa

Please sign in to comment.