Building an Activity Generator with Django and Django Rest Framework

by Anna Makarudze


June 24, 2022, 3:26 a.m.



This is a REST API I developed for fun using Django and Django Rest Framework. The API allows a user to save activities they like to do when they are bored. When the user is bored, they can send a query with either name or nature in query parameters. The full source code for this project is available here.

Architecture

The REST API was developed using:

  • Python 3.9+,
  • Django 4.0+,
  • Django REST Framework, and
  • Postgres 12.

Apps

The project has two apps, an accounts app to manage user authentication and an activities app for managing activities. As is usual practice for Django, I added these apps to the INSTALLED_APPS setting in settings.py together with rest_framework and rest_framework.authtoken.

activity_generator/settings.py

INSTALLED_APPS += [
    "rest_framework",
    "rest_framework.authtoken",
    "accounts",
    "activities",
]

Project urls.py

I also included the URLs of the two apps in the project urls.py, that is in activity_generator/urls.py.

urlpatterns += [
    path("accounts/", include("accounts.urls", namespace="accounts")),
    path("", include("activities.urls", namespace="activities")),
]

Accounts App

The accounts app handles authentication.

Models

Models for accounts app are defined in accounts/models.py. It has a UserManager class which extends the django.contrib.auth.models.BaseUserManager class and has custom create_user() and create_superuser() methods which take email, first_name, last_name and password as parameters. It also has a User model that extends the django.contrib.auth.models.BaseUser and django.contrib.auth.models.PermissionsMixin classes. It also uses email as the username field thereby overriding the Django default username field, and makes first_name and last_name required fields.

accounts/models.py

from django.db import models
from django.contrib.auth.models import (
    AbstractBaseUser,
    BaseUserManager,
    PermissionsMixin,
)
from django.forms import ValidationError


class UserManager(BaseUserManager):
    """Base manager for user model."""

    def create_user(self, email, first_name, last_name, password):
        """Create a new user."""
        if not email:
            raise ValidationError("A user must have an email address.")
        email = self.normalize_email(email)
        user = self.model(email=email, first_name=first_name, last_name=last_name)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, first_name, last_name, password):
        """Create a new superuser."""
        user = self.create_user(email, first_name, last_name, password)
        user.is_superuser = True
        user.is_staff = True
        user.save(using=self._db)
        return user


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(max_length=100, unique=True)
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)

    objects = UserManager()

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["first_name", "last_name"]

    def get_full_name(self):
        return f"{self.first_name} {self.last_name}"

    def get_short_name(self):
        return self.first_name

    def __str__(self):
        return self.email

Serializers

The accounts app has two serializers, UserSerializer and AuthTokenSerializer. The UserSerializer extends rest_framework.serializers.ModelSerializer while the AuthTokenSerializer extends rest_framework.serializers.Serializer. For the UserSerializer, I override the create() method to create a new user by calling the User.objects.create_user() method. For the AuthTokenSerializer, I also override the validate() method for AuthTokenSerializer to handle user authentication and provide users with tokens.

accounts/serializers.py

from django.contrib.auth import authenticate, get_user_model
from django.utils.translation import gettext_lazy as _

from rest_framework import serializers


class UserSerializer(serializers.ModelSerializer):
    """Serializer for the user object."""

    class Meta:
        model = get_user_model()
        fields = ("email", "first_name", "last_name", "password")
        extra_kwargs = {"password": {"write_only": True, "min_length": 8}}

    def create(self, validated_data):
        """Create a new user with encrypted password and return the user."""
        return get_user_model().objects.create_user(**validated_data)


class AuthTokenSerializer(serializers.Serializer):
    """Serializer for user authentication object."""

    email = serializers.CharField()
    password = serializers.CharField(
        style={"input_type": "password"}, trim_whitespace=True
    )

    def validate(self, attrs):
        """Validate and authenticate the user."""
        email = attrs.get("email")
        password = attrs.get("password")

        user = authenticate(
            request=self.context.get("request"), username=email, password=password
        )
        if not user:
            msg = _("Unable to authenticate with provided details.")
            raise serializers.ValidationError(msg, code="authentication")
        attrs["user"] = user
        return attrs

Views

The accounts app has three views; one for creating the user, one for creating an authentication token for the user and another for managing the user.

accounts/views.py

from rest_framework import authentication, generics, permissions
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.settings import api_settings

from .serializers import AuthTokenSerializer, UserSerializer


class CreateUserView(generics.CreateAPIView):
    """Create a new user in the system."""

    serializer_class = UserSerializer


class CreateTokenView(ObtainAuthToken):
    """Create a token for a user."""

    serializer_class = AuthTokenSerializer
    renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES


class ManageUserView(generics.RetrieveUpdateAPIView):
    """Manage the authenticated user."""

    serializer_class = UserSerializer
    authentication_classes = (authentication.TokenAuthentication,)
    permission_classes = (permissions.IsAuthenticated,)

    def get_object(self):
        """Retrieve and return authenticated user."""
        return self.request.user

