Updating an ManyToMany field with Django rest
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
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):
return not movie_id
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)