DEV Community 👩‍💻👨‍💻

Hana Belay for Documatic

Posted on

Build a Blog API With JWT Authentication Using Django Rest Framework

Django REST framework is a powerful and flexible toolkit for building Web APIs. You can easily build a REST API using DRF and consume the endpoints from a React, Angular, or other Frontend application. DRF provides a lot of features out of the box to make the development process easier and faster. In this tutorial, we will build a blog API with the following features:

  • Custom user model where email is the unique identifier instead of email.
  • JWT-based authentication.
  • Ability to create, retrieve, update, and delete posts.
  • Like/Dislike feature for posts.
  • Ability to comment on posts.

Note:- If you encounter any issues throughout the tutorial, you can check out the code in the GitHub repository.

Table of Contents

  1. Prerequisite
  2. Project Configuration
  3. Custom User Model in Django for Email-Based Auth
  4. User Profile
  5. JWT Authentication
  6. User Registration and Login Endpoints
  7. The Blog API

Prerequisite

This guide assumes that you have intermediate-level knowledge of Django and Django Rest Framework.

Project Configuration

First, create a virtual environment and activate it:

python3 -m venv .venv
source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

Next, install Django and create a new Django project:

pip install django==4.1.2
django-admin startproject config .
Enter fullscreen mode Exit fullscreen mode

Custom User Model in Django for Email-Based Auth

One of the things that make Django really great is its built-in User model that comes with username-based authentication. In no time, you can have authentication that works out of the box. However, not all projects need username and password pair for authentication, or in some cases, you might want to include additional fields in the built-in User model. Either way, you are in luck because Django also provides room for customization. In this tutorial, we will tweak the default User model to use email as the unique primary identifier of users instead of a username.

Other use cases for a custom user model include:

  • Including other unique fields for authentication like a phone number.
  • To stack all user information, be it auth-related or non-auth fields all in the User model.

Alright, let’s now create an app named users that will contain the logic we just talked about.

py manage.py startapp users
Enter fullscreen mode Exit fullscreen mode

Add it to the installed apps list in the settings:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Local apps
    'users',
]
Enter fullscreen mode Exit fullscreen mode

There are two ways you can customize the User model. Either by extending from AbstractUser or AbstractBaseUser

What’s the difference and when should you use one over the other? Glad you asked.

  • AbstractUser: Are you satisfied with the existing fields in the built-in User model but do you want to use email as the primary unique identifier of your users or perhaps remove the username field? or do you want to add fields to the existing User? If yes, AbstractUser is the right option for you.
  • AbstractBaseUser: This class contains authentication functionality but no fields, so you have to add all the necessary fields when you extend from it. You probably want to use this to have more flexibility on how you want to handle users.

Note: If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default User model is sufficient for you. This model behaves identically to the default user model, but you’ll be able to customize it in the future if the need arises:

from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass
Enter fullscreen mode Exit fullscreen mode

Custom Model Manager

A Manager is a class that provides an interface through which database query operations are provided to Django models. You can have more than one manager for your model.

Consider this model:

from django.db import models

class Car(models.Model):
    pass
Enter fullscreen mode Exit fullscreen mode
  • To get all instances of Car, you will use Car.objects.all()

objects is the default name that Django managers use. To change this name, you can do the following:

from django.db import models

class Car(models.Model):
    cars = models.Manager();
Enter fullscreen mode Exit fullscreen mode

Now, to get all instances of car, you should use Car.cars.all()

For our custom user model, we need to define a custom manager class because we are going to modify the initial Queryset that the default Manager class returns. We do this by extending from BaseUserManager and providing two additional methods create_user and create_superuser

Create a file named managers.py inside the users app and put the following:

# users/managers.py

from django.contrib.auth.base_user import BaseUserManager
from django.utils.translation import gettext_lazy as _

