Updating an ManyToMany field with Django rest

2019-04-14 01:56发布

问题:

I'm trying to set up this API so I can use a "PUT" to update one/many "TAG"s on an item in the model "MOVIE". Tags is an M2M on MOVIE. I am posting on the PK of the item in movie.

My httpie work (returns a 200OK) but nothing gets created. When I post the whole JSON (using fetch) it just creates the TAG but no M2M relationship on MOVIE (link).

httpie

http -f PUT http://localhost:8000/api/Edit/3/ tag:='{"name": "TEST"}'

Models.py

class Tag(models.Model):
    name = models.CharField("Name", max_length=5000, blank=True)
    taglevel = models.IntegerField("Tag level", null=True, blank=True)

class Movie(models.Model):
    title = models.CharField("Whats happening?", max_length=10000, blank=True)
    tag = models.ManyToManyField('Tag', blank=True)

Serializers.py

class Tag1Serializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ('name',)

class EditSerializer(serializers.ModelSerializer):
    tag = Tag1Serializer(many=True, read_only=True)
    class Meta:
            model = Movie
            fields = ('title', 'tag', 'info', 'created',  'status')

    def update(self, instance, validated_data):
        import pdb; pdb.set_trace()
        tags_data = validated_data.pop('tag')
        for tag_data in tags_data:
            tag_qs = Tag.objects.filter(name__iexact=tag_data['name'])
            if tag_qs.exists():
                tag = tag_qs.first()
            else:
                tag = Tag.objects.get(**tag_data)
            instance.tag.add(tag)
        return movie

Views.py

class MovieViewSet(viewsets.ModelViewSet):
    queryset = Movie.objects.all()
    serializer_class = MovieSerializer

Error:

Traceback
    tags_data = validated_data.pop('tag')
KeyError: 'tag'

回答1:

Okay. I promised to come back when I figured it out. This probably isn't completely data-safe as django hasn't yet validated the incoming data so I'm making some assumptions in my relative ignorance of python and django. If anyone who's smarter than I am can expand this answer, please hit me up.

note: I am a firm adherent to the Clean Code standard of writing software. It has served me well over the years. I know it's not meta for Python code, but without small, tightly focused methods, it felt sloppy.

Views.py

You have to clear the related objects yourself before you can add new ones if you can't have dupes. It's the only way I could find to delete m2m reliably for my use case. I needed to ensure there were no duplicates and I expect an atomic model. Your mileage may vary.

class MovieViewSet(viewsets.ModelViewSet):
    queryset = Movie.objects.all()
    serializer_class = MovieSerializer

    def update(self, requiest, *args, **kwargs):
        movie = self.get_object()
        movie.tags.clear()
        return super().update(request, *args, **kwargs)

Serializers.py

You have to hook the to_internal_value serializer method to get the data you need since the validator ignores m2m fields.

class Tag1Serializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ('name',)

class EditSerializer(serializers.ModelSerializer):
    tag = Tag1Serializer(many=True, read_only=True)
    class Meta:
        model = Movie
        fields = ('title', 'tag', 'info', 'created',  'status')

    def to_internal_value(self, data):
        movie_id = data.get('id')
        #if it's new, we can safely assume there's no related objects.
        #you can skip this bit if you can't make that assumption.
        if self.check_is_new_movie(movie_id):
            return super().to_internal_value(data)
        #it's not new, handle relations and then let the default do its thing
        self.save_data(movie_id, data)
        return super().to_internal_value(data)

    def check_is_new_movie(self, movie_id):
        if not movie_id:
            return True
        return False

    def save_data(self, movie_id, data):
        movie = Movie.objects.filter(id=movie_id).first()
        #the data we have is raw json (string).  Listify converts it to python primitives.
        tags_data = Utils.listify(data.get('tags'))

        for tag_data in tags_data:
            tag_qs = Tag.objects.filter(name__iexact=tag_data['name'])
            #I am also assuming that the tag already exists.  
            #If it doesn't, you have to handle that.
            if tag_qs.exists():
                tag = tag_qs.first()
                movie.tags.add(tag)

Utils.py

from types import *
class Utils:
#python treats strings as iterables; this utility casts a string as a list and ignores iterables
def listify(arg):
    if Utils.is_sequence(arg) and not isinstance(arg, dict):
        return arg
    return [arg,]

 def is_sequence(arg):
     if isinstance(arg, str):
         return False
     if hasattr(arg, "__iter__"):
         return True

Test.py

Adjust urls as necessary for this to work. The logic should be correct but may need some tweaking to correctly reflect your models and serializers. It's more complex because we have to create the json data for the APIClient to send with the put request.

class MovieAPITest(APITestCase):
    def setUp(self):
        self.url = '/movies/'

    def test_add_tag(self):
        movie = Movie.objects.create(name="add_tag_movie")
        tag = Tag.objects.create(name="add_tag")
        movie_id = str(movie.id)
        url = self.url + movie_id + '/'

        data = EditSerializer(movie).data
        data.update({'tags': Tag1Serializer(tag).data})
        json_data = json.dumps(data)

        self.client.put(url, json_data, content_type='application/json')
        self.assertEqual(movie.tags.count(), 1)


回答2:

There's no put method on the drf model serializer class so nothing calls put(self, validated_data). Use: update(self, instance, validated_data) instead. Docs on saving instances: http://www.django-rest-framework.org/api-guide/serializers/#saving-instances

Also neither does the django model queryset has it: Movie.objects.put and Tag.objects.put. You have the instance argument for the movie already and if you are querying tags perhaps you need Tag.objects.get or Tag.objects.filter? QuerySet API Reference: https://docs.djangoproject.com/en/1.10/ref/models/querysets/#queryset-api

After verifying that the serializer method is called, maybe you should write a test for it using drf test api client to be able to easily spot errors: http://www.django-rest-framework.org/api-guide/testing/#apiclient

serializers.py

class TagSerializer(serializers.ModelSerializer):
    class Meta:
        model = Tag
        fields = ('name', 'taglevel', 'id')


class MovieSerializer(serializers.ModelSerializer):
    tag = TagSerializer(many=True, read_only=False)

    class Meta:
        model = Movie
        ordering = ('-created',)
        fields = ('title', 'pk', 'tag')

    def update(self, instance, validated_data):
        tags_data = validated_data.pop('tag')
        instance = super(MovieSerializer, self).update(instance, validated_data)

        for tag_data in tags_data:
            tag_qs = Tag.objects.filter(name__iexact=tag_data['name'])

            if tag_qs.exists():
                tag = tag_qs.first()
            else:
                tag = Tag.objects.create(**tag_data)

            instance.tag.add(tag)

        return instance

tests.py

class TestMovies(TestCase):
    def test_movies(self):
        movie = Movie.objects.create(title='original title')

        client = APIClient()
        response = client.put('/movies/{}/'.format(movie.id), {
            'title': 'TEST title',
            'tag': [
                {'name': 'Test item', 'taglevel': 1}
            ]
        }, format='json')

        self.assertEqual(response.status_code, 200, response.content)
        # ...add more specific asserts


回答3:

See here for my clear example if wanting to use something generic but simple in a view function:

https://stackoverflow.com/a/55043187/5626788