pythondjangodjango-modelsdjango-rest-frameworkmanytomanyfield

django rest framework serializer.data throws error about attribute in manytomany relationship


I have the following models:

class MenuItem(models.Model):

    class Category(models.TextChoices):
        PIZZA = "pizza"
        SIDE = "side"
        OTHER = "other"
    
    name = models.CharField(max_length=40)
    description = models.TextField(max_length=150)
    price = models.FloatField(default=0.0)
    category = models.CharField(max_length=50, choices=Category.choices, default=Category.OTHER)

class Order(models.Model):

    class OrderStatus(models.TextChoices):
        NEW = "new"
        READY = "ready"
        DELIVERED = "delivered"

    customer = models.CharField(max_length=50)
    order_time = models.DateTimeField(auto_now_add=True)
    items = models.ManyToManyField(MenuItem, through='OrderItem')
    status = models.CharField(max_length=50, choices=OrderStatus.choices, default=OrderStatus.NEW)

class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    menu_item = models.ForeignKey(MenuItem, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField()

And the following serializers:

class MenuItemSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    name = serializers.CharField(max_length=40)
    description = serializers.CharField(max_length=150)
    price = serializers.FloatField(default=0.0)
    category = serializers.CharField(max_length=50)
    
    def create(self, validated_data):
        return MenuItem.objects.create(**validated_data)

class OrderItemSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    menu_item_id = serializers.PrimaryKeyRelatedField(queryset=MenuItem.objects.all(), source='menu_item', read_only=False)
    quantity = serializers.IntegerField(min_value=0)

class OrderSerializer(serializers.Serializer):
    id = serializers.IntegerField(read_only=True)
    customer = serializers.CharField(max_length=50)
    order_time = serializers.DateTimeField(read_only=True)
    items = OrderItemSerializer(many=True)

    def create(self, validated_data):
        order_items_data = validated_data.pop('items')
        order = Order.objects.create(**validated_data)
        for order_item_data in order_items_data:
            quantity = order_item_data.pop('quantity')
            menu_item = order_item_data.pop('menu_item')
            OrderItem.objects.create(order=order, quantity=quantity, menu_item=menu_item)
        return order

And then in views:

@csrf_exempt
def order(request):
    if request.method == 'POST':
        data = JSONParser().parse(request)
        serializer = OrderSerializer(data=data)
        if serializer.is_valid():
            serializer.save()
            return JsonResponse(serializer.data, status=201)
        print(serializer.errors)
        return JsonResponse(serializer.errors, status=400)

An example request looks like:

echo -n '{"customer": "John Doe", "items": [{"menu_item_id": 1, "quantity": 2}, {"menu_item_id": 2, "quantity": 1}]}' | http POST http://127.0.0.1:8000/order

When serializer.data is called an error is thrown:

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

So its gone through the serializer.save() but errors on serializer.data. Can't work out why it can't get a value menu_item_id as it should be related to the id on MenuItem with the foreign key.


Solution

  • The issue here is that you're accidentally applying the OrderItemSerializer to the wrong model. In OrderSerializer the items field is defined as an OrderItemSerializer, but the items field in the model refers to the MenuItem model. The OrderItem model is only the through-model of the M2M relationship here.

    Here are the updated models and serializers that you need.

    models.py

    class OrderItem(models.Model):
        order = models.ForeignKey(Order, related_name="order_items", on_delete=models.CASCADE)
        menu_item = models.ForeignKey(MenuItem, on_delete=models.CASCADE)
        quantity = models.PositiveIntegerField()
    

    Notice that I added related_name="order_items" to the FK field that relates it to the Order model, so that we can reference this data model easily from the OrderSerializer.

    serializers.py

    class OrderSerializer(serializers.Serializer):
        id = serializers.IntegerField(read_only=True)
        customer = serializers.CharField(max_length=50)
        order_time = serializers.DateTimeField(read_only=True)
        order_items = OrderItemSerializer(many=True)
    
        def create(self, validated_data):
            order_items_data = validated_data.pop('order_items')
            order = Order.objects.create(**validated_data)
            for order_item_data in order_items_data:
                quantity = order_item_data.pop('quantity')
                menu_item = order_item_data.pop('menu_item')
                OrderItem.objects.create(order=order, quantity=quantity, menu_item=menu_item)
            return order
    

    I changed the name of the field referencing OrderItemSerializer to order_items so that the serializer is working with OrderItem objects now (using the related_name I just configured). The serializer now expects to receive an order_items field in the POST body, so make sure to reformat your requests. And in the first line of the create method I made sure we're popping order_items as well, instead of items.

    EDIT

    If you want to take just the ID of the MenuItem in the request body, but have the response include all the MenuItem data, you can do this with a serializer.Field. I'll set it up so that the serializer can accept input data in a different format than it outputs.

    serializers.py

    class MenuItemField(serializers.Field):
        def to_internal_value(self, data):
            return MenuItem.objects.filter(id=data).first()
    
        def to_representation(self, value):
            return MenuItemSerializer(value).data
    
    class OrderItemSerializer(serializers.Serializer):
        id = serializers.IntegerField(read_only=True)
        menu_item = MenuItemField()
        quantity = serializers.IntegerField(min_value=0)
    

    to_internal_value uses the data provided (the object's ID) to find the MenuItem object when the serializer is being used to save data. But when the serializer is being used to show data, the to_representation method is called, which uses the MenuItemSerializer to show all the object's data.

    Notice that I replaced the menu_item_id line of OrderItemSerializer with menu_item, which uses the new MenuItemField I defined. This means that your request body should use menu_item instead of menu_item_id.