DRF – AttributeError when using ModelSerializer to save manyToMany relation with 'though' table

Question:

I am building a Django Rest Framework project and I’m encountering an attribute error that I cannot seem to resolve. Specifically, I’m getting the following error when I try to serialize a Sale object:

Got AttributeError when attempting to get a value for field `product` on serializer `SaleItemSerializer`.
The serializer field might be named incorrectly and not match any attribute or key on the `Product` instance.
Original exception text was: 'Product' object has no attribute 'product'.

I’ve defined my models and serializers as follows:

# Models
class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    cost = models.DecimalField(max_digits=10, decimal_places=2)
    description = models.TextField()
    image = models.ImageField(upload_to='images/')
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


class Sale(models.Model):
    products = models.ManyToManyField(Product, related_name='sales', through='SaleItem')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


class SaleItem(models.Model):
    sale = models.ForeignKey(Sale, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.IntegerField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)


# Serializers
class SaleItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = SaleItem
        fields = ['product', 'quantity', 'value']


class SaleSerializer(serializers.ModelSerializer):
    products = SaleItemSerializer(many=True)

    class Meta:
        model = Sale
        fields = '__all__'

    def create(self, validated_data):
        sale_items_data = validated_data.pop('products')
        sale = Sale.objects.create(**validated_data)
        for sale_item in sale_items_data:
            SaleItem.objects.create(sale=sale, **sale_item) # The erro
        return sale
        

The code I’m using for the serializers is basically this documentation example: DRF Writable Nested Serializers the only difference is that SaleItem is an Through table for the manyToMany relation instead of a "normal" table.

I’ve tried changing the SaleItem object creation line to passing the data back to the serializer but that way the serializer as never valid and changing it to be valid for creation breaks the field for the SaleSerializer.

I suspect the issue is with the SaleItemSerializer or the SaleSerializer create method, but I cannot figure out what’s causing the attribute error.
The only hope I have right now is to write an normal Serializer and the whole logic but i really wanted to use the whole framework potential.

Asked By: Andeen

||

Answers:

Basically because you are trying to serialize a Product using a SaleItem model. And,Product has no field called product.

You can, for instance, add a related name to SaleItem sales field:

models.py

class SaleItem(models.Model):
    sale = models.ForeignKey(
        Sale,
        on_delete=models.CASCADE,
        related_name='items'
    )
    ...

And, use this to access SaleItem(and consequently the Product) related with that Sale instance together with SerializerMethodField:

class SaleItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = SaleItem
        fields = ['product', 'quantity', 'price']

class SaleSerializer(serializers.ModelSerializer):
    products = serializers.SerializerMethodField()

    class Meta:
        model = Sale
        fields = '__all__'

    def get_products(self, instance):
        qs = instance.items.all()
        serializer = SaleItemSerializer(qs, many=True)
        return serializer.data

Also, note that there is no value field in SaleItem, it is called price.

Regarding creating objects, that will heavily depend on the payload you are sending, here is a simple example, assuming the products exists on the database and with no treatment otherwise:

payload

{
    "sale_items":
    [
        {
            "product": 1,
            "quantity": 1,
            "price": 123.12
        },
        {
            "product": 2,
            "quantity": 2,
            "price": 234.23
        },
        {
            "product": 1,
            "quantity": 1,
            "price": 456.45
        }
    ]
}

Then we can override the create method on the ViewSet, and pass data via extra context to the serializer:

views.py

class SalesViewSet(ModelViewSet):
    queryset = Sale.objects.all()
    serializer_class = SaleSerializer

    def create(self, request, *args, **kwargs):
        sale_items = request.data.pop('sale_items')
        serializer = self.get_serializer(data=request.data, context={'sale_items': sale_items})
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self. get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

serializers.py

class SaleSerializer(serializers.ModelSerializer):
    ...
    
    def get_products(self, instance):
        ...

    def create(self, validated_data):
        sale_items = self.context.get('sale_items')
        instance = Sale.objects.create(**validated_data)

        for item in sale_items:
            product_instance = Product.objects.get(id=item['product'])
            item['product'] = product_instance
            item['sale'] = instance
            SaleItem.objects.create(**item)

        return (instance)
Answered By: Niko

Based on Niko’s answer I managed to get id done containing all the logic on the serializers.
As I stated in the comment and Niko after modified his answer to fit around, creation was root of the whole problem and SerializerMethodField is read_only so it couldn’t solve my problem completely, so I tried to mix up the two answers by creating an MethodField Subclass who’s not read_only, and it worked pretty well:

class ReadWriteSerializerMethodField(serializers.SerializerMethodField):
    def __init__(self, method_name=None, **kwargs):
        self.method_name = method_name
        kwargs["source"] = "*"
        super(serializers.SerializerMethodField, self).__init__(**kwargs)

    def to_internal_value(self, data):
        return {self.field_name: data}

With this we can still access the products data inside the create method, but now using the SaleItemSerializer itself to save the subitems

class SaleSerializer(serializers.ModelSerializer):
    products = ReadWriteSerializerMethodField()

    class Meta:
        model = Sale
        fields = "__all__"

    def create(self, validated_data):
        sale_items_data = validated_data.pop("products")
        sale = Sale.objects.create(**validated_data)
        for sale_item in sale_items_data:
            sale_item["sale"] = sale.id
            serializer = SaleItemSerializer(data=sale_item)
            serializer.is_valid(raise_exception=True)
            serializer.save()
        return sale

    def get_products(self, instance):
        qs = instance.items.all()
        serializer = SaleItemSerializer(qs, many=True)
        return serializer.data

This way I was able to fix the whole issue and kept everything inside the serializers layer to easily add new things such as a size parameter to the SaleItemSerializer which is a write_only param to specify the stock of the product which the quantity must be decreased as it is being sold.

Answered By: Andeen