class CustomUserManager(BaseUserManager):
    """
    Custom user model manager where email is the unique identifier
    for authentication instead of usernames.
    """

    def create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError(_("Users must have an email address"))
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save()
        return user

    def create_superuser(self, email, password, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        extra_fields.setdefault("is_active", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError(_("Superuser must have is_staff=True."))
        if extra_fields.get("is_superuser") is not True:
            raise ValueError(_("Superuser must have is_superuser=True."))
        return self.create_user(email, password, **extra_fields)
Enter fullscreen mode Exit fullscreen mode

Then, create the custom user model as follows:

# users/models.py

from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _

from .managers import CustomUserManager

class CustomUser(AbstractUser):
    email = models.EmailField(_("email address"), unique=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username"]

    objects = CustomUserManager()

    def __str__(self):
        return self.email
Enter fullscreen mode Exit fullscreen mode
  • USERNAME_FIELD specifies the name of the field on the user model that is used as the unique identifier. In our case it’s email.
  • REQUIRED_FIELDS A list of the field names that will be prompted when creating a superuser via the createsuperuser management command. This doesn’t have any effect in other parts of Django like when creating a user in the admin panel.

Next, we need to tell Django about the new model that should be used to represent a User. This is done as follows:

# config/settings.py

AUTH_USER_MODEL = 'users.CustomUser'
Enter fullscreen mode Exit fullscreen mode

Finally, create and apply migrations:

py manage.py makemigrations
py manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Forms

You need to extend Django's built-in UserCreationForm and UserChangeForm forms so that they can use the new user model that we are working with.

Create a file named forms.py inside the users app and add the following:

# users/forms.py

from django.contrib.auth.forms import UserChangeForm, UserCreationForm

from .models import CustomUser

class CustomUserCreationForm(UserCreationForm):
    class Meta:
        model = CustomUser
        fields = ("email",)

class CustomUserChangeForm(UserChangeForm):
    class Meta:
        model = CustomUser
        fields = ("email",)
Enter fullscreen mode Exit fullscreen mode

Admin

Tell the admin panel to use these forms by extending from UserAdmin in users/admin.py

# users/admin.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .forms import CustomUserChangeForm, CustomUserCreationForm
from .models import CustomUser

@admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
    add_form = CustomUserCreationForm
    form = CustomUserChangeForm

    model = CustomUser

    list_display = (
        "username",
        "email",
        "is_active",
        "is_staff",
        "is_superuser",
        "last_login",
    )
    list_filter = ("is_active", "is_staff", "is_superuser")
    fieldsets = (
        (None, {"fields": ("username", "email", "password")}),
        (
            "Permissions",
            {
                "fields": (
                    "is_staff",
                    "is_active",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                )
            },
        ),
        ("Dates", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": (
                    "username",
                    "email",
                    "password1",
                    "password2",
                    "is_staff",
                    "is_active",
                ),
            },
        ),
    )
    search_fields = ("email",)
    ordering = ("email",)

Enter fullscreen mode Exit fullscreen mode
  • add_form and form specify the forms to add and change user instances.
  • fieldsets specify the fields to be used in editing users and add_fieldsets specify fields to be used when creating a user.

And that’s all you need. You can now go to the admin panel and add/edit users.

User Profile

Let’s now create a user profile. This includes non-auth-related fields for the user. For now, this model contains an avatar and bio. You must already be familiar with modeling a profile for the user. Basically, this is done by using a one-to-one relationship between the User and Profile model. In a one-to-one relationship, one record in a table is associated with one and only one record in another table using a foreign key. For example - a user model instance is associated with one and only one profile instance.

Head over to users/models.py and add the following:

# users/models.py

import os

from django.conf import settings
from django.db import models
from django.template.defaultfilters import slugify

def get_image_filename(instance, filename):
    name = instance.product.name
    slug = slugify(name)
    return f"products/{slug}-{filename}"

class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    avatar = models.ImageField(upload_to=get_image_filename, blank=True)
    bio = models.CharField(max_length=200, blank=True)

    def __str__(self):
        return self.user.email

    @property
    def filename(self):
        return os.path.basename(self.image.name)
Enter fullscreen mode Exit fullscreen mode

Whenever you are using an ImageField in Django, you need to install Pillow which is one of the most common image-processing libraries in Python. Let’s install it:

pip install pillow==9.3.0
Enter fullscreen mode Exit fullscreen mode

Next, let’s register the profile model in line with the custom user model as follows:

# users/admin.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .forms import CustomUserChangeForm, CustomUserCreationForm
from .models import CustomUser, Profile

class ProfileInline(admin.StackedInline):
    model = Profile
    can_delete = False
    verbose_name_plural = "Profile"

@admin.register(CustomUser)
class CustomUserAdmin(UserAdmin):
    add_form = CustomUserCreationForm
    form = CustomUserChangeForm

    model = CustomUser

    list_display = (
        "username",
        "email",
        "is_active",
        "is_staff",
        "is_superuser",
        "last_login",
    )
    list_filter = ("is_active", "is_staff", "is_superuser")
    fieldsets = (
        (None, {"fields": ("username", "email", "password")}),
        (
            "Permissions",
            {
                "fields": (
                    "is_staff",
                    "is_active",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                )
            },
        ),
        ("Dates", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": (
                    "username",
                    "email",
                    "password1",
                    "password2",
                    "is_staff",
                    "is_active",
                ),
            },
        ),
    )
    search_fields = ("email",)
    ordering = ("email",)
    inlines = (ProfileInline,)

