dimmg
dimmg

Reputation: 3815

Django-Rest-Framework. Updating nested object

I'm having a problem of updating a nested object.

So I have a model which structure is similar to this one:

class Invoice(models.Model):
    nr = models.CharField(max_length=100)
    title = models.CharField(max_length=100)

class InvoiceItem(models.Model):
    name = models.CharField(max_length=100)
    price = models.FloatField()
    invoice = models.ForeignKey(Invoice, related_name='items')

I need to create child objects from parent, and what I mean by that, is to create InvoiceItems directly when creating an Invoice object. For this purpose, I've wrote the following serializers:

class InvoiceItemSerializer(serializers.ModelSerializer):
    invoice = serializers.PrimaryKeyRelatedField(queryset=Invoice.objects.all(), required=False)
    class Meta:
        model = InvoiceItem


class InvoiceSerializer(serializers.ModelSerializer):
    items = InvoiceItemSerializer(many=True)

    class Meta:
        model = Invoice

    def create(self, validated_data):
        items = validated_data.pop('items', None)
        invoice = Invoice(**validated_data)
        invoice.save()
        for item in items:
            InvoiceItem.objects.create(invoice=invoice, **item)
        return invoice

Up till now, the create/read/delete methods work perfectly, except the update. I think the below logic should be correct, but it misses something.

def update(self, instance, validated_data):
    instance.nr = validated_data.get('nr', instance.nr)
    instance.title = validated_data.get('title', instance.title)
    instance.save()

    # up till here everything is updating, however the problem appears here.
    # I don't know how to get the right InvoiceItem object, because in the validated
    # data I get the items queryset, but without an id.

    items = validated_data.get('items')
    for item in items:
        inv_item = InvoiceItem.objects.get(id=?????, invoice=instance)
        inv_item.name = item.get('name', inv_item.name)
        inv_item.price = item.get('price', inv_item.price)
        inv_item.save()

    return instance

Any help would be really appreciated.

Upvotes: 48

Views: 38683

Answers (9)

lautaro dapin
lautaro dapin

Reputation: 61

I would add transaction and use some util django methods

from django.db import transaction

def update(self, instance, validated_data):
    with transaction.atomic():
        items = validated_data.pop('items', None)
        for key, value in validated_data.items():
            setattr(instance, key, value)
        instance.save()

        for item in items:
            inv_item, created = InvoiceItem.objects.update_or_create(id=item['id'], invoice=instance, defaults={**item})

        return instance

Upvotes: 1

Dr Sheldon
Dr Sheldon

Reputation: 186

The drf-writable-nested package provides writable nested model serializer which allows to create/update models with nested related data.

https://github.com/beda-software/drf-writable-nested

Upvotes: 0

Preston Badeer
Preston Badeer

Reputation: 2717

All of these solutions seemed too complex or too specific for me, I ended up using code from the tutorial here which was incredibly simple and reusable:

from rest_framework import serializers
from django.contrib.auth import get_user_model
from myapp.models import UserProfile


# You should already have this somewhere
class UserProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = UserProfile
        fields = ['nested', 'fields', 'you', 'can', 'edit']


class UserSerializer(serializers.ModelSerializer):
    # CHANGE "userprofile" here to match your one-to-one field name
    userprofile = UserProfileSerializer()

    def update(self, instance, validated_data):
        # CHANGE "userprofile" here to match your one-to-one field name
        if 'userprofile' in validated_data:
            nested_serializer = self.fields['userprofile']
            nested_instance = instance.userprofile
            nested_data = validated_data.pop('userprofile')

            # Runs the update on whatever serializer the nested data belongs to
            nested_serializer.update(nested_instance, nested_data)

        # Runs the original parent update(), since the nested fields were
        # "popped" out of the data
        return super(UserSerializer, self).update(instance, validated_data)

EDIT: Bugfix, I added a check for the nested field's existence before attempting to update it.

Upvotes: 22

Ketan Shukla
Ketan Shukla

Reputation: 113

I think Vitor Hugo Morales's answer is great and would like to contribute one cent of mine by looping through the keys to assign each field in the object to that in validated data rather than hard-coding it the way he has done. For example,

def update_product_items(self, instance, validated_data):
    # get the nested objects list
    product_items = validated_data.pop('products')
    # get all nested objects related with this instance and make a dict(id, object)
    product_items_dict = dict((i.id, i) for i in instance.products.all())

    for item_data in product_items:
        if 'id' in item_data:
            # if exists id remove from the dict and update
            product_item = product_items_dict.pop(item_data['id'])
            # remove id from validated data as we don't require it.
            item_data.pop('id')
            # loop through the rest of keys in validated data to assign it to its respective field
            for key in item_data.keys():
                setattr(product_item,key,item_data[key])

            product_item.save()
        else:
            # else create a new object
            ProductItem.objects.create(product=instance, **item_data)

    # delete remaining elements because they're not present in my update call
    if len(product_items_dict) > 0:
        for item in product_items_dict.values():
            item.delete()

