User Photos

Part 1, Chapter 8


Viewing a user's photo is an important piece of functionality in ride-sharing apps. In fact, most of these apps make it mandatory to provide a photo before you can drive or ride. From one perspective, it's a security issue—riders need to confirm that the drivers are who they expect before they enter their vehicles. User photos are also good design and add life to the product.

Our app will allow users to add their photos at sign up.

Media Files

Media files are a form of user-generated static files and Django handles both in a similar way. We need to provide two new settings, MEDIA_ROOT and MEDIA_URL.

The MEDIA_ROOT is the path to the directory where file uploads will be saved. For the purpose of this tutorial, we can create a "media" folder inside our "server" directory. In a production environment, we'd specify an absolute path to a directory on the server or we'd store files with a service like AWS S3. The MEDIA_URL is the prefix to use in our URL path.

Set both the MEDIA_URL and the MEDIA_ROOT within the settings file:

# taxi/settings.py

MEDIA_URL = '/media/'

MEDIA_ROOT = os.path.join(BASE_DIR, '../media')

One last step is required to get our local environment to serve media files. Update taxi/urls.py like so:

# taxi/urls.py

from django.conf import settings # new
from django.conf.urls.static import static # new
from django.contrib import admin
from django.urls import include, path

from trips.views import SignUpView, LogInView, LogOutView


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/sign_up/', SignUpView.as_view(), name='sign_up'),
    path('api/log_in/', LogInView.as_view(), name='log_in'),
    path('api/log_out/', LogOutView.as_view(), name='log_out'),
    path('api/trip/', include('trips.urls', 'trip',)),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # new

Media files can now be retrieved via http://localhost:8000/media/<file_path>/ on your local machine.

To test, add a new folder called media to server. Then, add a test file called test.txt to that folder and add some random text to the file. Fire up the server and navigate to http://localhost:8000/media/test.txt to view the file.

Make sure you remove the static() function from the urlpatterns when you deploy your application. We only need that for local development.

Tests

Change the existing AuthenticationTest.test_user_can_sign_up test in the following way:

# tests/test_http.py

def test_user_can_sign_up(self):
    photo_file = create_photo_file() # new
    response = self.client.post(reverse('sign_up'), data={
        'username': '[email protected]',
        'first_name': 'Test',
        'last_name': 'User',
        'password1': PASSWORD,
        'password2': PASSWORD,
        'group': 'rider',
        'photo': photo_file, # new
    })
    user = get_user_model().objects.last()
    self.assertEqual(status.HTTP_201_CREATED, response.status_code)
    self.assertEqual(response.data['id'], user.id)
    self.assertEqual(response.data['username'], user.username)
    self.assertEqual(response.data['first_name'], user.first_name)
    self.assertEqual(response.data['last_name'], user.last_name)
    self.assertEqual(response.data['group'], user.group)
    self.assertIsNotNone(user.photo) # new

Add the create_photo_file helper function right after the create_user helper:

# trips/tests/test_http.py

def create_photo_file():
    data = BytesIO()
    Image.new('RGB', (100, 100)).save(data, 'PNG')
    data.seek(0)
    return SimpleUploadedFile('photo.png', data.getvalue())

This code leverages the Pillow library, BytesIO from the standard library, and Django's SimpleUploadedFile to create fake image data.

Add the imports:

# trips/tests/test_http.py

from io import BytesIO
from PIL import Image
from django.core.files.uploadedfile import SimpleUploadedFile

Of course the test will fail since we need to update our user model and its serializer:

AttributeError: 'User' object has no attribute 'photo'

File Changes

Modify the user model:

# trips/models.py

class User(AbstractUser):
    photo = models.ImageField(upload_to='photos', null=True, blank=True) # new

    @property
    def group(self):
        groups = self.groups.all()
        return groups[0].name if groups else None

Now, when users upload their photos, the app will save them in a photos subdirectory within our media folder.