admin.site.register(Profile)
Enter fullscreen mode Exit fullscreen mode

Since we are working with user-uploaded images, we need to set MEDIA_URL and MEDIA_ROOT in the settings:

# config/settings.py

MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
Enter fullscreen mode Exit fullscreen mode

Next, configure the project's urls.py to serve user-uploaded media files during development.

# config/urls.py

from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # ... 
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Enter fullscreen mode Exit fullscreen mode

Before testing this out, let’s create a signal to automatically create a user profile when a user is created. Create a file named signals.py and add the following:

# users/signals.py

from django.contrib.auth import get_user_model
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Profile

User = get_user_model()

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)

@receiver(post_save, sender=User)
def save_profile(sender, instance, **kwargs):
    instance.profile.save()
Enter fullscreen mode Exit fullscreen mode

Finally, connect the receivers in the ready() method of the app's configuration by importing the signals module. Head over to users/apps.py and add the following:

# users/apps.py

from django.apps import AppConfig

class UsersConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "users"

    def ready(self):
        import users.signals
Enter fullscreen mode Exit fullscreen mode

Test it out:

py manage.py makemigrations
py manage.py migrate
py manage.py runserver
Enter fullscreen mode Exit fullscreen mode

JWT Authentication

The default authentication system that Django provides is session-based. Sessions in Django are implemented using the django.contrib.sessions.middleware.SessionMiddleware middleware. This session-based auth works well with the traditional HTML request-response cycle. However, if you have a client that expects the server to return a JSON response instead of an HTML, you are going to have to use token authentication or JWT authentication and let the client decide what to do with the JSON response. In this tutorial, we will implement JWT authentication using Django Rest Framework.

What is JWT?

JSON Web Token (JWT) is a cryptographically signed URL-safe token for securely transmitting information between parties as a JSON object.

In JWT-based auth, the following happens:

  • The client sends the username and password to the server.
  • The server validates user credentials against the database.
  • The server generates and sends to the client a secure JWT token that is signed using a secret key. This token is of the format:

    header.payload.signature
    

    Decoding tokens in the above format will give information about the user like ID, username, etc.

  • The client then includes this token in the HTTP header for subsequent requests.

  • The server verifies the token using the secret key without hitting the database. If the token has been tampered with, the client’s request will be rejected.

This token (also called access token), although customizable, is usually short-lived. Along with the access token, the server also generates and sends to the client a refresh token. A refresh token has a longer life and you can exchange it for an access token.

JWT thus is scalable and fast because of fewer database hits.

Alright, let’s first install Django Rest Framework:

pip install djangorestframework==3.14.0
Enter fullscreen mode Exit fullscreen mode

Add it to installed apps settings:

# config/settings.py

INSTALLED_APPS = [
    # ...
    "rest_framework",
]
Enter fullscreen mode Exit fullscreen mode

To implement JWT auth in our project, we are going to make use of djangorestframework_simplejwt. Install it:

pip install djangorestframework-simplejwt==5.2.2
Enter fullscreen mode Exit fullscreen mode

Then, tell DRF the authentication backend we want to use:

# config/settings.py

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}
Enter fullscreen mode Exit fullscreen mode

Here are a couple of setting variables for simple JWT that can be customized in the settings:

# config/settings.py

