powderflask
powderflask

Reputation: 389

Django model.field validators don't execute for required Boolean fields

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

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

Answers (1)

Peter DeGlopper
Peter DeGlopper

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:

  1. call to_python to convert the raw value into the correct type
  2. call validate on the converted value to check for conditions like required
  3. call run_validators on the converted value to check for violations of any registered validator

A 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

Related Questions