pythondjangodjango-rest-framework

How to add a validation to a model serializer so that it ensures at least one of two fields are passed for sure?


EDIT: A bit more technical description follows.

So, I have a model:

class MyModel(UUIDModel):
    file = models.FileField(blank=True, null=True, ...)
    code = models.CharField(blank=True, null=True, ...)

    def clean(self):
        super().clean()
        if not self.file and not self.code:
            raise ValidationError("At least one of the fields 'file' or 'code' must be provided.")
   
    def save(self, *args, **kwargs):
        self.full_clean()
        super().save(*args, **kwargs)

So, this is the model.

Now, this addition here was enough to add this validation to the admin panel functionality for this.

For the API part, I have a serializer:

class MySerializer(serializers.ModelSerializer):
    file = serializers.FileField(required=False)
    code = serializers.CharField(required=False)

    class Meta:
        model = MyModel
        fields = ("file", "code")

    def validate(self, data):
        file = data.get('file')
        code = data.get('code')
        if not file and not code:
            raise serializers.ValidationError("At least one out of 'file' or 'code' must be provided.")
        return data

So, this is the approach I'm going for but it doesn't work while hitting POST requests to the endpoint. Even if I don't pass any of these two fields, I still don't get an error.

I wanna implement it such that if none of the fields is passed, it throws an error for both fields and even if one is passed, it doesn't and no error for both either.

Any help will be appreciated. Thanks!

EDIT: The errors do occur actually but once all the other required fields in the model are provided, only then. They don't appear with the whole list of validation errors.


Solution

  • I dug around in the internet and found out that that is how DRF validation is meant to function:
    - check field-level validation first: This means it checks each individual field for any issues like missing required fields. If any field fails its validation (like a required field isn't provided), it stops right there and doesn't go ahead to the object-level validation which is the purpose of validate() method. => DRF won't even get to valdiate() if there are any field-level valdiation errors.

    validate() function is only called if all fields pass their field-level valdiation. So, data param in the validate() method is the list of fields that successfully passed validation, in your case, code and file are considered that they passed validation successfully since they are optional (required=False). That's why, file and code error only shows when every required field is passed like you said here:

    EDIT: The errors do occur actually but once all the other required fields in the model are provided, only then. They don't appear with the whole list of validation errors.

    So i dug around even more and found this article that outlined this problem and a workaround to get your desired behaviour. The solution that he mentioned is overriding is_valid() method. i adapted that to your use case and here is how you would implement it:

    class MySerializer(serializers.ModelSerializer):
        file = serializers.FileField(required=False)
        code = serializers.CharField(required=False)
    
        class Meta:
            model = MyModel
            fields = ("title", "file", "code")
    
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.is_validated = False
    
        def is_valid(self, *, raise_exception=True):
            super().is_valid(raise_exception=False)
    
            if self._errors and not self.is_validated:
                try:
                    self.validate(self.data)
                except serializers.ValidationError as e:
                    for field, message in e.detail.items():
                        if field in self.fields:
                            if not isinstance(self._errors.get(field), list):
                                self._errors[field] = []
                            self._errors[field].append(message)
    
            if self._errors and raise_exception:
                raise serializers.ValidationError(self._errors)
    
            return not bool(self._errors)
    
    
    
        def validate(self, data):
            self.is_validated = True
            file = data.get('file')
            code = data.get('code')
            if not file and not code:
                raise serializers.ValidationError({
                    'file': "Either file or code must be provided.",
                    'code': "Either code or file must be provided.",
                })
            return data
    

    I did some changes to the raised validation error in validate() so that it becomes field-specific errors. and since DRF gives back error as list, i also made the code and file errors have list type, that's why this piece of code was added:

    for field, message in e.detail.items(): # <-- this loops over the raised list of errors (which are 2 from validate method)
                        if field in self.fields:
                            if not isinstance(self._errors.get(field), list): # <-- this checks if error is of type list, ik though in our case it's always string 
                                self._errors[field] = [] 
                            self._errors[field].append(message) # <-- this appends string error to the made list
    

    Now, this is how the returned error is now, the way you want it !

    {"title":["This field is required."],"file":["Either file or code must be provided."],"code":["Either code or file must be provided."]}
    

    Note: I would advise you though to read through the article, he went through how is_valid() is implemented and run_validations() and why overriding is_valid() that way will give you the desired behaviour :)

    Sorry if this was a long reply, hope any added value makes it up !