Upvotes: 9

CuriousGeorge
CuriousGeorge

Reputation: 491

Try this.

from rest_framework.utils import model_meta

class InvoiceSerializer(serializers.ModelSerializer):
    invoice_item=InvoiceItemSerializer(many=True,required=False)

    field_map={"invoice_item" : { "model":  models.InvoiceItem
                                   "pk_field" : "id"}}    



    class Meta:
        model = models.Invoice
        fields = '__all__'

    def create(self, validated_data):
        extra_data={}
        for key in self.field_map.keys():
            extra_data[key]=validated_data.pop(key,[])

        # create invoice
        invoice = models.Invoice.objects.create(**validated_data)

        for key in extra_data.keys():
            for data in extra_data[key]:
                self.field_map[key]["model"].objects.create(invoice=invoice,**data)

        return invoice

    def _update(self,instance,validated_data):
        #drf default implementation
        info = model_meta.get_field_info(instance)

        for attr, value in validated_data.items():
            if attr in info.relations and info.relations[attr].to_many:
                field = getattr(instance, attr)
                field.set(value)
            else:
                setattr(instance, attr, value)
        instance.save()
        return instance

    def update(self,instance,validated_data):

        extra_data={}
        for key in self.field_map.keys():
            extra_data[key]=validated_data.pop(key,[])

        instance=self._update(instance,validated_data)

        for key in extra_data.keys():
            for data in extra_data[key]:

                id=data.get(self.field_map[key]["pk_field"],None)
                if id:
                    try:
                        related_instance=self.field_map[key]["model"].objects.get(id=id)
                    except:
                        raise
                    self._update(related_instance,data)
                else:
                    self.field_map[key]["model"].objects.create(**data)

        return instance    

Upvotes: 0

Vitor Hugo Morales
Vitor Hugo Morales

Reputation: 325

In my case I wish to update all list of nested objects even if they're deleted.

I don't want to in every nested object delete, call the nested Model DELETE method; just update entire object and your nested object list.

For this implementation: 1-Product has N-ProductItems

def update_product_items(self, instance, validated_data):
    # get the nested objects list
    product_items = validated_data.pop('products')
    # get all nested objects related with this instance and make a dict(id, object)
    product_items_dict = dict((i.id, i) for i in instance.products.all())

    for item_data in product_items:
        if 'id' in item_data:
            # if exists id remove from the dict and update
            product_item = product_items_dict.pop(item_data['id'])

            product_item.quantity = item_data['quantity']
            product_item.size_pmg = item_data['size_pmg']
            product_item.size_number = item_data['size_number']
            product_item.color = item_data['color']
            product_item.save()
        else:
            # else create a new object
            ProductItem.objects.create(product=instance, **item_data)

    # delete remaining elements because they're not present in my update call
    if len(product_items_dict) > 0:
        for item in product_items_dict.values():
            item.delete()

Upvotes: 4

dimmg
dimmg

Reputation: 3815

This is the way I've accomplished the task:

I've added an id field to the InvoiceItemSerializer

class InvoiceItemSerializer(serializers.ModelSerializer):
    ...
    id = serializers.IntegerField(required=False)
    ...

And the update method for the InvoiceSerializer

def update(self, instance, validated_data):
    instance.nr = validated_data.get('nr', instance.nr)
    instance.title = validated_data.get('title', instance.title)
    instance.save()

    items = validated_data.get('items')

    for item in items:
        item_id = item.get('id', None)
        if item_id:
            inv_item = InvoiceItem.objects.get(id=item_id, invoice=instance)
            inv_item.name = item.get('name', inv_item.name)
            inv_item.price = item.get('price', inv_item.price)
            inv_item.save()
        else:
            InvoiceItem.objects.create(account=instance, **item)

    return instance

Also in the create method I'm popping the id if it is passed.

Upvotes: 57

FACode
FACode

Reputation: 1061

Try

def update(self, instance, validated_data):
    instance.nr = validated_data.get('nr', instance.nr)
    instance.title = validated_data.get('title', instance.title)
    instance.save()


    items = validated_data.get('items')
    for item in items:
        inv_item = InvoiceItem.objects.get(invoice=instance, pk=item.pk)
        inv_item.name = item.get('name', inv_item.name)
        inv_item.price = item.get('price', inv_item.price)
        inv_item.invoice = instance
        inv_item.save()

    instance.save()
    return instance

Upvotes: 0

djq
djq

Reputation: 15286

I came across the same problem recently. The way I addressed it was to force the id to be a required field:

class MySerializer(serializers.ModelSerializer):

    class Meta:
        model = MyModel
        fields = ('id', 'name', 'url', )
        extra_kwargs = {'id': {'read_only': False, 'required': True}}

This way I was able to retrieve the correct instance and update it

Upvotes: 10

Related Questions