Writable nested serializer with existing objects u

2019-02-02 11:37发布

问题:

Consider a Listing model that has an associated Category. I want to create a new Listing for an existing Category by doing a POST with data: {"title": "myapp", "category": {"name": "Business"}}, where title is the title of the Listing that should be created, and Business is the name of an existing category to use for this new listing.

When I try to make such a request and instantiate the ListingSerializer for this, I get an error indicating that the Category name must be unique - I don't want to create a new Category, but use an existing one instead. I've tried setting the validators on the category field to [], but that didn't change the behavior.

I can use a SlugRelatedField, but that forces my request data to look more like {"title": "myapp", "category": "Business"}, which isn't what I want. I tried using the source argument for the SlugRelatedField to specify a nested relationship, but that didn't work either:

category = serializers.SlugRelatedField(
        slug_field='category.name',
        queryset=models.Category.objects.all()
    )

yields:

  "category": [
    "Object with name={'name': 'Business'} does not exist."
  ]

models.py:

import django.contrib.auth
from django.db import models
from django.conf import settings

class Profile(models.Model):
    display_name = models.CharField(max_length=255)
    user = models.OneToOneField(settings.AUTH_USER_MODEL)

class Category(models.Model):
    name = models.CharField(max_length=50, unique=True)
    description = models.CharField(max_length=200)

class Listing(models.Model):
    title = models.CharField(max_length=50, unique=True)
    category = models.ForeignKey(Category, related_name='listings', null=True)
    owners = models.ManyToManyField(
        Profile,
        related_name='owned_listings',
        db_table='profile_listing',
        blank=True
    )

serializers.py:

import logging
import django.contrib.auth
from rest_framework import serializers
import myapp.models as models

logger = logging.getLogger('mylogger')

class ShortUserSerializer(serializers.ModelSerializer):
    class Meta:
        model = django.contrib.auth.models.User
        fields = ('username', 'email')

class ProfileSerializer(serializers.ModelSerializer):
    user = ShortUserSerializer()
    class Meta:
        model = models.Profile
        fields = ('user', 'display_name')
        read_only = ('display_name',)

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Category
        fields = ('name', 'description')
        read_only = ('description',)


class ListingSerializer(serializers.ModelSerializer):
    owners = ProfileSerializer(required=False, many=True)
    # TODO: how to indicate that this should look for an existing category?
    category = CategorySerializer(required=False, validators=[])

    class Meta:
        model = models.Listing
        depth = 2

    def validate(self, data):
        logger.info('inside ListingSerializer validate')
        return data

    def create(self, validated_data):
        logger.info('inside ListingSerializer.create')
        # not even getting this far...

views.py:

import logging

from django.http import HttpResponse
from django.shortcuts import get_object_or_404

import django.contrib.auth

from rest_framework import viewsets
from rest_framework.response import Response

import myapp.serializers as serializers
import myapp.models as models


# Get an instance of a logger
logger = logging.getLogger('mylogger')

class CategoryViewSet(viewsets.ModelViewSet):
    queryset = models.Category.objects.all()
    serializer_class = serializers.CategorySerializer

class UserViewSet(viewsets.ModelViewSet):
    queryset = django.contrib.auth.models.User.objects.all()
    serializer_class = serializers.ShortUserSerializer

class ProfileViewSet(viewsets.ModelViewSet):
    queryset = models.Profile.objects.all()
    serializer_class = serializers.ProfileSerializer

class ListingViewSet(viewsets.ModelViewSet):
    logger.info('inside ListingSerializerViewSet')
    queryset = models.Listing.objects.all()
    serializer_class = serializers.ListingSerializer

Full example: https://github.com/arw180/drf-example

回答1:

This isn't ideal, but I did find a solution that solved my problem (I'm waiting to accept it as the answer, hoping someone else can do better). There are two parts:

