Custom Hyperlinked URL field for more than one lookup field in a serializer of DRF

5.5k Views Asked by At

I am using Django Rest Framework for developing web api for my project. As in my project i need to build nested api's endpoint like this:

   /users/ - to get all users
   /users/<user_pk> - to get details of a particular user
   /users/<user_pk>/mails/ - to get all mails sent by a user
   /users/<user_pk>/mails/<pk> - to get details of a mail sent by a user

So, i am using drf-nested-routers for ease of writing & maintaing these nested resources.

I want output of all my endpoints have hyperlink for getting details of each nested resource alongwith other details like this:

[
    {
        "url" : "http://localhost:8000/users/1",
        "first_name" : "Name1",
        "last_name": "Lastname"
        "email" : "[email protected]",
        "mails": [
            {
                 "url": "http://localhost:8000/users/1/mails/1",
                 "extra_data": "This is a extra data",
                 "mail":{
                     "url": "http://localhost:8000/mails/3"
                     "to" : "[email protected]",
                     "from": "[email protected]",
                     "subject": "This is a subject text",
                     "message": "This is a message text"
                 }
            },
            {
             ..........
            }
           ..........
         ]
    }
    .........
]

To do this, i write my serializers by inherit HyperlinkedModelSerializer as per DRF docs, which automatically adds a url field in response during serialization.

But, by default DRF serializers does not support generation of url for nested resource like above mentioned or we can say more than single lookup field. To handle this situation, they recommended to create custom hyperlinked field.

I followed this doc, and write custom code for handling url generation of nested resource. My code snippets are as follows:

models.py

from django.contrib.auth.models import AbstractUser
from django.db import models

# User model
class User(models.AbstractUser):
    mails = models.ManyToManyField('Mail', through='UserMail', 
                                     through_fields=('user', 'mail'))

# Mail model
class Mail(models.Model):
    to = models.EmailField()
    from = models.EmailField()
    subject = models.CharField()
    message = models.CharField()

# User Mail model
class UserMail(models.Model):
    user = models.ForeignKey('User')
    mail = models.ForeignKey('Mail')
    extra_data = models.CharField()

serializers.py

from rest_framework import serializers
from .models import User, Mail, UserMail
from .serializers_fields import UserMailHyperlink

# Mail Serializer
class MailSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Mail
        fields = ('url', 'to', 'from', 'subject', 'message' )

# User Mail Serializer
class UserMailSerializer(serializers.HyperlinkedModelSerializer):
    url = UserMailHyperlink()
    mail = MailSerializer()

    class Meta:
        model = UserMail
        fields = ('url', 'extra_data', 'mail')  


# User Serializer
class UserSerializer(serializers.HyperlinkedModelSerializer):
    mails = UserMailSerializer(source='usermail_set', many=True)

    class Meta:
        model = User
        fields = ('url', 'first_name', 'last_name', 'email', 'mails')

serializers_fields.py

from rest_framework import serializers
from rest_framework.reverse import reverse
from .models import UserMail

class UserMailHyperlink(serializers.HyperlinkedRelatedField):
    view_name = 'user-mail-detail'
    queryset = UserMail.objects.all()

    def get_url(self, obj, view_name, request, format):
        url_kwargs = {
            'user_pk' : obj.user.pk,
            'pk' : obj.pk
        }
        return reverse(view_name, kwargs=url_kwargs, request=request, 
                          format=format)

    def get_object(self, view_name, view_args, view_kwargs):
        lookup_kwargs = {
           'user_pk': view_kwargs['user_pk'],
           'pk': view_kwargs['pk']
        }
        return self.get_queryset().get(**lookup_kwargs)

views.py

from rest_framework import viewsets
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from .models import User, UserMail
from .serializers import UserSerializer, MailSerializer

class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer

class UserMailViewSet(viewsets.ViewSet):
    queryset = UserMail.objects.all()
    serializer_class = UserMailSerializer

    def list(self, request, user_pk=None):
        mails = self.queryset.filter(user=user_pk)
        serializer = self.serializer_class(mails, many=True,
            context={'request': request}
        )
        return Response(serializer.data)

    def retrieve(self, request, pk=None, user_pk=None):
        queryset = self.queryset.filter(pk=pk, user=user_pk)
        mail = get_object_or_404(queryset, pk=pk)
        serializer = self.serializer_class(mail,
            context={'request': request}
        )
        return Response(serializer.data)

urls.py

from rest_framework.routers import DefaultRouter
from rest_framework_nested import routers
from django.conf.urls import include, url
import views

router = DefaultRouter()
router.register(r'users', views.UserViewSet, base_name='user')

user_router = routers.NestedSimpleRouter(router, r'users',
    lookup='user'
)
user_router.register(r'mails', views.UserMailViewSet,
    base_name='user-mail'
)


urlpatterns = [
    url(r'^', include(router.urls)),
    url(r'^', include(user_router.urls)), 
]

Now, after doing this when i run a project and ping /users/ api endpoint, i got this error:

AttributeError : 'UserMail' object has no attribute 'url'

I couldn't understand why this error came, because in UserMailSerializer i added url field as a attribute of this serializer, so when it has to serialize why it takes url field as a attribute of UserMail model. Please help me out to get away from this problem.

P.S: Please don't suggest any refactoring in models. As, here i just disguised my project real idea with user & mail thing. So, take this as test case and suggest me a solution.

1

There are 1 best solutions below

4
On BEST ANSWER

I just needed to do something similar lately. My solution ended up making a custom relations field. To save space, Ill simply (and shamelessly) will point to the source code. The most important part is adding lookup_fields and lookup_url_kwargs class attributes which are used internally to both lookup objects and construct the URIs:

class MultiplePKsHyperlinkedIdentityField(HyperlinkedIdentityField):
    lookup_fields = ['pk']
    def __init__(self, view_name=None, **kwargs):
        self.lookup_fields = kwargs.pop('lookup_fields', self.lookup_fields)
        self.lookup_url_kwargs = kwargs.pop('lookup_url_kwargs', self.lookup_fields)
        ...

That in turn allows the usage like:

class MySerializer(serializers.ModelSerializer):
    url = MultiplePKsHyperlinkedIdentityField(
        view_name='api:my-resource-detail',
        lookup_fields=['form_id', 'pk'],
        lookup_url_kwargs=['form_pk', 'pk']
    )

Here is also how I use it source code.

Hopefully that can get you started.