/learn_drf_with_images

A complete example that illustrates how to use image fields with Django REST Framework

Primary LanguagePython

learn_drf_with_images

Introduction

This project was created to provide a complete example that illustrates how to implement image uploads and models with image fields with Django REST Framework.

The model

There's only one class that represents the typical "User Profile" use case on a Django site:

def upload_to(instance, filename):
    return 'user_profile_image/{}/{}'.format(instance.user_id, filename)


class UserProfile(models.Model):
    GENDER_UNKNOWN = 'U'
    GENDER_MALE = 'M'
    GENDER_FEMALE = 'F'
    GENDER_CHOICES = (
        (GENDER_UNKNOWN, _('unknown')),
        (GENDER_MALE, _('male')),
        (GENDER_FEMALE, _('female')),
    )

    user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True)
    date_of_birth = models.DateField(_('date of birth'), blank=True, null=True)
    phone_number = PhoneNumberField(_('phone number'), blank=True)
    gender = models.CharField(_('gender'), max_length=1, choices=GENDER_CHOICES, default=GENDER_UNKNOWN)
    image = models.ImageField(_('image'), blank=True, null=True, upload_to=upload_to)

With the exception of the phone_number field (which uses django-phonenumber-field), the rest of the fields are regular Django fields, including the image which is the subject of this project and represents an image for the associated user.

The API

As with all Django REST Framework APIs, we need to define serializers, views (or viewsets) and hook the views in the site's URLs. Let's start with the serializers:

class UserProfileSerializer(HyperlinkedModelSerializer):
    class Meta:
        model = UserProfile
        fields = ('url', 'date_of_birth', 'phone_number', 'gender', 'image')
        readonly_fields = ('url', 'image')

It couldn't be simpler. UserProfileSerializer it's just a HyperlinkedModelSerializer that handles the UserProfile model. Given that it is not possible to handle uploads using the default JSON parser, we marked the image field as read-only.

The views are a little more interesting:

class UserProfileViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
    queryset = UserProfile.objects.all()
    serializer_class = UserProfileSerializer
    permission_classes = (IsAdminOrIsSelf,)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @detail_route(methods=['POST'], permission_classes=[IsAdminOrIsSelf])
    @parser_classes((FormParser, MultiPartParser,))
    def image(self, request, *args, **kwargs):
        if 'upload' in request.data:
            user_profile = self.get_object()
            user_profile.image.delete()

            upload = request.data['upload']

            user_profile.image.save(upload.name, upload)

            return Response(status=HTTP_201_CREATED, headers={'Location': user_profile.image.url})
        else:
            return Response(status=HTTP_400_BAD_REQUEST)

We have a GenericViewSet combined with RetrieveModelMixin and UpdateModelMixin to provide retrieve and update funcionality for our UserProfile model (It doesn't make sense to provide list or destroy in this context). The interesting part is the image method, which is exposed as a view using @detail_route decorator.

The trick here is that the method is also decorated using @parser_classes where we declare that the requests should be parsed using FormParser or MultiPartParser, and this is what is going to allow us to handle the uploaded files.

When the method is invoked, we check that the request data contains an upload entry, and if it does we delete the image associated with the user profile, replace it with the UploadedFile contents and return a Response with status code 201 (Created). If upload is not in the request data, we return a fail response with status 400 (Bad Request).

Alternatively if you want to update the the image as well as the model in a single request, you can use the following:

class UserProfileMultiPartParserViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
    queryset = UserProfile.objects.all()
    serializer_class = UserProfileSerializer
    permission_classes = (IsAdminOrIsSelf,)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @parser_classes((MultiPartParser,))
    def update(self, request, *args, **kwargs):
        if 'upload' in request.data:
            user_profile = self.get_object()

            user_profile.image.delete()

            upload = request.data['upload']

            user_profile.image.save(upload.name, upload)

        return super(UserProfileMultiPartParserViewSet, self).update(request, *args, **kwargs)

In here after the image is updated (if necessary) we proceed with the default update.

The last part is to set up the URLs for our API:

router = DefaultRouter()
router.register(r'user_profiles', UserProfileViewSet)
router.register(r'user_profiles_mpp', UserProfileMultiPartParserViewSet)

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^', include(router.urls)),
    url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')),
]

We used a Django REST Framework Router which wires everything automatically and thus save us a lot of work. Notice that we're also using Django OAuth Toolkit to provide authentication for our API.

Usage

The following session illustrates the typical usage of our API.

