Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Django Rest Framework - Updating related model using ModelSerializer and ModelViewSet

BACKGROUND

I have two serializers: PostSerializer and PostImageSerializer which both inherit DRF ModelSerializer. The PostImage model is linked with Post by related_name='photos'.

Since I want the serializer to perform update, PostSerializer overrides update() method from ModelSerializer as stated in official DRF doc.

class PostSerializer(serializers.ModelSerializer):
    photos = PostImageSerializer(many=True)

    class Meta:
        model = Post
        fields = ('title', 'content')

    def update(self, instance, validated_data):
        photos_data = validated_data.pop('photos')
        for photo in photos_data:
            PostImage.objects.create(post=instance, image=photo)
        return super(PostSerializer, self).update(instance, validated_data)

class PostImageSerializer(serializer.ModelSerializer):
    class Meta:
        model = PostImage
        fields = ('image', 'post')

I have also defined a ViewSet which inherits ModelViewSet.

 class PostViewSet(viewsets.ModelViewSet):
        queryset = Post.objects.all()
        serializer_class = PostSerializer

Finally the PostViewSet is registered to DefaultRouter. (Omitted code)

Goal

The goals are simple.

  • Send PUT request via PostMan with url like 'PUT http://localhost:8000/api/posts/1/'
  • Since Image files should be included, request would be done by form-data like below.

Problem

I'm getting 400 Response with error message as following.

{
"photos": [ "This field is required." ],
"title": [ "This field is required." ],
"content": [ "This field is required." ]
}

(Should you plz note that the error messages might not exactly fit with DRF error messages since they are translated.)

It is obvious that none of my PUT fields are applied. So I have been digging around Django rest framework source code itself and found out serializer validation in ViewSet update() method continues to fail.

I doubt that because I PUT request not by JSON but by form-data using key-value pair so request.data is not properly validated.

However, I should contain multiple images in the request which means plain JSON would not work.

What would be the most clear solutions for this case?

Thank you.

Update

As Neil pointed out, I added print(self) at the first line of update() method of PostSerializer. However nothing printed out on my console.

I think this is due to my doupt above because perform_update() method which calls serializer update() method is called AFTER serializer is validated.

Therefore the main concept of my question could be narrowed to the followings.

  1. How should I fix requested data fields so that validation inside update() method of ModelViewSet could pass?
  2. Do I have to override update() method of ModelViewSet(not the one from ModelSerializer)?

Thanks again.

like image 850
Ihn Geun Kim Avatar asked Nov 24 '18 10:11

Ihn Geun Kim


People also ask

What is the difference between ModelSerializer and HyperlinkedModelSerializer?

The HyperlinkedModelSerializer class is similar to the ModelSerializer class except that it uses hyperlinks to represent relationships, rather than primary keys. By default the serializer will include a url field instead of a primary key field.

What is Serializers in Django REST framework?

Serializers in Django REST Framework are responsible for converting objects into data types understandable by javascript and front-end frameworks. Serializers also provide deserialization, allowing parsed data to be converted back into complex types, after first validating the incoming data.


2 Answers

First of all you need to set header:

Content-Type: multipart/form-data;

But maybe if you set form-data in postman, this header should be default.

You can't send images as a json data (unless you encode it to string and decode on server side to image eg. base64).

In DRF PUT by default requires all fields. If you want to set only partial fields you need to use PATCH.

To get around this and use PUT to update partial fields you have two options:

  • edit update method in viewset to partial update serializer
  • edit router to always call partial_update method in serializers which is more advanced

