18

I have multiple API which historically work using id as the lookup field:

/api/organization/10

I have a frontend consuming those api.

I'm building a new interface and for some reasons, I would like to use a slug instead an id:

/api/organization/my-orga

The API is built with Django Rest Framework. Except the change of lookup field, the api behavior should stay the same.

Is there a solution to allow my API to work with both a slug and a pk ? Those two path should give them same results:

/api/organization/10
/api/organization/my-orga

Here is my API definition:

# urls.py
router = DefaultRouter()
router.register(r'organization', Organization)
urlpatterns = router.urls

#view.py
class Organization(viewsets.ModelViewSet):
    queryset = OrganisationGroup.objects.all()
    serializer_class = OrganizationSerializer

# serializer.py
class OrganizationSerializer(PermissionsSerializer):
    class Meta:
        model = Organization
Alex Grs
  • 3,231
  • 5
  • 39
  • 58
  • This might be beneficial: http://www.django-rest-framework.org/api-guide/serializers/#how-hyperlinked-views-are-determined – jape Jul 19 '16 at 14:40
  • Hi Alex, were you able to find a good solution for this? – Vinch Aug 06 '17 at 16:32

10 Answers10

13

Try this

from django.db.models import Q
import operator
from functools import reduce
from django.shortcuts import get_object_or_404

class MultipleFieldLookupMixin(object):
    def get_object(self):
        queryset = self.get_queryset()             # Get the base queryset
        queryset = self.filter_queryset(queryset)  # Apply any filter backends
        filter = {}
        for field in self.lookup_fields:
            filter[field] = self.kwargs[field]
        q = reduce(operator.or_, (Q(x) for x in filter.items()))
        return get_object_or_404(queryset, q)

Then in View

class Organization(MultipleFieldLookupMixin, viewsets.ModelViewSet):
    queryset = OrganisationGroup.objects.all()
    serializer_class = OrganizationSerializer
    lookup_fields = ('pk', 'another field')
itzMEonTV
  • 19,851
  • 4
  • 39
  • 49
  • 4
    I think it doesn't answer the question as the URL must contain both pk and the other field in that case. – Vinch Aug 06 '17 at 16:15
  • 1
    How does the URL need to be modified to use MultiFieldLookupMixin? I am using routers to define my URLs. Can anyone share code for this? Thanks. – Foobar Oct 29 '18 at 17:23
  • 1
    To use this with routers, you have to leave `lookup_field` in the viewset class, then change this line `filter[field] = self.kwargs[field]` to this `filter[field] = self.kwargs[self.lookup_field]` – Brobin Feb 15 '19 at 17:41
9

I know you asked this question quite a time ago, but here is the complete solution i got from all answers, considering both views and urls:

Put this in your views.py: (With a little edit from drf)

class MultipleFieldLookupMixin(object):

def get_object(self):
    queryset = self.get_queryset()             
    queryset = self.filter_queryset(queryset)  
    filter = {}
    for field in self.lookup_fields:
        if self.kwargs.get(field, None):  
            filter[field] = self.kwargs[field]
    obj = get_object_or_404(queryset, **filter)  # Lookup the object
    self.check_object_permissions(self.request, obj)
    return obj

Then inherit your view from this Mixin and add fields you want to lookup_fields. Like this:

class YourDetailView(MultipleFieldLookupMixin, RetrieveUpdateAPIView):
    ...
    lookup_fields = ['pk', 'slug','code']

And in urls.py:

re_path(r'^organization/(?P<pk>[0-9]+)/$',
        YourDetailView),
re_path(r'^organization/(?P<slug>[-a-zA-Z0-9_]+)/$',
        YourDetailView),
re_path(r'^organization/sth_else/(?P<code>[0-9]+)/$',
        YourDetailView),
  • 1
    Great answer. Just to add to this a bit, if you are on a newer django, you can use path converter instead of regex. And if you are using ViewSets instead of Views, you will have to specify the action mapping. Apologies for comment limitations in code fomratting: `path( 'releases///', YourDetailViewset.as_view( { 'get': 'retrieve', 'put': 'update', 'patch': 'partial_update', 'delete': 'destroy' } ),` – SuperFunkyMonkey Aug 12 '21 at 16:03
  • @SuperFunkyMonkey if i have some action such as list that don't need this,should i add another path for list action?i can't use router.register anymore? – Cherrymelon Feb 15 '22 at 16:09
7

I solved the similar problem by overriding retrieve method and check pk field's value against any pattern. For example if it consists of only numbers.

def retrieve(self, request, *args, **kwargs):
    if kwargs['pk'].isdigit():
        return super(Organization, self).retrieve(request, *args, **kwargs)
    else:
        # get and return object however you want here.
Hikmat G.
  • 2,591
  • 1
  • 20
  • 40
  • This should be the best solution – TangHongWan Dec 20 '19 at 02:55
  • well this soultion is really clean but also it is very short sighted - if slug is a digit it will not work properly – quqa123 Dec 28 '20 at 02:36
  • 2
    Afaik, this solution totally bypass GenericAPIView.get_object which checks object permissions. – Elektordi Feb 08 '22 at 19:03
  • Updated solution with the same logic: ``` def retrieve(self, request, *args, **kwargs): if not kwargs['pk'].isdigit(): self.lookup_url_kwarg = 'pk' self.lookup_field = 'slug' return super().retrieve(request, *args, **kwargs) ``` – Elektordi Feb 08 '22 at 19:15
3