$ curl --header "Content-Type: application/x-www-form-urlencoded" --header "Accept: application/json; indent=4" --request POST --data "username=admin&password=admin&client_id=zmfZyf7EAGJJ6imph3qtwGtoH8eqt1VdVmRZh7NC&grant_type=password" http://localhost:8000/o/token/; echo
{"access_token": "PkwvCYq0cRYfvpJeXvc4czFKvohwea", "expires_in": 36000, "token_type": "Bearer", "scope": "write read", "refresh_token": "jl3Y5Mo7fLaHvJDWCQv5I9g4zbLHkT"}

$ curl --header "Authorization: Bearer PkwvCYq0cRYfvpJeXvc4czFKvohwea" --header "Accept: application/json; indent=4" --request GET http://localhost:8000/user_profiles/1/; echo
{
    "url": "http://localhost:8000/user_profiles/1/",
    "date_of_birth": "2015-07-07",
    "phone_number": "+41524204242",
    "gender": "M",
    "image": "http://localhost:8000/media/user_profile_image/1/admin.png"
}

$ curl --verbose --header "Authorization: Bearer PkwvCYq0cRYfvpJeXvc4czFKvohwea" --header "Accept: application/json; indent=4" --request POST --form upload=@admin2.jpg http://localhost:8000/user_profiles/1/image/; echo
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8000 (#0)
* Initializing NSS with certpath: sql:/etc/pki/nssdb
> POST /user_profiles/1/image/ HTTP/1.1
> User-Agent: curl/7.40.0
> Host: localhost:8000
> Authorization: Bearer PkwvCYq0cRYfvpJeXvc4czFKvohwea
> Accept: application/json; indent=4
> Content-Length: 3737
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------f915e8f2eaef4479
>
* Done waiting for 100-continue
* HTTP 1.0, assume close after body
< HTTP/1.0 201 CREATED
< Date: Tue, 07 Jul 2015 01:34:01 GMT
< Server: WSGIServer/0.2 CPython/3.4.2
< Vary: Accept
< Location: http://localhost:8000/media/user_profile_image/1/admin2.jpg
< X-Frame-Options: SAMEORIGIN
< Allow: POST, OPTIONS
<
* Closing connection 0

$ curl --header "Authorization: Bearer PkwvCYq0cRYfvpJeXvc4czFKvohwea" --header "Accept: application/json; indent=4" --request GET http://localhost:8000/user_profiles/1/; echo
{
    "url": "http://localhost:8000/user_profiles/1/",
    "date_of_birth": "2015-07-07",
    "phone_number": "+41524204242",
    "gender": "M",
    "image": "http://localhost:8000/media/user_profile_image/1/admin2.jpg"
}

Using the UserProfileMultiPartParserViewSet view set, we can update the model and the image in one request:

$ curl --verbose --header "Authorization: Bearer PkwvCYq0cRYfvpJeXvc4czFKvohwea" --header "Accept: application/json; indent=4" --request PUT --form data='{"url":"http://127.0.0.1:8000/user_profiles_mpp/1/","date_of_birth":"1980-01-01","phone_number":null,"gender":"M","image":null}' --form upload=@image.png http://localhost:8000/user_profiles_mpp/1/; echo
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8000 (#0)
* Initializing NSS with certpath: sql:/etc/pki/nssdb
> PUT /user_profiles_mpp/1/ HTTP/1.1
> User-Agent: curl/7.40.0
> Host: localhost:8000
> Authorization: Bearer PkwvCYq0cRYfvpJeXvc4czFKvohwea
> Accept: application/json; indent=4
> Content-Length: 720
> Expect: 100-continue
> Content-Type: multipart/form-data; boundary=------------------------740bde5e39c758b5
>
< HTTP/1.1 100 Continue
< HTTP/1.1 200 OK
< Server: nginx/1.4.6 (Ubuntu)
< Date: Tue, 11 Aug 2015 14:53:10 GMT
< Content-Type: application/json; indent=4
< Transfer-Encoding: chunked
< Connection: keep-alive
< X-Frame-Options: SAMEORIGIN
< Allow: GET, PUT, PATCH, HEAD, OPTIONS
< Vary: Accept
<
{
    "url": "http://127.0.0.1:8000/user_profiles_mpp/1/",
    "date_of_birth": "2015-07-07",
    "phone_number": "+41524204242",
    "gender": "M",
    "image": "http://127.0.0.1:8000/media/user_profile_image/1/image.png"
* Connection #0 to host localhost left intact
}

A Vagrant configuration file is included if you want to test the service yourself.

Feedback

As usual, I welcome comments, suggestions and pull requests.