afinkado
afinkado

Reputation: 21

A multipart/form-data with nested serializers and files, DRF raises "The submitted data was not a file. Check the encoding type on the form."

I have this issue with using a multipart/form-data, nested serializers and the upload of images/files. I think my models and serializers are working well, but in some moment during the parse of the form data is when it fails.

I've just extended the BaseParser to parse strings to lists and dicts.

parsers.py

class MultiPartJSONParser(BaseParser):
    media_type = 'multipart/form-data'
   
    def parse(self, stream, media_type=None, parser_context=None):
        parser_context = parser_context or {}
        request = parser_context['request']
        encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET)
        meta = request.META.copy()
        meta['CONTENT_TYPE'] = media_type
        upload_handlers = request.upload_handlers

        try:
            parser = DjangoMultiPartParser(meta, stream, upload_handlers, encoding)
            data, files = parser.parse()
            data = data.dict()
            for key in data:
                if data[key]:
                    try:
                        data[key] = json.loads(data[key])
                    except ValueError as e:
                        pass
            return DataAndFiles(data, files)
        except MultiPartParserError as exc:
            raise ParseError('Multipart form parse error - %s' % exc)

Why I do this? Because my intention is to pass a form-data like this:

key Value
user 6
address {"street": "Main St.", "number": "1"}
first_name Alice
last_name Bob
image [file] ...\image_directory
language [1,2]

If I don't POST or PATCH with the image it works well, but when I send an image:

The submitted data was not a file. Check the encoding type on the form.

Where it fails?

I've tracked down where it fails. When it calls 'to_internal_value()' from FileField class, the data has not 'name' and 'size' and raises an AttributeError.

class FileField(Field):
    default_error_messages = {
        'required': _('No file was submitted.'),
        'invalid': _('The submitted data was not a file. Check the encoding type on the form.'),
        'no_name': _('No filename could be determined.'),
        'empty': _('The submitted file is empty.'),
        'max_length': _('Ensure this filename has at most {max_length} characters (it has {length}).'),
    }

    def __init__(self, **kwargs):
        self.max_length = kwargs.pop('max_length', None)
        self.allow_empty_file = kwargs.pop('allow_empty_file', False)
        if 'use_url' in kwargs:
            self.use_url = kwargs.pop('use_url')
        super().__init__(**kwargs)

    def to_internal_value(self, data):
        try:
            # `UploadedFile` objects should have name and size attributes.
            file_name = data.name
            file_size = data.size
        except AttributeError:
            self.fail('invalid')

        if not file_name:
            self.fail('no_name')
        if not self.allow_empty_file and not file_size:
            self.fail('empty')
        if self.max_length and len(file_name) > self.max_length:
            self.fail('max_length', max_length=self.max_length, length=len(file_name))

        return data

Why it fails?

In some part of the code is taking the image field as a list, instead of a InMemoryUploadedFile.

The "crazy" thing about this it is that if I use the MultipartParser of DRF, which is very similar to the one above, it works with the image.

Here are my serializers:

serializers.py

class ProfileSerializer(serializers.ModelSerializer):
    user = PrimaryKeyRelatedField(many = False, queryset = User.objects.all())
    address = AddressSerializer()
    language = PrimaryKeyRelatedField(many = True, queryset = Language.objects.all())

    class Meta:
        model = RenterProfile
        fields = '__all__'

    def create(self, validated_data):
        address_data = validated_data.pop('address')
        address = Address.objects.create(**address_data)
        language_data = validated_data.pop('language')
        renter_profile = RenterProfile.objects.create(address=address, **validated_data)
        renter_profile.language.set(language_data)
    
    def update(self, instance, validated_data)
        if 'address' in validated_data:
            address_data = validated_data.pop('address')
            Address.objects.filter(id=instance.address.id).update(**address_data)

        if 'language' in validated_data:
            language_data = validated_data.pop('language')            
            instance.language.set(language_data)
        
        for (key, value) in validated_data.items():
            setattr(instance, key, value)
        instance.save()
        return instance

class AddressSerializer(serializers.ModelSerializer):
    class Meta:
        models = Address
        fields = '__all__'

Here is more context, in case you need it.

NOTE: I've reduced the code to avoid unnecessary info.

Models.py

class User(models.Model):
    email = models.EmailField()
    password = models.CharField()

class UserProfile(models.Model):
    user = models.OneToOneField(User)
    image = models.ImageField()
    first_name = models.CharField()
    last_name = models.Charfield()
    address = models.ForeignKey(Address)
    language = models.ManyToMany(Language)

class Address(models.Model):
    street = models.Charfield()
    number = models.Charfield()
     
class Language(models.Model):
    name = models.CharField()

My views are simple:

views.py

class CreateProfileView(CreateAPIView):
    serializer_class = ProfileSerializer
    parser_classes = [MultiPartJSONParser]
 
class RUDProfileView(RetrieveUpdateDestroyAPIView):
    serializer_class = ProfileSerializer
    queryset = UserProfile.objects.all()
    lookup_field = 'user'
    parser_classes = [MultiPartJSONParser]

Upvotes: 1

Views: 3162

Answers (1)

afinkado
afinkado

Reputation: 21

I've come to a solution, which is not the desired for me but it works.

I have discovered that for any unknown reason, the file/image field is being inserted into a list if I was using my MultiPartJSONParser.

To solve it I have to extract this fields from the list in the views.py before calling super().create() or super().update()

views.py

class CreateProfileView(CreateAPIView):
    serializer_class = ProfileSerializer
    parser_classes = [MultiPartJSONParser]

    def create(self, request, *args, **kwargs):
        for key in request.FILES:
            request.data[key] = request.data[key][0]
        return super().update(request, *args, **kwargs)

class RUDProfileView(RetrieveUpdateDestroyAPIView):
    serializer_class = ProfileSerializer
    queryset = UserProfile.objects.all()
    lookup_field = 'user'
    parser_classes = [MultiPartJSONParser]

    def update(self, request, *args, **kwargs):
        for key in request.FILES:
            request.data[key] = request.data[key][0]
        return super().update(request, *args, **kwargs)

Upvotes: 1

Related Questions