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