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.
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.
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.
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 [email protected] 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 [email protected] 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.
As usual, I welcome comments, suggestions and pull requests.