URLs

Wiring the views to URLS is pretty simple and straightforward. I use the .as_view() method for class based views as they extent rest_framework.generics views and that’s it.

accounts.urls.py

from django.urls import path

from . import views

app_name = "accounts"


urlpatterns = [
    path("create/", views.CreateUserView.as_view(), name="create"),
    path("token/", views.CreateTokenView.as_view(), name="token"),
    path("me/", views.ManageUserView.as_view(), name="me"),
]

The accounts app is up and running. Now, moving on to the activities app.

Activities App

The activities app has two models Tag and Activity, both of which have user as a foreign key since a user should see only their own tags and activities. A user can create tags and add them to activities when they create an activity or to an already created activity. To get activities to do when the user is bored, the user sends a GET request to “/activities/” url with a name or nature as a query parameter. For example, a GET request for activities which are outdoors would be like “/activities/?nature=outdoor”.

Models

The model definitions are shown below.

activities/models.py

from django.db import models

from accounts.models import User


class Tag(models.Model):
    """Tags to be used for an activity."""

    name = models.CharField(max_length=255)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

    def __str__(self):
        return self.name


class Activity(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    name = models.CharField(max_length=255)
    nature = models.CharField(max_length=255)
    tag = models.ForeignKey(Tag, on_delete=models.CASCADE)

    class Meta:
        verbose_name_plural = "activities"

    def __str__(self):
        return f"{self.user} - {self.name}"

Serializers

The serializers for both models extend rest_framework.serializers.ModelSerializer and are very basic, with no customization whatsoever as there was no need to override any of the base serializer methods.

activities/serializers.py

from rest_framework import serializers

from activities.models import Activity, Tag


class TagSerializer(serializers.ModelSerializer):
    """Serializer for tag objects."""

    class Meta:
        model = Tag
        fields = ("id", "name")
        read_only_fields = ("id",)


class ActivitySerializer(serializers.ModelSerializer):
    """Serializer for the activity model."""

    class Meta:
        model = Activity
        fields = ("id", "user", "name", "nature", "tag")
        read_only_fields = ("id",)

Views

For the views this time, I used rest_framework.viewsets and rest_framewok.mixins as well as rest_framework.permissions. Viewsets do not have get() and post() methods but instead have list(), create(), update(), retrieve(), delete() etc methods. The mixins allow us to specify which of the default viewset methods a user is allowed to do and permission classes allow us to specify whether authentication is required or not.

Since I am restricting users to accessing their own content, I write custom get_queryset() and perform_create() methods to override the default behaviour of those methods. The activities/views.py is shown below.

activities/views.py

from rest_framework import mixins, viewsets
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated

from .models import Activity, Tag
from .serializers import ActivitySerializer, TagSerializer


class TagViewSet(
    viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateModelMixin
):
    """Viewset for Tag objects."""

    authentication_classes = (TokenAuthentication,)
    permission_classes = (IsAuthenticated,)
    queryset = Tag.objects.all()
    serializer_class = TagSerializer

    def get_queryset(self):
        """Return objects for the current authenticated user only."""
        assigned_only = bool(int(self.request.query_params.get("assigned_only", 0)))
        queryset = self.queryset
        if assigned_only:
            queryset = queryset.filter(activity__isnull=False)

        return queryset.filter(user=self.request.user).order_by("-name").distinct()

    def perform_create(self, serializer):
        """Create a new tag"""
        serializer.save(user=self.request.user)


class ActivityViewSet(
    viewsets.GenericViewSet,
    mixins.ListModelMixin,
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.UpdateModelMixin,
):
    """Manage activities in the database."""

    queryset = Activity.objects.all()
    serializer_class = ActivitySerializer
    authentication_classes = (TokenAuthentication,)
    permission_classes = (IsAuthenticated,)

    def get_queryset(self):
        nature = self.request.query_params.get("nature")
        name = self.request.query_params.get("name")
        queryset = self.queryset

        if nature:
            return queryset.filter(
                user=self.request.user, nature__icontains=nature
            ).order_by("-id")
        if name:
            return queryset.filter(
                user=self.request.user, name__icontains=name
            ).order_by("-id")
        return queryset.filter(user=self.request.user).order_by("-id")

    def perform_create(self, serializer):
        """Create a new activity."""
        serializer.save(user=self.request.user)

URLs

URLs for viewsets are implemented a bit differently from those of generic views using rest_framework.routers.DefaultRouter.

activities/urls.py

from django.urls import include, path

from rest_framework.routers import DefaultRouter

from . import views

app_name = "activities"

router = DefaultRouter()
router.register("tags", views.TagViewSet)
router.register("activities", views.ActivityViewSet)

urlpatterns = [
    path("", include(router.urls)),
]

Code Formatting

As in all my recent projects, I used blackfor code formatting by running the black . command in a terminal with a virtualenv enabled.

Testing

Once again for testing I used pytest since it makes it so easy to separate test data fixtures from the actual tests using the conftest.py. Especially given that the project has two apps which need to share the same authenticated and non-authenticated user clients, pytest enables me to follow the DRY principle as I just put shared fixtures in tests/unit_tests/conftest.py and then app specific fixtures, I put them in the conftest.py for each app.

I indulged myself on this project a bit by adding to the tests I usually write for models, views, forms etc, tests for serializers. For this I had to use django-factoryboy and Faker for the first time in my own app and it was fun putting together tests and fixtures for the serializers.

Testing serializers

To test serializers, I needed to create a factories.py in tests/unit_tests/test_accounts for the UserFactory and TokenFactory, and another factories.py in tests/unit_tests/test_activities for TagFactory and ActivityFactory.

tests/unit_tests/test_accounts/factories.py

import factory
from faker import Factory as FakerFactory
from pytest_factoryboy import register

from accounts.models import User

faker = FakerFactory.create()


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    first_name = factory.LazyAttribute(lambda x: faker.first_name())
    last_name = factory.LazyAttribute(lambda x: faker.last_name())
    email = factory.LazyAttribute(lambda x: faker.email())
    password = factory.LazyAttribute(lambda x: faker.password())


class TokenFactory(factory.Factory):
    class Meta:
        model = User

    email = factory.LazyAttribute(lambda x: faker.email())
    password = factory.LazyAttribute(lambda x: faker.password())


register(UserFactory)
register(TokenFactory)

tests/unit_tests/test_accounts/test_serializers.py

import factory
import pytest

from accounts.serializers import UserSerializer, AuthTokenSerializer
from tests.unit_tests.test_accounts.factories import UserFactory, TokenFactory


@pytest.mark.unit
def test_serialize_user_model():
    user = UserFactory()
    serializer = UserSerializer(user)

    assert serializer.data


@pytest.mark.unit
def test_serialized_data(mocker):
    valid_serialized_data = factory.build(dict, FACTORY_CLASS=UserFactory)

    serializer = UserSerializer(data=valid_serialized_data)
    assert serializer.is_valid(raise_exception=True)
    assert serializer.errors == {}


@pytest.mark.unit
def test_serialize_token():
    token = TokenFactory()
    serializer = AuthTokenSerializer(token)

    assert serializer

tests/unit_tests/test_activities/factories.py

import factory
from faker import Factory as FakerFactory
from pytest_factoryboy import register

from activities.models import Activity, Tag
from tests.unit_tests.test_accounts.factories import UserFactory

faker = FakerFactory.create()


class TagFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Tag

    name = factory.lazy_attribute(lambda x: faker.word())
    user = factory.SubFactory(UserFactory)


class ActivityFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Activity

    user = factory.SubFactory(UserFactory)
    name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3))
    nature = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3))
    tag = factory.SubFactory(TagFactory)


