Reputation: 389
My use case:
I have a Yes/No question the user must answer, but they may answer it yes or no. I don't want to supply a default because I need the user to actively select their answer. If they do not select an answer, I want a form validation error, like "This field is required".
I know that could store this in the DB as a CharField, but I'd prefer to store it as a required BooleanField. The issue appears to be that the form validation logic does NOT enforce required=True for Boolean fields, nor does it call the model field's custom validators when a value of '' is returned in the POST data.
My setup:
boolean_choices = (
(True, 'Yes'),
(False, 'No'),
)
def boolean_validator(value):
if value is not True and value is not False:
raise ValidationError("This field is required.")
class Item(models.Model):
accept = models.BooleanField(
choices=boolean_choices,
validators=[boolean_validator]
)
class ItemForm(ModelForm):
class Meta:
model = Item
fields = ['accept']
A complete, working demo of the issue is here: https://repl.it/@powderflask/django-model-form-validators
Reproducing the issue
create or edit an item, and set the 'accept' value to None ("-------") --> save will crash - notice form.is_valid() passed, crash is in form.save() --> notice that the boolean_validator did NOT get called.
create or edit an item and select "Yes" or "No" --> save will work fine, and you can see in the terminal that the boolean_validator DID get called.
My suspicionre
I suspect that somewhere deep in the form validation logic there is a special case for Boolean fields, which are most often a checkbox (which have a nasty habit of not returning anything in the POST data when not checked). I suspect that it is difficult to tell between "checkbox not ticked, so False" and "No value returned, so raise RequiredField ValidationError), and the form validation is not treating my use case correctly.
My Question Am I doing something hopelessly stupid here? Am I missing something? Should I give up and go have hot bath? with thanks.
Upvotes: 1
Views: 2951
Reputation: 37319
Reviewing the validation documentation and the source code of the form field classes reveals the issue and suggests a solution.
A form field handles its input in this order in its clean
method:
to_python
to convert the raw value into the correct typevalidate
on the converted value to check for conditions like required
run_validators
on the converted value to check for violations of any registered validatorA BooleanField
's to_python
method turns any input into either True
or False
. It cannot return None
or any other value:
class BooleanField(Field):
widget = CheckboxInput
def to_python(self, value):
"""Return a Python boolean object."""
# Explicitly check for the string 'False', which is what a hidden field
# will submit for False. Also check for '0', since this is what
# RadioSelect will provide. Because bool("True") == bool('1') == True,
# we don't need to handle that explicitly.
if isinstance(value, str) and value.lower() in ('false', '0'):
value = False
else:
value = bool(value)
return super().to_python(value)
This means that no validator can distinguish the situation in which the field was given None
as a value from a different situation that caused the field to return False
as the Python parsed value. You can use required=True
or an explicit validator to enforce that the value must be True
if you want that, but you can't tell the different reasons the value might be False
apart.
To do so, you need a different class - one that either treats None
and anything else you don't want to accept as invalid input before converting it to Python, or one that converts such values to None
and then can use a validator to reject None
values.
For the first solution, you could subclass and override to_python
. The NullBooleanField
class provides a good example, but we'll adapt it to treat null input as invalid rather than allowing it to return None
:
class StrictBooleanField(Field):
def to_python(self, value):
"""
Explicitly check for the string 'True' and 'False', which is what a
hidden field will submit for True and False, for 'true' and 'false',
which are likely to be returned by JavaScript serializations of forms,
and for '1' and '0', which is what a RadioField will submit. Unlike
the Booleanfield, this field must check for True because it doesn't
use the bool() function.
"""
if value in (True, 'True', 'true', '1'):
return True
elif value in (False, 'False', 'false', '0'):
return False
else:
raise ValidationError(_('Invalid boolean value %(value)s'), params={'value': value},', code='invalid')
For the second option (attractive because it doesn't require a custom field subclass) is to accept a conflict between the field name and the intent and to use a NullBooleanField
directly, registering a validator that will detect and reject a null value. Something like:
def validate_non_null(value):
if value is None:
raise ValidationError(_('Value must not be None', code='invalid')
class ItemForm(ModelForm):
accept = NullBooleanField(validators=[validate_non_null])
class Meta:
model = Item
fields = ['accept']
Here, we're making use of the NullBooleanField
to understand the input as True
, False
, or None
rather than just True
or False
, and then using standard validation to treat the None
as recognized but bad input.
Upvotes: 1