from datetime import timedelta

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=14),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "UPDATE_LAST_LOGIN": False,
    "ALGORITHM": "HS256",
    "SIGNING_KEY": SECRET_KEY,
    "VERIFYING_KEY": None,
    "AUDIENCE": None,
    "ISSUER": None,
    "JWK_URL": None,
    "LEEWAY": 0,
    "AUTH_HEADER_TYPES": ("Bearer",),
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
    "USER_ID_FIELD": "id",
    "USER_ID_CLAIM": "user_id",
    "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
    "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
    "TOKEN_TYPE_CLAIM": "token_type",
    "JTI_CLAIM": "jti",
    "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
    "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
    "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}
Enter fullscreen mode Exit fullscreen mode
  • If ROTATE_REFRESH_TOKENS is set to True, a new refresh token will be returned along with the access token. And if BLACKLIST_AFTER_ROTATION is set to True, refresh token submitted to the refresh view will be added to the blacklist. You need to add 'rest_framework_simplejwt.token_blacklist' to the list of installed apps for the BLACKLIST_AFTER_ROTATIONsetting to work. so let’s do that:
# config/settings.py

# Third-party apps
INSTALLED_APPS = [
    # ...
    "rest_framework_simplejwt.token_blacklist",
]
Enter fullscreen mode Exit fullscreen mode

Finally, run the following command to apply the app’s migrations:

py manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Now, we need to create access and refresh tokens when the user registers/login. In the next section, we will add serializer and views to accomplish this task.

User Registration and Login Endpoints

Create a file named serializers.py inside the users app and add the following:

# users/serializers.py

from django.contrib.auth import authenticate
from rest_framework import serializers

from .models import CustomUser, Profile

class CustomUserSerializer(serializers.ModelSerializer):
    """
    Serializer class to serialize CustomUser model.
    """

    class Meta:
        model = CustomUser
        fields = ("id", "username", "email")

class UserRegisterationSerializer(serializers.ModelSerializer):
    """
    Serializer class to serialize registration requests and create a new user.
    """

    class Meta:
        model = CustomUser
        fields = ("id", "username", "email", "password")
        extra_kwargs = {"password": {"write_only": True}}

    def create(self, validated_data):
        return CustomUser.objects.create_user(**validated_data)

class UserLoginSerializer(serializers.Serializer):
    """
    Serializer class to authenticate users with email and password.
    """

    email = serializers.CharField()
    password = serializers.CharField(write_only=True)

    def validate(self, data):
        user = authenticate(**data)
        if user and user.is_active:
            return user
        raise serializers.ValidationError("Incorrect Credentials")

class ProfileSerializer(CustomUserSerializer):
    """
    Serializer class to serialize the user Profile model
    """

    class Meta:
        model = Profile
        fields = ("bio",)

class ProfileAvatarSerializer(serializers.ModelSerializer):
    """
    Serializer class to serialize the avatar
    """

    class Meta:
        model = Profile
        fields = ("avatar",)
Enter fullscreen mode Exit fullscreen mode
  • Note that we have also created a serializer class for the profile.

Then in the views.py

# users/views.py

from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.generics import GenericAPIView, RetrieveUpdateAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken

from . import serializers
from .models import Profile

User = get_user_model()

class UserRegisterationAPIView(GenericAPIView):
    """
    An endpoint for the client to create a new User.
    """

    permission_classes = (AllowAny,)
    serializer_class = serializers.UserRegisterationSerializer

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.save()
        token = RefreshToken.for_user(user)
        data = serializer.data
        data["tokens"] = {"refresh": str(token), "access": str(token.access_token)}
        return Response(data, status=status.HTTP_201_CREATED)

class UserLoginAPIView(GenericAPIView):
    """
    An endpoint to authenticate existing users using their email and password.
    """

    permission_classes = (AllowAny,)
    serializer_class = serializers.UserLoginSerializer

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.validated_data
        serializer = serializers.CustomUserSerializer(user)
        token = RefreshToken.for_user(user)
        data = serializer.data
        data["tokens"] = {"refresh": str(token), "access": str(token.access_token)}
        return Response(data, status=status.HTTP_200_OK)