register(TagFactory)
register(ActivityFactory)

tests/unit_tests/test_activities/test_serializers.py

import pytest

from activities.serializers import ActivitySerializer, TagSerializer
from tests.unit_tests.test_activities.factories import ActivityFactory, TagFactory


@pytest.mark.unit
def test_serialize_activity_model():
    activity = ActivityFactory()
    serializer = ActivitySerializer(activity)

    assert serializer.data


@pytest.mark.unit
def test_activity_serialized_data(user, tag):
    t = ActivityFactory.build()
    valid_serialized_data = {
        "user": user.pk,
        "name": t.name,
        "nature": t.nature,
        "tag": tag.pk,
    }
    serializer = ActivitySerializer(data=valid_serialized_data)

    assert serializer.is_valid(raise_exception=True)
    assert serializer.errors == {}


@pytest.mark.unit
def test_tag_serializer():
    tag = TagFactory()
    serializer = TagSerializer(tag)

    assert serializer.data


@pytest.mark.unit
def test_tag_serializer_data(mocker):
    t = TagFactory.build()
    valid_serializer_data = {"user": t.user.pk, "name": t.name}

    serializer = TagSerializer(data=valid_serializer_data)

    assert serializer.is_valid(raise_exception=True)
    assert serializer.errors == {}

Continuous Integration (CI)

I used GitHub Actions and defined three .yaml files for unit tests, black formatting and coverage and uploaded the coverage report to Codecov.

Conclusion

This was a fun project to build and experience TDD at work. It also made me appreciate testing event more as I had to set up factories to use for serializers. You can try and build your own version of this API to your own specification and hope you had fun following my own account of this API.

Search
Coding Tips

Pytest assert True


Category: Django

Pytest equivalent of

unittest.TestCase.assertTrue()

is

assert some_condition.

For example

self.assertTrue(form.is_valid())

using pytest would be

assert form.is_valid().

✨Magic ✨