Home > front end >  Django REST Frameworking: modify PrimaryKeyRelatedField queryset for GET requests
Django REST Frameworking: modify PrimaryKeyRelatedField queryset for GET requests

Time:10-04

Is it possible to give PrimaryKeyRelatedField a get_queryset function that is called when doing a GET request? Currently it's only getting called when I'm doing a POST or PUT request, but I want to be able to limit which relations get shown in a GET request, or as the result of a PUT or POST request.


The use case is that I have a model with a many to many relationship, some of which the user has access to, and some not. So I want to only include the ones the user has access to in the result.

Specifically, take this example:

class Quest(models.Model):
    name = models.CharField(max_length=255)
    is_completed = models.BooleanField(default=False)
    characters = models.ManyToManyField(Character, blank=True)
    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, editable=False)


class Character(models.Model):
    name = models.CharField(max_length=255)
    is_hidden = models.BooleanField(default=False)
    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, editable=False)

So a quest has an array of characters, some of which can be marked as hidden.

My serializer looks like this:

class QuestSerializer(DmNotesModelSerializer):
    characters = CharacterPrimaryKeyRelatedField(many=True)

    class Meta:
        model = Quest
        fields = "__all__"


class CharacterPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
    def get_queryset(self):
        return Character.objects.filter(campaign_id=self.context.get("view").kwargs.get("campaign_id"))

This makes the characters array writable, AND I am limiting the characters you can add to ones that belong to the same campaign as the quest itself. This all works fine, no questions there.

However, at the moment it is possible that you get a quest that contains hidden characters in the characters array, which can cause problems in the app (fetching such a character results in a 404). So I want to limit the queryset to remove the hidden characters.


Experiment 1

I am able to change my view to do the filtering like this:

class QuestController(viewsets.ModelViewSet):
    serializer_class = QuestSerializer

    def get_queryset(self):
        character_qs = Character.objects.all()
        if not self.request.membership.is_dm:
            character_qs = character_qs.filter(is_hidden=False)

        return (
            Quest.objects.filter(campaign_id=self.kwargs["campaign_id"])
            .prefetch_related(Prefetch("character_set", queryset=character_qs))
        )

    def perform_create(self, serializer):
        return serializer.save(campaign_id=self.kwargs["campaign_id"])

And that mostly works: when you fetch a quest, the characters array will only contain characters that are not hidden. But there is a problem with this: when you're doing a PUT request to update a quest, that response will then still contain hidden characters, so I have to make even more changes to the view to modify the serializer that is used in the response of the update method.

And there's a bunch of these many to many relations in a bunch of views, so this is quickly becoming a lot of boilerplate to add.


Experiment 2

I tried to modify the field in my serializer like this:

characters = CharacterPrimaryKeyRelatedField(many=True, source="get_characters")

And then add a get_characters method to my Quest model, but since that only takes a self parameter, I have no access to the request and thus don't know what filters to apply - I have no idea what role the user has.


Experiment 3

I tried to use a custom Manager like this on the Character:

class HiddenObjectsManager(models.Manager):
    def get_queryset(self):
        return super(HiddenObjectsManager, self).get_queryset().filter(is_hidden=False)


class Character(models.Model):
    objects = HiddenObjectsManager()
    # ...

That does solve all my problems, the result is now always correct, both for GET requests and the response of PUT requests. However, it is now hardcoded to alway filter out hidden characters, with no regards for the user access. And I can't access the request in the custom Manager's get_queryset method, so this seems like a dead end too. Unless I could switch out the managers in the serializer, based on the request?


Experiment 4

I can override the source of a relationship field like this:

class QuestSerializer(serializers.ModelSerializer):
    def get_fields(self):
        fields = super(QuestSerializer, self).get_fields()

        if not self.context.get("request").membership.is_dm:
            fields["characters"].source = "visible_characters"

        return fields

And then I can add a visible_characters method on Quest:


class Quest(models.Model):
    # ...

    def visible_characters(self):
        return self.character_set.filter(is_hidden=False)

While this does work (yay!), it does massively increase the query count, because prefetch_related("character_set") (done in view's get_queryset method) doesn't help anymore - so now it does a query for each character (even though only the ID is returned). Sigh, I keep running into problems like this.

CodePudding user response:

Not 100% sure if I got it all, but I think, based on QuestController.get_queryset, that you need access to request.membership.is_dm in CharacterPrimaryKeyRelatedField.get_queryset in order to filer on is_hidden.

Well, you already use self.context.get("view") and in the same way can access request.

class CharacterPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
    def get_queryset(self):
        view = self.context.get("view")
        request = self.context.get("request")
        return Character.objects.filter(
            campaign_id=view.kwargs.get("campaign_id"), 
            is_hidden=request.membership.is_dm,
         )

And if request is not in context, it's easy to add it by overriding QuestController.get_serializer_context; see more at Pass request context to serializer from Viewset in Django Rest Framework.


EDIT

Based on your comment, try to change field's queryset on init. Not tested.

class CharacterPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        view = self.context.get("view")
        request = self.context.get("request")
        self.fields["characters"].queryset = self.fields["characters"].queryset.filter(
            campaign_id=view.kwargs.get("campaign_id"), 
            is_hidden=request.membership.is_dm,
         )

CodePudding user response:

This what I came up with in the end. I know this is very hacky and really not recommended, but it solves all my problems without adding a whole lot of boilerplate or a bunch of extra queries.

# managers.py
from django.db import models

is_dm = False


class HiddenObjectsManager(models.Manager):
    def get_queryset(self):
        queryset = super(HiddenObjectsManager, self).get_queryset()
        if not is_dm:
            queryset = queryset.filter(is_hidden=False)
        return queryset
# models.py
class Character(models.Model):
    objects = HiddenObjectsManager()
    # ...

And then in the same place as where I store membership onto the request object (in a BasePermission subclass), I set the is_dm parameter:

# permissions.py
from lib import managers

class CampaignMemberOrPublicReadOnlyPermission(BasePermission):
    def has_permission(self, request, view, *args, **kwargs):
        request.campaign = Campaign.objects.get(pk=view.kwargs.get("campaign_id"))

        try:
            request.membership = Membership.objects.get(user=request.user, campaign_id=view.kwargs.get("campaign_id"))
            managers.is_dm = request.membership.is_dm
            return True
        except Membership.DoesNotExist:
            request.membership = Membership()
            managers.is_dm = False

            # Not a member, then check if it's a public campaign, in which case we can do GET requests only
            if not request.campaign.is_private:
                return request.method in SAFE_METHODS

            return False
  • Related