class UserLogoutAPIView(GenericAPIView):
    """
    An endpoint to logout users.
    """

    permission_classes = (IsAuthenticated,)

    def post(self, request, *args, **kwargs):
        try:
            refresh_token = request.data["refresh"]
            token = RefreshToken(refresh_token)
            token.blacklist()
            return Response(status=status.HTTP_205_RESET_CONTENT)
        except Exception as e:
            return Response(status=status.HTTP_400_BAD_REQUEST)

class UserAPIView(RetrieveUpdateAPIView):
    """
    Get, Update user information
    """

    permission_classes = (IsAuthenticated,)
    serializer_class = serializers.CustomUserSerializer

    def get_object(self):
        return self.request.user

class UserProfileAPIView(RetrieveUpdateAPIView):
    """
    Get, Update user profile
    """

    queryset = Profile.objects.all()
    serializer_class = serializers.ProfileSerializer
    permission_classes = (IsAuthenticated,)

    def get_object(self):
        return self.request.user.profile

class UserAvatarAPIView(RetrieveUpdateAPIView):
    """
    Get, Update user avatar
    """

    queryset = Profile.objects.all()
    serializer_class = serializers.ProfileAvatarSerializer
    permission_classes = (IsAuthenticated,)

    def get_object(self):
        return self.request.user.profile
Enter fullscreen mode Exit fullscreen mode
  • The above views are self-explanatory. Basically, the views for user authentication use the RefreshToken class of simple JWT to generate and send to the client refresh and access tokens. In addition, the logout view blacklists the refresh token. The other views are used to get or update a user and his/her profile.

Now, let’s hook our views in the URLs.

Head over to config/urls.py and add the users app URLs:

# config/urls.py

from django.urls import include, path

urlpatterns = [
    # ...
    path("", include("users.urls", namespace="users")),
]
Enter fullscreen mode Exit fullscreen mode

Inside the users app, create a file named urls.py and add the endpoints as follows:

# users/urls.py

from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView

from users import views

app_name = "users"

urlpatterns = [
    path("register/", views.UserRegisterationAPIView.as_view(), name="create-user"),
    path("login/", views.UserLoginAPIView.as_view(), name="login-user"),
    path("token/refresh/", TokenRefreshView.as_view(), name="token-refresh"),
    path("logout/", views.UserLogoutAPIView.as_view(), name="logout-user"),
    path("", views.UserAPIView.as_view(), name="user-info"),
    path("profile/", views.UserProfileAPIView.as_view(), name="user-profile"),
    path("profile/avatar/", views.UserAvatarAPIView.as_view(), name="user-avatar"),
]
Enter fullscreen mode Exit fullscreen mode
  • Note that the token/refresh endpoint will be used to get a new access and refresh token.

The Blog API

First, create an app named posts

py manage.py startapp posts
Enter fullscreen mode Exit fullscreen mode

and add it to the list of installed apps in the settings:

# config/settings.py

INSTALLED_APPS = [
    # ...
    "posts",
]
Enter fullscreen mode Exit fullscreen mode

Let’s wire up the models. We are going to have 3 models. The Post, Category, and Comment

A post can have many categories and comments, thus we are going to use a ManyToMany field:

# posts/models.py

from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _

class Category(models.Model):
    name = models.CharField(_("Category name"), max_length=100)

    class Meta:
        verbose_name = _("Category")
        verbose_name_plural = _("Categories")

    def __str__(self):
        return self.name

class Post(models.Model):
    title = models.CharField(_("Post title"), max_length=250)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="posts",
        null=True,
        on_delete=models.SET_NULL,
    )
    categories = models.ManyToManyField(Category, related_name="posts_list", blank=True)
    body = models.TextField(_("Post body"))
    likes = models.ManyToManyField(
        settings.AUTH_USER_MODEL, related_name="post_likes", blank=True
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("-created_at",)

    def __str__(self):
        return f"{self.title} by {self.author.username}"

class Comment(models.Model):
    post = models.ForeignKey(Post, related_name="comments", on_delete=models.CASCADE)
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name="post_comments",
        null=True,
        on_delete=models.SET_NULL,
    )
    body = models.TextField(_("Comment body"))
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ("-created_at",)

    def __str__(self):
        return f"{self.body[:20]} by {self.author.username}"
