Django REST Framework: make a serializer behave different for request payload vs response body?

Viewed 325

I'm trying to come up with an easy way to make my serializers and views work the way I want to, while preventing a bunch of manual boilerplate.

My simplified model:

class Character(models.Model):
    name = models.CharField(max_length=255)
    location = models.ForeignKey(Location, on_delete=models.SET_NULL, blank=True, null=True)
    factions = models.ManyToManyField(Faction, blank=True)

So: character can have one location, and multiple factions.

Now, when I GET a character, I'd like those submodels (Location and Faction) to be expanded to their full representation, so my serializer looks like this:

class CharacterSerializer(serializers.ModelSerializer):
    location = LocationSerializer(read_only=True)
    factions = FactionSerializer(many=True, read_only=True)

    class Meta:
        model = Character
        fields = "__all__"

And this works perfectly fine so far. The thing is, when I POST or PUT a character, I'd like to just send the id(s).. but still get the full location and faction objects back in the response.

In other words, when I create a character with a payload like this:

{"name":"Saga","location":1,"factions":[1]}

I'd like the response to look like this:

{
  "id": 1,
  "location": {
    "id": 1,
    "name": "Location 1"
  },
  "factions": [
    {
      "id": 1,
      "name": "Faction 1"
    }
  ],
  "name": "Saga"
}

Is this possible at all without overriding the create and update methods of my ModelViewSet subclass? I was hoping I could slightly modify the serializer itself to only apply those location and factions field serializers on the response, not on the request.

2 Answers

You can override the create and update methods of the serializer to work with primary keys.

This thus means that we first pop the data from the validated_data and then work with the remaining data to create/update an object. We can then update the many-to-many relation by using .set(…) and .add(…):

class CharacterSerializer(serializers.ModelSerializer):
    location = LocationSerializer(read_only=True)
    factions = FactionSerializer(many=True, read_only=True)

    def create(self, validated_data):
        location_data = validated_data.pop('location')
        factions_data = validated_data.pop('factions')
        character = Character.objects.create(
            **validated_data,
            location_id=location_data
        )
        character.factions.add(*factions_data)

    def update(self, instance, validated_data):
        location_data = validated_data.pop('location')
        factions_data = validated_data.pop('factions')
        instance.location_id = location_data
        result = super().update(instance, validated_data)
        instance.factions.set(faction_data)
        return result

    class Meta:
        model = Character
        fields = '__all__'

What I came up so far was to create my own ModelViewSet subclass:

class DualSerializerModelViewSet(viewsets.ModelViewSet):
    def create(self, request, *args, **kwargs):
        write_serializer = self.write_serializer_class(data=request.data)
        write_serializer.is_valid(raise_exception=True)
        created_object = self.perform_create(write_serializer)
        headers = self.get_success_headers(write_serializer.data)

        read_serializer = self.serializer_class(created_object)
        return Response(read_serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    def update(self, request, *args, **kwargs):
        partial = kwargs.pop("partial", False)
        instance = self.get_object()
        write_serializer = self.write_serializer_class(instance, data=request.data, partial=partial)
        write_serializer.is_valid(raise_exception=True)
        self.perform_update(write_serializer)

        if getattr(instance, "_prefetched_objects_cache", None):
            # If 'prefetch_related' has been applied to a queryset, we need to
            # forcibly invalidate the prefetch cache on the instance.
            instance._prefetched_objects_cache = {}

        read_serializer = self.serializer_class(instance)
        return Response(read_serializer.data)

It can be used as such:

class WriteCharacterSerializer(serializers.ModelSerializer):
    class Meta:
        model = Character
        fields = "__all__"


class CharacterSerializer(WriteCharacterSerializer):
    location = LocationSerializer(read_only=True)
    factions = FactionSerializer(many=True, read_only=True)


class CharacterController(DualSerializerModelViewSet):
    serializer_class = CharacterSerializer
    write_serializer_class = WriteCharacterSerializer
    queryset = Character.objects.all()

This works pretty great for my use case, maybe it helps someone else as well.

Related