Home > Software design >  Django DRF: how to groupby on a foreign fields?
Django DRF: how to groupby on a foreign fields?

Time:07-30

I have a model where users can upvote other users for specific topics. Something like:

#models.py
Class Topic(models.Model):
    name = models.StringField()

    def __str__(self):
        return str(self.name)

Class UserUpvotes(models.Model):
    """Holds total upvotes by user and topic"""
    user = models.ForeignKey(User)
    topic= models.ForeignKey(Topic)
    upvotes = models.PositiveIntegerField(default=0)

Using DRF, I have an API that returns the following: topic_id, topic_name, and upvotes, which is the total upvotes for a given topic.

One of the project requirements is for the API to use these field names specifically: topic_id, topic_name, and upvotes

#serializers.py
class TopicUpvotesSerializer(serializers.ModelSerializer):
    topic_name = serializers.StringRelatedField(source="topic")

    class Meta:
        model = UserUpvotes
        fields = ["topic_id", "topic_name", "upvotes"]

My trouble is aggregating these fields. I'm filtering the UserUpvotes by user or team and then aggregating by topic.

Desired output

This is the result I want to get. When I don't perform any aggregations (and there are views where this will be the case), it works.

[
    {
        "topic_id": 3,
        "topic_name": "Korean Studies",
        "upvotes": 14
    },
    {
        "topic_id": 12,
        "topic_name": "Inflation",
        "upvotes": 3
    },
]

At first, I tried creating a TopicSerializer, and then assigning it to the topic field in TopicUpvotesSerializer. But then, the resulting json would have a nested "topic" field and the aggragation would fail.

Attempt 1

#views.py

def get_queryset(self):
    return (
      UserUpvotes.objects.filter(user__team=team)
          .values("topic")
          .annotate(upvotes=models.Sum("upvotes"))
          .order_by("-upvotes")
      )

My problem is that the topic_id and topic_name fields are not showing. I get something like:

[
    {
        "topic_name": "3",
        "upvotes": 14
    },
    {
        "topic_name": "12",
        "upvotes": 3
    },
]

Attempt 2

Another queryset attempt:

# views.py

def get_queryset(self):
    return (
      UserUpvotes.objects.filter(user__team=team)
          .values("topic__id", "topic__name")
          .annotate(upvotes=models.Sum("upvotes"))
          .order_by("-upvotes")
      )

Which yields:

[
    {
        "upvotes": 14
    },
    {
        "upvotes": 3
    },
]

The aggregation worked on the queryset level, but the serializer failed to find the correct fields.

Attempt 3

This was the closest I got:

# views.py

def get_queryset(self):
    return (
      UserUpvotes.objects.filter(user__team=team)
          .values("topic__id", "topic__name")
          .annotate(upvotes=models.Sum("upvotes"))
          .values("topic_id", "topic", "upvotes")
          .order_by("-upvotes")[:n]
      )
[
    {
        "topic_name": 3,
        "topic_name": "3",
        "upvotes": 14
    },
    {
        "topic_name": 12,
        "topic_name": "12",
        "upvotes": 3
    },
]

I have no idea why "topic_name" is simply transforming the "topic_id" into a string, instead of calling the string method.

CodePudding user response:

Work with a serializer for the topic:

class TopicSerializer(serializers.ModelSerializer):
    upvotes = serializers.IntegerField(read_only=True)

    class Meta:
        model = Topic
        fields = ['id', 'name', 'upvotes']

then in the ModelViewSet, you annotate:

from django.db.models import Sum
from rest_framework.viewsets import ModelViewSet


class TopicViewSet(ModelViewSet):
    serializer_class = TopicSerializer
    queryset = Topic.objects.annotate(upvotes=Sum('userupvotes__upvotes'))

CodePudding user response:

Desired output This is the result I want to get. When I don't perform any aggregations (and there are views where this will be the case), it works.

[
    {
        "topic_name": 3,
        "topic_name": "Korean Studies",
        "upvotes": 14
    },
    {
        "topic_name": 12,
        "topic_name": "Inflation",
        "upvotes": 3
    },
]

The serialized FK will always give you the ID of the related model. I am not sure why you name it topic_name if that is equal to an ID. Now, if you really want to get the name field of the Topic model in the topic_name = serializers.StringRelatedField(source="topic") you should give it a source="topic.name"

However, if you trying to get the ID of the relation you can still use ModelSerializer :

class TopicUpvotesSerializer(serializers.ModelSerializer):
    class Meta:
        model = UserUpvotes
        fields = "__all__"

CodePudding user response:

@willem-van-onsem's answer is the correct one for the problem as I had put it.

But... I had another use case (sorry! ◑﹏◐), for when the Users API used UserUpvotes serializer as a nested field. So I had to find another solution. This is was I eventually ended up with. I'm posting in case it helps anyone.

class UserUpvotesSerializer(serializers.ModelSerializer): topic_name = serializers.SerializerMethodField()

def get_topic_name (self, obj):
    try:
        _topic_name  = obj.topic.name
    except TypeError:
        _topic_name = obj.get("skill__name", None)
    return _topic_name

class Meta:
    model = UserUpvotes
    fields = ["topic_id", "topic_name", "upvotes"]

I still have no idea why the SerializerMethodField works and the StringRelatedField field doesn't. It feels like a bug?

Anyways, the rub here is that, after the values().annotate() aggregation, obj is no longer a QuerySet, but a dict. So accessing namedirectly will give you a 'UserUpvotes' object is not subscriptable error.

I don’t know if there are any other edge cases I should be aware of (this is when I REALLY miss type hints in Django), but it works so far

  • Related