Enter fullscreen mode Exit fullscreen mode

Now, register these models in the admin:

# posts/admin.py

from django.contrib import admin

from .models import Category, Comment, Post

admin.site.register(Category)
admin.site.register(Post)
admin.site.register(Comment)
Enter fullscreen mode Exit fullscreen mode

Create and run migrations:

py manage.py makemigrations
py manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Great! let’s now set up the serializer classes and views.

Create serializers.py inside the posts app and add the following:

# posts/serializers.py

from rest_framework import serializers

from .models import Category, Comment, Post

class CategoryReadSerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = "__all__"

class PostReadSerializer(serializers.ModelSerializer):
    author = serializers.CharField(source="author.username", read_only=True)
    categories = serializers.SerializerMethodField(read_only=True)
    likes = serializers.SerializerMethodField(read_only=True)

    class Meta:
        model = Post
        fields = "__all__"

    def get_categories(self, obj):
        categories = list(
            cat.name for cat in obj.categories.get_queryset().only("name")
        )
        return categories

    def get_likes(self, obj):
        likes = list(
            like.username for like in obj.likes.get_queryset().only("username")
        )
        return likes

class PostWriteSerializer(serializers.ModelSerializer):
    author = serializers.HiddenField(default=serializers.CurrentUserDefault())

    class Meta:
        model = Post
        fields = "__all__"

class CommentReadSerializer(serializers.ModelSerializer):
    author = serializers.CharField(source="author.username", read_only=True)

    class Meta:
        model = Comment
        fields = "__all__"

class CommentWriteSerializer(serializers.ModelSerializer):
    author = serializers.HiddenField(default=serializers.CurrentUserDefault())

    class Meta:
        model = Comment
        fields = "__all__"
Enter fullscreen mode Exit fullscreen mode
  • Separating serializers for read and write is something that can be really helpful because you may sometimes want to include more details in the response for read (list and retrieve) but limit the number of fields when adding an entry to the database. This makes your serializer classes less complicated.
  • Also, note the use of serializers.CurrentUserDefault. This is really hand-in to automatically set the authenticated user as the author or owner of something.

Next, to write our views, we are going to use ViewSets. If you are new to ViewSets, here is a quick overview.

ViewSet is a type of class-based view that combines the logic for a set of related views into a single class. The 2 most common types of ViewSets that you are most likely to use are Modelviewset and ReadOnlyModelViewSet. One of the main advantages of ViewSets is to have URL endpoints automatically defined for you through Routers

ViewSets have the highest level of abstraction and you can use them to avoid writing all the code for basic and repetitive stuff. They are a huge time-saver!

That being said, add the following code in the views and read the comments for further explanation.

# posts/views.py

from django.shortcuts import get_object_or_404
from rest_framework import permissions, status, viewsets
from rest_framework.response import Response
from rest_framework.views import APIView

from posts.models import Category, Comment, Post
from posts.serializers import (
    CategoryReadSerializer,
    CommentReadSerializer,
    CommentWriteSerializer,
    PostReadSerializer,
    PostWriteSerializer,
)

from .permissions import IsAuthorOrReadOnly

# Category is going to be read-only, so we use ReadOnlyModelViewSet
class CategoryViewSet(viewsets.ReadOnlyModelViewSet):
    """
    List and Retrieve post categories
    """

    queryset = Category.objects.all()
    serializer_class = CategoryReadSerializer
    permission_classes = (permissions.AllowAny,)

class PostViewSet(viewsets.ModelViewSet):
    """
    CRUD posts
    """

    queryset = Post.objects.all()

    # In order to use different serializers for different 
    # actions, you can override the 
    # get_serializer_class(self) method
    def get_serializer_class(self):
        if self.action in ("create", "update", "partial_update", "destroy"):
            return PostWriteSerializer

        return PostReadSerializer

    # get_permissions(self) method helps you separate 
    # permissions for different actions inside the same view.
    def get_permissions(self):
        if self.action in ("create",):
            self.permission_classes = (permissions.IsAuthenticated,)
        elif self.action in ("update", "partial_update", "destroy"):
            self.permission_classes = (IsAuthorOrReadOnly,)
        else:
            self.permission_classes = (permissions.AllowAny,)

        return super().get_permissions()