class MultipleFieldLookupMixin(object):
    """
    Apply this mixin to any view or viewset to get multiple field filtering
    based on a `lookup_fields` attribute, instead of the default single field filtering.
    """

    def get_object(self):
        queryset = self.get_queryset()  # Get the base queryset
        queryset = self.filter_queryset(queryset)
        filter = {}
        for field in self.lookup_fields:
            if self.kwargs[field]:  # Ignore empty fields.
                filter[field] = self.kwargs[field]
        return get_object_or_404(queryset, **filter)  # Lookup the object


class RetrieveUserView(MultipleFieldLookupMixin, generics.RetrieveAPIView):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    lookup_fields = ('account', 'username')
ahprosim
  • 308
  • 1
  • 5
  • 1
    Hi @D. Wang, triied this solution but it seems that there is a problem with routing. Getting error ParameterError: {'account': 'Unknown parameter.', 'username': 'Unknown parameter.', 'id': 'This parameter is required.'}. The view is still expecting the default id – unlockme Jul 16 '18 at 12:13
3

I think best way is to override the get_object(self) method

class Organization(generics.RetrieveAPIView):
    serializer_class = OrganizationSerializer
    queryset = Organization.objects.all()
    multiple_lookup_fields = ['pk', 'slug']

    def get_object(self):
        queryset = self.get_queryset()
        filter = {}
        for field in self.multiple_lookup_fields:
            filter[field] = self.kwargs[field]

        obj = get_object_or_404(queryset, **filter)
        self.check_object_permissions(self.request, obj)
        return obj
kingJulian
  • 5,601
  • 5
  • 17
  • 30
giveJob
  • 1,500
  • 3
  • 17
  • 29
2

There are a lot of answers here already, but none provide a full description including the mixin, view, and url configuration. This answer does.

This is the mixin that works best, it is slightly modified from https://www.django-rest-framework.org/api-guide/generic-views/#creating-custom-mixins to not error out on non-existing fields.

class MultipleFieldLookupMixin:
    """
    Apply this mixin to any view or viewset to get multiple field filtering
    based on a `lookup_fields` attribute, instead of the default single field filtering.

    Source: https://www.django-rest-framework.org/api-guide/generic-views/#creating-custom-mixins
    Modified to not error out for not providing all fields in the url.
    """

    def get_object(self):
        queryset = self.get_queryset()             # Get the base queryset
        queryset = self.filter_queryset(queryset)  # Apply any filter backends
        filter = {}
        for field in self.lookup_fields:
            if self.kwargs.get(field):  # Ignore empty fields.
                filter[field] = self.kwargs[field]
        obj = get_object_or_404(queryset, **filter)  # Lookup the object
        self.check_object_permissions(self.request, obj)
        return obj

Now add the view as follows, it is important to have the Mixin first, otherwise the get_object method is not overwritten:

class RudAPIView(MultipleFieldLookupMixin, generics.RetrieveUpdateDestroyAPIView):
    ...
    lookup_fields = ['pk', 'other_field']

Now, for the urls, we use default converters. It is important int comes first as that one will actually check if it is an int, and if not fallback to str. If you have more complex fields, you need to resort to regex.

path('efficiency/<int:pk>/', views.RudAPIView.as_view(), name='something-rud'),
path('efficiency/<string:other_field>/', views.RudAPIView.as_view(), name='something-rud'),
Hielke Walinga
  • 2,677
  • 1
  • 17
  • 30
1

I think the fundamental answer is that this would not be good REST/API design and just isn't something DRF would enable.

Evan Zamir
  • 8,059
  • 14
  • 56
  • 83
1

The official docs have an example for this at https://www.django-rest-framework.org/api-guide/generic-views/#creating-custom-mixins

Also, you need to modify the urls.py adding a new route for the same view, but with the new field name.

Alvaro Cavalcanti
  • 2,938
  • 4
  • 21
  • 31
0

If you still would like to use Viewsets without breaking it apart, here you go. (Test passed on my end)

import operator
from functools import reduce

from django.db.models import Q
from django.shortcuts import get_object_or_404


class MultipleFieldLookupMixin(object):
    def get_object(self):
        queryset = self.get_queryset()  # Get the base queryset
        queryset = self.filter_queryset(queryset)  # Apply any filter backends
        filters = {}
        pk_fields = ["pk", "id"]
        for field in self.lookup_fields:
            identifier = self.kwargs[self.lookup_field]
            if (field in pk_fields and identifier.isdigit()) or field not in pk_fields:
                filters[field] = self.kwargs[self.lookup_field]
        q = reduce(operator.or_, (Q(x) for x in filters.items()))
        obj = get_object_or_404(queryset, q)
        self.check_object_permissions(self.request, obj)
        return obj

Edmund Wang
  • 109
  • 1
  • 2
0

This is my latest version that supports primary key fields that not necessary are strings, I think is more resilient.

import operator
from functools import reduce

from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.core.exceptions import ValidationError

class MultipleFieldLookupMixin:
    """
    Apply this mixin to any view or viewset to get multiple field filtering
    based on a `lookup_fields` attribute, instead of the default single field filtering.
    """

    def get_object(self):
        queryset = self.get_queryset()  # Get the base queryset
        queryset = self.filter_queryset(queryset)  # Apply any filter backends
        filters = {}
        for field in self.lookup_fields:
            try:
                # Validate the data type we got is a valid data type for the field we are setting
                self.get_serializer_class().Meta.model._meta.get_field(field).to_python(
                    self.kwargs[self.lookup_field]
                )
                filters[field] = self.kwargs[self.lookup_field]
            except ValidationError:
                continue

        query = reduce(operator.or_, (Q(x) for x in filters.items()))
        obj = get_object_or_404(queryset, query)
        self.check_object_permissions(self.request, obj)

        return obj
Darkslave
  • 111
  • 5