Search code examples
djangodjango-modelsdjango-formsdjango-admin

Django - type validation on not required field


In Django admin interface, when submitting a form which has an optional integer field, I want to show a validation error message when user enters an incorrect type (string). However when user enters a string into this field and submits the form, Django recognizes it as an incorrect type and changes the value to None and successfully validates the form. Therefore there is no way to find out if the field was left blank or if the user entered incorrect type. Even when I try to get clean data from custom form (clean_<field_name>) it always returns None.

Is there a way to show a validation error for an optional field? Or can I somehow get the value that was actually entered into the field?

Thank you for your help.

I tried to overwrite default behaviour with custom form and tried to get clean data from the field but nothing works, it always returns None.

Here is the model:

class Condition(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    code = models.CharField()
    common_name_en = models.CharField()
    ordering = models.IntegerField(blank=True, null=True)
    common_name_cs = models.CharField()
    eval_time_frame = models.ForeignKey('ConditionEvalTime', models.DO_NOTHING, db_column='eval_time_frame')
    unit = models.CharField(blank=True, null=True)
    metric_extreme_low = models.FloatField(blank=True, null=True)
    metric_extreme_high = models.FloatField(blank=True, null=True)
    metric_scale = models.FloatField(blank=True, null=True)

    class Meta:
        managed = False
        db_table = 'planner"."condition'

    def __str__(self) -> str:
        return self.common_name_en

And here is my admin file:

def validate_integer(value):
    print("before check " + value)
    if not isinstance(value, int):
        print("after check " + value)
        raise forms.ValidationError("This field must be an integer.")


class ConditionForm(forms.ModelForm):
    class Meta:
        model = Condition
        exclude = []

    ordering = forms.IntegerField(required=False, validators=[validate_integer])


@admin.register(Condition)
class ConditionAdmin(admin.ModelAdmin):

    def get_related_models(self, obj):
        """
        Return all models that have 'one-to-many' relation to Condition model
        """
        related_models = []
        related_fields = obj._meta.get_fields()

        for field in related_fields:
            if field.is_relation and field.one_to_many:
                related_model = field.related_model
                related_models.append(related_model._meta.object_name.lower())

        return related_models

    def has_delete_permission(self, request, obj=None):
        """
        Overwrite delete permission if object has relations
        """
        if obj:
            for related_model in self.get_related_models(obj):
                if getattr(obj, f"{related_model}_set").exists():
                    messages.warning(request, f"Can't delete: {obj.common_name_en} - {obj.id}")
                    return False
        return super().has_delete_permission(request, obj)

    form = ConditionForm
    fields = [field.name for field in Condition._meta.fields]
    readonly_fields = ["id"]

I tried to use custom validators but it doesn't work either, the validator just doesn't get run.


Solution

  • When the ModelForm renders the model's IntegerField, it creates an <input type="number"> HTML tag. Depending on how you look at it, this input type has a problematic behaviour, particularly in Firefox. While Chrome will block any character that's not a valid floating-point number, Firefox does not. Firefox will let you type into the field any character you want, which I suspect is the browser you're using. The value of the field will be whatever you typed into it as long as it's a valid input. However, the moment an invalid character is entered, the value will be set to an empty string, which is what Firefox does and the the HTML spec states:

    The value sanitization algorithm is as follows: If the value of the element is not a valid floating-point number, then set it to the empty string instead.

    I tried logging the input's value on keyup event, and that's indeed the behaviour Firefox implements. Value of number input set to empty

    Because this "emptying of the value" happens on an input event, it will be empty on form submission (if you don't set the novalidate attribute, Firefox will not let you submit the form if the field has an invalid input. This is why you can't access the invalid entry of the field in your backend (assuming you disabled browser validation), just as you can't access it with JavaScript via the value attribute: it will always be empty.

    In Django admin, the change_form that is used to render the add/change forms has a novalidate attribute set, so the browser's form validation is disabled. That's why when you save your Condition object with an invalid ordering input, the ordering value will be an empty string, and cast as None by Django.

    There are developers who have voiced out their distaste for < input type="number"> tag. See this SO blog post for example. And the UK government does not use it for similar reasons.

    As the UK government suggests, one possible workaround would be to use <input type="text" inputmode="numeric" pattern="[0-9]*". Which means the ordering will have to be a Charfield and handle interger validation yourself (in case browser validation is bypassed or disabled), which can be easily done by overriding the model's clean method. But with your current setup, it is impossible to achieve the functionality you want.

    I hope that helps.