Build a Blog API With JWT Authentication Using Django Rest Framework
Tags: python, django, webdev, tutorial
Published: 2022-12-07 06:10:28

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](https://github.com/earthcomfy/drf-blog-api)._

## Table of Contents

1. [Prerequisite](#prerequisite)
2. [Project Configuration](#config)
3. [Custom User Model in Django for Email-Based Auth](#custom_user)
4. [User Profile](#profile)
5. [JWT Authentication](#jwt)
6. [User Registration and Login Endpoints](#auth)
7. [The Blog API](#blog)

## Prerequisite <a name="prerequisite"></a>

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

## Project Configuration <a name="config"></a>

First, create a virtual environment and activate it:

```bash
python3 -m venv .venv
source .venv/bin/activate
```

Next, install Django and create a new Django project:

```bash
pip install django==4.1.2
django-admin startproject config .
```

## Custom User Model in Django for Email-Based Auth <a name="custom_user"></a>

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.

```bash
py manage.py startapp users
```

Add it to the installed apps list in the settings:

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

    # Local apps
    'users',
]
```

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:

```python
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass
```

**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:

```python
from django.db import models

class Car(models.Model):
    pass
```

- 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:

```python
from django.db import models

class Car(models.Model):
    cars = models.Manager();
```

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](https://docs.djangoproject.com/en/4.0/topics/auth/customizing/#django.contrib.auth.models.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:

```python
# 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)
```

Then, create the custom user model as follows:

```python
# 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
```

- `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:

```python
# config/settings.py

AUTH_USER_MODEL = 'users.CustomUser'
```

Finally, create and apply migrations:

```bash
py manage.py makemigrations
py manage.py migrate
```

**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:

```python
# 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",)
```

**Admin**

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

```python
# 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",)

```

- `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 <a name="profile"></a>

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:

```python
# 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)
```

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:

```bash
pip install pillow==9.3.0
```

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

```python
# 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)
```

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

```python
# config/settings.py

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

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

```python
# 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)
```

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:

```python
# 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()
```

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:

```python
# 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
```

Test it out:

```bash
py manage.py makemigrations
py manage.py migrate
py manage.py runserver
```

## JWT Authentication <a name="jwt"></a>

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:
    
    ```bash
    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:

```bash
pip install djangorestframework==3.14.0
```

Add it to installed apps settings:

```python
# config/settings.py

INSTALLED_APPS = [
    # ...
    "rest_framework",
]
```

To implement JWT auth in our project, we are going to make use of [djangorestframework_simplejwt](https://django-rest-framework-simplejwt.readthedocs.io/en/latest/). Install it:

```bash
pip install djangorestframework-simplejwt==5.2.2
```

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

```python
# config/settings.py

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ),
}
```

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

```python
# 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),
}
```

- 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_ROTATION`setting to work. so let’s do that:

```python
# config/settings.py

# Third-party apps
INSTALLED_APPS = [
    # ...
    "rest_framework_simplejwt.token_blacklist",
]
```

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

```bash
py manage.py migrate
```

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 <a name="auth"></a>

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

```python
# 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",)
```

- Note that we have also created a serializer class for the profile.

Then in the `views.py` 

```python
# 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
```

- 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:

```python
# config/urls.py

from django.urls import include, path

urlpatterns = [
    # ...
    path("", include("users.urls", namespace="users")),
]
```

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

```python
# 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"),
]
```

- Note that the `token/refresh` endpoint will be used to get a new access and refresh token.

## The Blog API <a name="blog"></a>

First, create an app named `posts`

```bash
py manage.py startapp posts
```

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

```python
# config/settings.py

INSTALLED_APPS = [
    # ...
    "posts",
]
```

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:

```python
# 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}"
```

Now, register these models in the admin:

```python
# 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)
```

Create and run migrations:

```bash
py manage.py makemigrations
py manage.py migrate
```

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

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

```python
# 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__"
```

- 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.

```python
# 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)
```

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:

```python
# 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
```

Finally, let’s configure the URLs:

```python
# config/urls.py

from django.urls import include, path

urlpatterns = [
    # ...
    path("post/", include("posts.urls", namespace="posts")),
]
```

```python
# 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"),
]
```

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:

```python
# config/settings.py

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": (
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ),
}
```

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

```python
# config/urls.py

from django.urls import include, path

urlpatterns = [
    path("api-auth/", include("rest_framework.urls")),
]
```

*P.S. don’t forget to configure [CORS](https://pypi.org/project/django-cors-headers/) to allow in-browser requests from other origins like your React app for example.*

Happy coding! 🖤