class CommentViewSet(viewsets.ModelViewSet):
    """
    CRUD comments for a particular post
    """

    queryset = Comment.objects.all()

    def get_queryset(self):
        res = super().get_queryset()
        post_id = self.kwargs.get("post_id")
        return res.filter(post__id=post_id)

    def get_serializer_class(self):
        if self.action in ("create", "update", "partial_update", "destroy"):
            return CommentWriteSerializer

        return CommentReadSerializer

    def get_permissions(self):
        if self.action in ("create",):
            self.permission_classes = (permissions.IsAuthenticated,)
        elif self.action in ("update", "partial_update", "destroy"):
            self.permission_classes = (IsAuthorOrReadOnly,)
        else:
            self.permission_classes = (permissions.AllowAny,)

        return super().get_permissions()

# Here, we are using the normal APIView class
class LikePostAPIView(APIView):
    """
    Like, Dislike a post
    """

    permission_classes = (permissions.IsAuthenticated,)

    def get(self, request, pk):
        user = request.user
        post = get_object_or_404(Post, pk=pk)

        if user in post.likes.all():
            post.likes.remove(user)

        else:
            post.likes.add(user)

        return Response(status=status.HTTP_200_OK)
Enter fullscreen mode Exit fullscreen mode

We haven’t created custom permission to limit edit and delete actions to the owner of a post, so let’s go ahead and do that. Create a file named permissions.py inside the posts app and add the following:

# posts/permissions.py

from rest_framework import permissions

class IsAuthorOrReadOnly(permissions.BasePermission):
    """
    Check if authenticated user is author of the post.
    """

    def has_permission(self, request, view):
        return request.user.is_authenticated is True

    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True

        return obj.author == request.user
Enter fullscreen mode Exit fullscreen mode

Finally, let’s configure the URLs:

# config/urls.py

from django.urls import include, path

urlpatterns = [
    # ...
    path("post/", include("posts.urls", namespace="posts")),
]
Enter fullscreen mode Exit fullscreen mode
# posts/urls.py

from django.urls import include, path
from rest_framework.routers import DefaultRouter

from .views import CategoryViewSet, CommentViewSet, LikePostAPIView, PostViewSet

app_name = "posts"

router = DefaultRouter()
router.register(r"categories", CategoryViewSet)
router.register(r"^(?P<post_id>\d+)/comment", CommentViewSet)
router.register(r"", PostViewSet)

urlpatterns = [
    path("", include(router.urls)),
    path("like/<int:pk>/", LikePostAPIView.as_view(), name="like-post"),
]
Enter fullscreen mode Exit fullscreen mode

Great! that’s all. You can use Postman or the built-in browsable API to test the endpoints. Note that, if you use the browsable API, you need to add session authentication to the DEFAULT_AUTHENTICATION_CLASSES because the browsable API uses session authentication for the login form. To do so, head over to the settings and update the DEFAULT_AUTHENTICATION_CLASSES setting:

# config/settings.py

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ),
}
Enter fullscreen mode Exit fullscreen mode

Then, in the project’s urls.py file add the API URL:

# config/urls.py

from django.urls import include, path

urlpatterns = [
    path("api-auth/", include("rest_framework.urls")),
]
Enter fullscreen mode Exit fullscreen mode

P.S. don’t forget to configure CORS to allow in-browser requests from other origins like your React app for example.

Happy coding! 🖤

Top comments (6)

Collapse
 
onyenzedon profile image
Onyenzedon

If only I could have your one on one tutelage. Or a video tutorial of this course. This tutorial has all that I've been looking for.

Collapse
 
earthcomfy profile image
Hana Belay

I am glad. You can reach out if you need more explanation on the topics discussed here. I will be happy to help.

Collapse
 
naubit profile image
Al - Naubit

Hey, it was a nice read, you got my follow, keep writing!

Collapse
 
earthcomfy profile image
Hana Belay

Thank you

Collapse
 
deotyma profile image
Deotyma

Thanks

Collapse
 
earthcomfy profile image
Hana Belay

😊

Regex for lazy developers

regex for lazy devs

You know who you are. Sorry for the callout 😆