First, use the partial=True argument when initializing the ListingSerializer ( http://www.django-rest-framework.org/api-guide/serializers/#partial-updates). Then use the serializer's validate method to get the actual model instance corresponding to the input data.

Second, explicitly remove the validators for the name field in the CategorySerializer. This is especially lousy because it effects more than just the ListingSerializer.

Leaving out either piece will result in the validation errors being thrown at the time the serializer is instantiated.

modifications to views.py:

class ListingViewSet(viewsets.ModelViewSet):
    queryset = models.Listing.objects.all()
    serializer_class = serializers.ListingSerializer

    def create(self, request):
        serializer = serializers.ListingSerializer(data=request.data,
            context={'request': request}, partial=True)
        if not serializer.is_valid():
            logger.error('%s' % serializer.errors)
            return Response(serializer.errors,
                  status=status.HTTP_400_BAD_REQUEST)

        serializer.save()
        return Response(serializer.data, status=status.HTTP_201_CREATED)

modifications to serializers.py:

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Category
        fields = ('name', 'description')
        read_only = ('description',)
        # also need to explicitly remove validators for `name` field
        extra_kwargs = {
            'name': {
                'validators': []
            }
        }

class ListingSerializer(serializers.ModelSerializer):
    owners = ProfileSerializer(required=False, many=True)
    category = CategorySerializer(required=False)
    class Meta:
        model = models.Listing
        depth = 2

    def validate(self, data):
        # manually get the Category instance from the input data
        data['category'] = models.Category.objects.get(name=data['category']['name'])
        return data

    def create(self, validated_data):
        title = validated_data['title']

        listing = models.Listing(title=validated_data['title'],
            category=validated_data['category'])

        listing.save()

        if 'owners' in validated_data:
            logger.debug('owners: %s' % validated_data['owners'])
            for owner in validated_data['owners']:
                print ('adding owner: %s' % owner)
                listing.owners.add(owner)
        return listing

I'll wait a bit to accept this as the answer in case someone can come up with a better solution (like how to make the source argument work properly with a SlugRelatedField) - I have a working example using the solution above at https://github.com/arw180/drf-example if you want to experiment. I'd also love to hear comments regarding why the extra_kwargs stuff is necessary in the CategorySerializer - why isn't instantiating it like this: category = CategorySerializer(required=False, validators=[]) sufficient (in the ListingSerializer)? UPDATE: I believe that doesn't work because the unique validator is added automatically from the DB constraints and run regardless of any explicit validators set here, as explained in this answer: http://iswwwup.com/t/3bf20dfabe1f/python-order-of-serializer-validation-in-django-rest-framework.html



回答2:

Turn CategorySerializer.create into an update_or_create method on name

class CategorySerializer(serializers.ModelSerializer):

    ...

    # update_or_create on `name`
    def create(self, validated_data):
        try:
            self.instance = Category.objects.get(name=validated_data['name'])
            self.instance = self.update(self.instance, validated_data)
            assert self.instance is not None, (
                '`update()` did not return an object instance.'
            )
            return self.instance
        except Category.DoesNotExist:
            return super(CategorySerializer, self).create(validated_data)

    ...

I recommend looking at the DRF source when ever you need to create custom functionality.

Related question answered by the creator of DRF: django-rest-framework 3.0 create or update in nested serializer

Edit

So I was still in the DRF 2 mindset where nested writable fields are handled automatically. You can read up on the subject here: http://www.django-rest-framework.org/topics/3.0-announcement/

I've tested the following code and it works:

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        ...
        extra_kwargs = {
            'name': {'validators': []},
            'description': {'required': False},
        }


class ListingSerializer(serializers.ModelSerializer):

    ...

    def update_or_create_category(self, validated_data):
        data = validated_data.pop('category', None)
        if not data:
            return None

        category, created = models.Category.objects.update_or_create(
            name=data.pop('name'), defaults=data)

        validated_data['category'] = category

    def create(self, validated_data):
        self.update_or_create_category(validated_data)
        return super(ListingSerializer, self).create(validated_data)

    def update(self, instance, validated_data):
        self.update_or_create_category(validated_data)
        return super(ListingSerializer, self).update(instance, validated_data)

Edit

The correct way of using SlugRelatedField is like this, in case you were wondering:

class ListingSerializer(serializers.ModelSerializer):

    ...

    # slug_field should be 'name', i.e. the name of the field on the related model
    category = serializers.SlugRelatedField(slug_field='name',
        queryset=models.Category.objects.all())

    ...


回答3:

I had similar problem: I needed to check if nested serializer (CategorySerializer) exists if yes to use it and if not - create it from nesting serializer (ListingSerializer). The solution of @demux totally worked for me only if I didn't use custom validation for a field in nested serializer (the field by which I would check from nesting serializer if this instance exists). So I added create() method to nested serializer and @demux custom update_or_create_category(), create(), update() for ListingSerializer worked perfectly.

class CategorySerializer(serializers.ModelSerializer):

    class Meta:
    model = Category
    ...

    def create(self, validated_data):
        if Category.objects.filter(name=self.validated_data['name']).exists():
            raise serializers.ValidationError("This category name already exists")
        return Category.objects.create(**validated_data)