You can override viewset update method to always update serializer partial (changing only provided fields):

    def update(self, request, *args, **kwargs):
        partial = True # Here I change partial to True
        instance = self.get_object()
        serializer = self.get_serializer(instance, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        self.perform_update(serializer)

        return Response(serializer.data)

Add

rest_framework.parsers.MultiPartParser

to the main settings file to the REST_FRAMEWORK dict:

REST_FRAMEWORK = {
    ...
    'DEFAULT_PARSER_CLASSES': (
        'rest_framework.parsers.JSONParser',
        'rest_framework.parsers.MultiPartParser',
    )
}

Looking at your serializers it's weird that you don't get error from PostSerializer because you don't add "photos" field to Meta.fields tuple.

More advices from me in this case:

  • add required=False to your photos field (unless you want this to be required)
  • as wrote above add photos field to you Meta.fields tuple fields = ('title', 'content', 'photos',)
  • add default None value for your validated_data.pop('photos'), then check photos data is provided before loop.
like image 89
mon io Avatar answered Nov 10 '22 01:11

mon io


The solution is somewhat a mixture or @Neil and @mon's answers. However I'll straighten out a bit more.

Analysis

Right now Postman submits form data which contains 2 key-value pairs(Please refer to the photo which I uploaded in my original question). One is "photos" key field linked with multiple photo files and the other is "data" key field linked with one big chunk of 'JSON-like string'. Although this is a fair method POSTing or PUTting data along with files, DRF MultiPartParser or JSONParser won't parse these properly.

The reason why I got the error message was simple. self.get_serializer(instance, data=request.data, partial=partial method inside ModelViewSet(especially UpdateModelMixin) couldn't understand request.data part.

Currently request.data from submitted form data looks like below.

<QueryDict: { "photos": [PhotoObject1, PhotoObject2, ... ],
  "request": ["{'\n 'title': 'title test', \n 'content': 'content test'}",]
}>

Watch the "request" part carefully. The value is a plain string object.

However my PostSerializer expects the request.data to look something like below.

{ "photos": [{"image": ImageObject1, "post":1}, {"image": ImageObject2, "post":2}, ... ],
  "title": "test title",
  "content": "test content"
 }

Therefore, let's do some experiment and PUT some data in accordance with above JSON form. i.e

{ "photos": [{"image": "http://tny.im/gMU", "post": 1}],
  "title" : "test title",
  "content": "test content"
}

You'll get an error message as following.

"photos": [{"image": ["submitted data is not a file."]}]

Which means every data is submitted properly but the image url http://tny.im/gMU is not a file but string.

Now the reason of this whole problem became clear. It is the Parser which needs to be fixed so that the Serializer could understand submitted form data.

Solution

1. Write new parser

New parser should parse 'JSON-like' string to proper JSON data. I've borrowed the MultipartJSONParser from here.

What this parser does is simple. If we submit 'JSON-like' string with the key 'data', call json from rest_framework and parse it. After that, return the parsed JSON with requested files.

class MultipartJsonParser(parsers.MultiPartParser):
    # https://stackoverflow.com/a/50514022/8897256
    def parse(self, stream, media_type=None, parser_context=None):
        result = super().parse(
            stream,
            media_type=media_type,
            parser_context=parser_context
        )
        data = {}
        data = json.loads(result.data["data"])
        qdict = QueryDict('', mutable=True)
        qdict.update(data)
        return parsers.DataAndFiles(qdict, result.files)

2. Redesign Serializer

Official DRF doc suggests nested serializers to update or create related objects. However we have a significant drawback that InMemoryFileObject cannot be translated into a proper form which the serializer expects. To do this, we should

  1. Override updatemethod of ModelViewSet
  2. Pop out 'photos' key-value pair from request.data
  3. Translate popped 'photos' pairs into the list of dictionaries containing 'image' and 'post' keys.
  4. Append the result to request.data with a key name 'photos'. This is because our PostSerializer expects the key name to be 'photos'.

However basically request.data is a QuerySet which is immutable by default. And I am quite skeptical if we must force-mutate the QuerySet. Therefore, I'll rather commission PostImage creation process to update() method of the ModelViewSet. In this case, we don't need to define nested serializer anymore.

Simply just do this:

class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = '__all__'


class PostImageSerializer(serializer.ModelSerializer):
    class Meta:
        model = PostImage
        fields = '__all__'

3. Override update() method from ModelViewSet

In order to utilize our Parser class, we need to explicitly designate it. We will consolidate PATCH and PUT behaviour, so set partial=True. As we saw earlier, Image files are carried with the key 'photos' so pop out the values and create each Photo instance.

Finally, thanks to our newly designed Parser, plain 'JSON-like' string would be transformed into regular JSON data. So just simply put eveything into serializer_class and perform_update.

class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    # New Parser
    parser_classes = (MultipartJsonParser,)

    def update(self, request, *args, **kwargs):
        # Unify PATCH and PUT
        partial = True
        instance = self.get_object()

        # Create each PostImage
        for photo in request.data.pop("photos"):
            PostImage.objects.create(post=instance, image=photo)

        serializer = self.get_serializer(instance, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        # Do ViewSet work.
        self.perform_update(serializer)
        return Response(serializer.data)

Conclusion

The solution works, but I'm not sure this is the cleanest way of saving foreign key related models. I get a strong feeling that it is the serializer that should save the related model. Just as the doc stated, data other than files are saved that way. If somebody could tell me more subtle way to do this, I would be deeply appreciated.

like image 28
Ihn Geun Kim Avatar answered Nov 10 '22 00:11

Ihn Geun Kim