In order to display a photo, we need either the relative URL (i.e. /media/photos/photo.jpg) or the absolute URL (i.e. http://localhost:8080/media/photos/photo.jpg). Django REST Framework provides a use_url=True property on its ImageField class, but the absolute URL it provides does not include the port. We can get around this shortcoming by creating our own custom serializer field.

Edit the serializers.py file to create and use a new MediaImageField.

# trips/serializers.py

from urllib.parse import urljoin # new

from django.conf import settings # new
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model

from rest_framework import serializers

from .models import Trip


class MediaImageField(serializers.ImageField): # new
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def to_representation(self, value):
        if not value:
            return None
        return urljoin(settings.MEDIA_URL, value.name)


class UserSerializer(serializers.ModelSerializer):
    password1 = serializers.CharField(write_only=True)
    password2 = serializers.CharField(write_only=True)
    group = serializers.CharField()
    photo = MediaImageField(allow_empty_file=True) # new

    def validate(self, data):
        if data['password1'] != data['password2']:
            raise serializers.ValidationError('Passwords must match.')
        return data

    def create(self, validated_data):
        group_data = validated_data.pop('group')
        group, _ = Group.objects.get_or_create(name=group_data)
        data = {
            key: value for key, value in validated_data.items()
            if key not in ('password1', 'password2')
        }
        data['password'] = validated_data['password1']
        user = self.Meta.model.objects.create_user(**data)
        user.groups.add(group)
        user.save()
        return user

    class Meta:
        model = get_user_model()
        fields = (
            'id', 'username', 'password1', 'password2',
            'first_name', 'last_name', 'group',
            'photo', # new
        )
        read_only_fields = ('id',)


class TripSerializer(serializers.ModelSerializer):
    class Meta:
        model = Trip
        fields = '__all__'
        read_only_fields = ('id', 'created', 'updated',)


class ReadOnlyTripSerializer(serializers.ModelSerializer):
    driver = UserSerializer(read_only=True)
    rider = UserSerializer(read_only=True)

    class Meta:
        model = Trip
        fields = '__all__'

Edit the views.py file to make TripView use the ReadOnlyTripSerializer. We want the Trip API response payload to include full driver and rider object representations.

# trips/views.py

from django.contrib.auth import get_user_model, login, logout
from django.contrib.auth.forms import AuthenticationForm
from django.db.models import Q

from rest_framework import generics, permissions, status, views, viewsets
from rest_framework.response import Response

from .models import Trip
from .serializers import ReadOnlyTripSerializer, UserSerializer # changed


class SignUpView(generics.CreateAPIView):
    queryset = get_user_model().objects.all()
    serializer_class = UserSerializer


class LogInView(views.APIView):
    def post(self, request):
        form = AuthenticationForm(data=request.data)
        if form.is_valid():
            user = form.get_user()
            login(request, user=form.get_user())
            return Response(UserSerializer(user).data)
        else:
            return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)


class LogOutView(views.APIView):
    permission_classes = (permissions.IsAuthenticated,)

    def post(self, *args, **kwargs):
        logout(self.request)
        return Response(status=status.HTTP_204_NO_CONTENT)


class TripView(viewsets.ReadOnlyModelViewSet):
    lookup_field = 'id'
    lookup_url_kwarg = 'trip_id'
    permission_classes = (permissions.IsAuthenticated,)
    serializer_class = ReadOnlyTripSerializer # changed

    def get_queryset(self):
        user = self.request.user
        if user.group == 'driver':
            return Trip.objects.filter(
                Q(status=Trip.REQUESTED) | Q(driver=user)
            )
        if user.group == 'rider':
            return Trip.objects.filter(rider=user)
        return Trip.objects.none()

One last thing—create a migration for the new photo field on our user table and run the migrations.

(env)$ python manage.py makemigrations
(env)$ python manage.py migrate

Now the tests should pass.

(env)$ python manage.py test trips.tests

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.972s

OK
Destroying test database for alias 'default'...

Here's our directory structure in its current state.

.
├── pytest.ini
└── server
    ├── media
    │   └── test.txt
    └── taxi
        ├── db.sqlite3
        ├── manage.py
        ├── taxi
        │   ├── __init__.py
        │   ├── asgi.py
        │   ├── routing.py
        │   ├── settings.py
        │   ├── urls.py
        │   └── wsgi.py
        └── trips
            ├── __init__.py
            ├── admin.py
            ├── apps.py
            ├── consumers.py
            ├── migrations
            │   ├── 0001_initial.py
            │   ├── 0002_trip.py
            │   ├── 0003_trip_driver_rider.py
            │   ├── 0004_user_photo.py
            │   └── __init__.py
            ├── models.py
            ├── serializers.py
            ├── tests
            │   ├── __init__.py
            │   ├── test_http.py
            │   └── test_websocket.py
            ├── urls.py
            └── views.py



Mark as Completed