😸

Django REST Framework を使用したバックエンド開発

2023/04/20に公開

はじめに

最近、DRFとReactの勉強をしていたのですが、基本的なToDoアプリを作成した後に何を作ろうか迷ってました。
そんな中で以下のURLを見つけて、Mediumのようなアプリを構築するのにいろんなフレームワークで書かれているコードが用意されていました。そのため、似たようなアプリをフロントエンドはReactで、バックエンドはDRFを選択して作っていこうと思います。

元のRealWorldと異なる点としては、1つ目はダッシュボードも作成したいので値段がついた本を記録するようなアプリに変更したこと、2つ目は練習として最低限の書き方の習得を目指したのでフォローや記事のお気に入りは除きました。フォローや記事のお気に入りは以下のGitHubの方のコードをそのまま追記すれば動くと思います。

[https://codebase.show/projects/realworld?category=frontend&language=all:embed:cite]
[https://github.com/gothinkster/django-realworld-example-app:embed:cite]

コード

djangoプロジェクト作成

カスタムユーザー用にaccounts、APIのVersion切り替えが可能な用にapiv1、本用にbooksアプリを作成します。

mkdir backend
cd backend/
django-admin startproject config .
python manage.py startapp accounts
python manage.py startapp apiv1
python manage.py startapp books

`config/settings.py``を変えて、アプリなどのインポートをします。
以下では、APIドキュメント用の設定(drf_spectacular)やカスタムユーザーモデルでのJWT認証の設定も追加しています。

from datetime import timedelta
from pathlib import Path

...

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

    # 3dr paty apps
    'rest_framework',
    'django_filters',
    'drf_spectacular',
    'djoser',
    'rest_framework_simplejwt',

    # my apps
    'accounts.apps.AccountsConfig',
    'apiv1.apps.Apiv1Config',
    'books.apps.BooksConfig',
]

...

# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/

LANGUAGE_CODE = 'ja'

TIME_ZONE = 'Asia/Tokyo'

...

# REST_FRAMEWORK 
REST_FRAMEWORK = {
    'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 

    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}

# custom user model
AUTH_USER_MODEL = 'accounts.User'

# Configure the JWT settings
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
}

config/urls.pyにAPIドキュメントを開く際のパスを書いておきます。これによりDEBUG時にapi/schema/にアクセスすることでAPIドキュメントを見ることができます。

from django.conf import settings
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView


urlpatterns = [
    path('admin/', admin.site.urls),
]

if settings.DEBUG:
    urlpatterns += [
        path('api/schema/', SpectacularAPIView.as_view(), name='schema'),                                      # 追加
        path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),  # 追加
        path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),              # 追加
    ]

カスタムユーザーモデルのJWT認証

ユーザーモデルは後で変えようと思った時に大変なため、最初の段階でカスタムユーザーモデルを実装します。
また、認証はrest_framework_simplejwtdjoserを用いてJWT認証を実装します。
カスタムユーザーモデルを作る前にmigrateしてしまうとエラーが発生する可能性があるので注意してください。

accounts/models.pyに以下のようにUserモデルとカスタムユーザーモデルに必須なCustomUserManagerを定義しています。
ここでログイン時に必要なフィールドとしてemailを指定しています。また、今回は必要ないのですが、次のアプリの時用に役職roleフィールドを追加しています。

import uuid

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


class User(AbstractBaseUser, PermissionsMixin):
  # Choices of ROLE
  ADMIN = 1
  MANAGER = 2
  EMPLOYEE = 3

  ROLE_CHOICES = (
    (ADMIN, 'Admin'),
    (MANAGER, 'Manager'),
    (EMPLOYEE, 'Employee')
  )

  id = models.UUIDField(primary_key=True, unique=True, editable=False, default=uuid.uuid4)
  email = models.EmailField(db_index=True, unique=True)
  username = models.CharField(db_index=True, max_length=50, unique=True)
  role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, blank=True, null=True, default=3)
  # ユーザーを削除せずにアクティブではなくするためのFlag
  is_active = models.BooleanField(default=True)
  # admin pageにログインできるかどうかのFlag
  is_staff = models.BooleanField(default=False)
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

  # ログイン時に使用するField
  USERNAME_FIELD = 'email'
  # 必要なField
  REQUIRED_FIELDS = ['username']

  # Custom User objectsを管理するためのMnagerクラス
  objects = CustomUserManager()

  def __str__(self):
    return self.email

以下のようにカスタムユーザーモデルのためにManagerクラスを定義します。

class CustomUserManager(BaseUserManager):
  """ 
  custom userの場合、custom User Managerを作成する必要がある。
  userとsuperuserを作る関数のみを上書きする。
  """
  def create_user(self, username, email, password, **extra_fields):
    if not username:
      raise ValueError('The username must be set.')
    if not email:
      raise ValueError('The email must be set.')
    if not password:
      raise ValueError('The password must be set.')

    user = self.model(username=username, email=self.normalize_email(email), **extra_fields)
    user.set_password(password)
    user.save()

    return user

  def create_superuser(self, username, email, password, **extra_fields):
    if not username:
      raise ValueError('The username must be set.')
    if not email:
      raise ValueError('The email must be set.')
    if not password:
      raise ValueError('The password must be set.')

    extra_fields.setdefault('is_active', True)
    extra_fields.setdefault('role', 1)
    if extra_fields.get('role') != 1:
      raise ValueError('Superuser must have role of Global Admin')
    
    user = self.create_user(username, email, password, **extra_fields)
    user.is_superuser = True
    user.is_staff = True
    user.save()

    return user

accounts/urls.pydjoserを使用したパスを追加します。

from django.urls import path, include

urlpatterns = [
    path('',include('djoser.urls')),
    path('',include('djoser.urls.jwt')),
]

また、config/urls.pyaccounts/urls.pyを追加するコードを書きます。

from django.conf import settings
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/auth/', include('accounts.urls')),
]

if settings.DEBUG:
    urlpatterns += [
        path('api/schema/', SpectacularAPIView.as_view(), name='schema'),                                      # 追加
        path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),  # 追加
        path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),              # 追加
    ]

カスタムユーザー周りの最後にadminページでユーザー情報をいじれるようにしていきます。
こちらを参考にしました。
account/forms.pyを新しく作成します。

from django import forms
from django.contrib.auth.forms import ReadOnlyPasswordHashField

from .models import User


class UserCreationForm(forms.ModelForm):
    password1 = forms.CharField(label='Password', widget=forms.PasswordInput)
    password2 = forms.CharField(
        label='Password confirmation', widget=forms.PasswordInput)

    class Meta:
        model = User
        fields = ('username', 'email')

    def clean_password2(self):
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")
        if password1 and password2 and password1 != password2:
            raise forms.ValidationError("Passwords don't match")
        return password2

    def save(self, commit=True):
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user


class UserChangeForm(forms.ModelForm):
    password = ReadOnlyPasswordHashField()

    class Meta:
        model = User
        fields = ('username', 'email', 'password',
                  'role', 'is_active', 'is_staff')

また、accounts/admin.pyに以下を追記します。
ここでは、Userを追加し、デフォルトのGroupを削除してます。

from django.contrib import admin
from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .forms import UserChangeForm, UserCreationForm
from .models import User


# Register your models here.
class UserAdmin(BaseUserAdmin):
    form = UserChangeForm
    add_form = UserCreationForm

    list_display = ('email', 'username', 'role', 'is_active', 'is_staff')
    list_filter = ('is_staff',)
    fieldsets = (
        (None, {'fields': ('username', 'email', 'password')}),
        ('Personal info', {'fields': ('role',)}),
        ('Permissions', {'fields': ('is_staff', 'is_active')}),
    )

    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('email', 'username', 'password1', 'password2')}
         ),
    )
    search_fields = ('email',)
    ordering = ('email',)
    filter_horizontal = ()


admin.site.register(User, UserAdmin)
admin.site.unregister(Group)

ここまで来たらmigrateやユーザーの作成を行い、動作確認をしていきましょう。
ここでsuperuserを作成するときに独自の入力としてemailとusername、passwordが聞かれます。
(ここでは、デモなのでadmin@example.com, admin, pass12345を設定しました。)

python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

djoserのendpointsとしては以下があります。

/users/
/users/me/
/users/confirm/
/users/resend_activation/
/users/set_password/
/users/reset_password/
/users/reset_password_confirm/
/users/set_username/
/users/reset_username/
/users/reset_username_confirm/
/token/login/ (Token Based Authentication)
/token/logout/ (Token Based Authentication)
/jwt/create/ (JSON Web Token Authentication)
/jwt/refresh/ (JSON Web Token Authentication)
/jwt/verify/ (JSON Web Token Authentication)

以下のように使用することができます。

新規ユーザーの作成
以下を実行した後に、実際にユーザーが作成されているかどうかhttp://localhost:8000/admin/で確認しましょう。

curl -X POST http://127.0.0.1:8000/api/auth/users/ --data 'email=test1@example.com&username=test1&password=pass12345'

この段階ではユーザーが新規に作成されただけなので、以下のようなログイン情報を取得できません。

curl -LX GET http://127.0.0.1:8000/api/auth/users/me/

ログイン

先ほど作成したユーザー情報でjwt/create/にアクセスすることでトークンを作成できます。
ちなみに、作成されていないユーザーですとアクティブなユーザーでないというレスポンスが返されます。

curl -X POST http://127.0.0.1:8000/api/auth/jwt/create/ --data 'email=test1@example.com&username=test1&password=pass12345'

上で取得したaccess tokenを利用するとログイン情報を取得できます。

curl -LX GET http://127.0.0.1:8000/api/auth/users/me/ -H 'Authorization: Bearer <access_token>'

パスワードの変更

何が違うのかわからないですが、uidの箇所でエラーが出て、users/reset_password_confirm/がうまくできませんでした。
とりあえず、管理者側からは以下のようなコマンドで変更できます。

python manage.py changepassword <user_name>

Book作成

books/models.pyにBookモデルを定義します。この時、作成者をownerとして定義し、ユーザーが削除されても関連するレコードが削除されないようにon_delete=models.SET_NULLを指定します。

import uuid
from django.db import models


class Book(models.Model):
  """ Book Model """

  class Meta:
    db_table = 'book'
    ordering = ['created_at',]
    verbose_name = verbose_name_plural = '本'
  
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  title = models.CharField(verbose_name="タイトル", max_length=50)
  content = models.TextField(verbose_name="内容", max_length=1000, null=True, blank=True)
  price = models.IntegerField(verbose_name='価格', null=True)
  owner = models.ForeignKey('accounts.User', verbose_name="作成者", on_delete=models.SET_NULL, null=True)
  
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

  def __str__(self):
    return self.title

また、adminでいじれるようにbooks/admin.pyに以下を追記します。

from django.contrib import admin

from .models import Book


class BookModelAdmin(admin.ModelAdmin):
  list_display = ('title', 'content', 'price', 'owner','created_at', 'updated_at',)
  ordering = ('-created_at',)
  readonly_fields = ('id', 'owner', 'created_at', 'updated_at', )

admin.site.register(Book, BookModelAdmin)

新しくテーブルを作ります。
その後、http://localhost:8000/admin/にアクセスすると

python manage.py makemigrations books
python manage.py migrate books

Foreign keyがある場合は以下のようにapiv1/serializers.pyにSerializerを定義します。

from rest_framework import serializers

from books.models import Book
from accounts.models import User


class UserSerializer(serializers.ModelSerializer):
  class Meta:
    model = User
    fields = ('username', 'email',)
    read_only_fields = ('username', 'email',)


class BookSerializer(serializers.ModelSerializer):
  """ Serializer for Book Model """

  owner = UserSerializer(read_only=True)

  class Meta:
      model = Book
      # publisher_id: モデルには存在しない追加する新フィールド
      fields = ['id', 'title', 'content', 'price', 'owner']

apiv1/views.pyに以下のようなViewを設定します。 serializer.save(owner=request.user)で作成者を保存登録してます。

import re
from django.shortcuts import get_object_or_404
from django_filters import rest_framework as filters
from drf_spectacular.utils import extend_schema
from rest_framework import status, views, viewsets, generics, mixins
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import (
    AllowAny, IsAuthenticated, IsAuthenticatedOrReadOnly
)

from books.models import Book
from .serializers import BookSerializer


class StandardResultsSetPagination(LimitOffsetPagination):
    default_limit = 2
    max_limit = 100

class BookFilter(filters.FilterSet):
  """ Bookモデル用のフィルタ """

  # 「price__lte」というキーで「priceが?円以下」という条件でフィルタリング可能に
  price__lte = filters.NumberFilter(field_name='price', lookup_expr='lte')

  class Meta:
    model = Book
    # フィルタリング可能なfieldを指定
    fields = '__all__'

class BookFilter(filters.FilterSet):
  """ Bookモデル用のフィルタ """

  # 「price__lte」というキーで「priceが?円以下」という条件でフィルタリング可能に
  price__lte = filters.NumberFilter(field_name='price', lookup_expr='lte')

  class Meta:
    model = Book
    # フィルタリング可能なfieldを指定
    fields = '__all__'

class BookViewSet(mixins.CreateModelMixin, 
                  mixins.ListModelMixin,
                  mixins.RetrieveModelMixin,
                  mixins.UpdateModelMixin,
                  mixins.DestroyModelMixin,
                  viewsets.GenericViewSet):
    queryset = Book.objects.all()
    #permission_classes = (IsAuthenticated,)
    serializer_class = BookSerializer
    pagination_class = StandardResultsSetPagination
    
    def create(self, request):

        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save(owner=request.user)

        return Response(serializer.data, status=status.HTTP_201_CREATED)

    def list(self, request):
        #queryset = self.filter_queryset(self.get_queryset())
        filterset = BookFilter(request.query_params, queryset=self.queryset)
        if not filterset.is_valid():
            raise ValidationError(filterset.errors)

        page = self.paginate_queryset(filterset.qs)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(filterset.qs, many=True)
        return Response(serializer.data)

    def retrieve(self, request, pk):

        book = get_object_or_404(Book, pk=pk)

        serializer = self.serializer_class(instance=book)

        return Response(serializer.data, status=status.HTTP_200_OK)

    def update(self, request, pk):
        instance = get_object_or_404(Book, pk=pk)

        serializer = self.serializer_class(
            instance, 
            data=request.data, 
            partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data, status=status.HTTP_200_OK)

    def destroy(self, request, pk):
        book = get_object_or_404(Book, pk=pk)

        book.delete()

        return Response(None, status=status.HTTP_204_NO_CONTENT)

最後にapiv1/urls.pyにルートを記載します。

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

from . import views

router = routers.DefaultRouter()
router.register('books', views.BookViewSet)

app_name = 'apiv1'
urlpatterns = [
  path('', include(router.urls)),
]

CREATE

以下のコマンドでユーザーに紐づいたBookオブジェクトを登録できます。

curl -X POST http://127.0.0.1:8000/api/v1/books/ --data 'title=title6' -H 'Authorization: Bearer <access_token>'

GET

curl -LX GET http://127.0.0.1:8000/api/v1/books/ -H 'Authorization: Bearer <access_token>'

RETRIEVE

curl -LX GET http://127.0.0.1:8000/api/v1/books/<book_id>/ -H 'Authorization: Bearer <access_token>'

PUT

curl -X PUT http://127.0.0.1:8000/api/v1/books/<book_id>/ --data 'content=changed'  -H 'Authorization: Bearer <access_token>'

DESTOROY

curl -LX DELETE http://127.0.0.1:8000/api/v1/books/<book_id>/ -H 'Authorization: Bearer <access_token>'

コメント実装

books/models.pyに以下のモデルを定義します。

class Comment(models.Model):
  """ Comment Model """

  class Meta:
    db_table = 'comment'
    ordering = ['created_at',]
    verbose_name = verbose_name_plural = 'コメント' 

  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  body = models.TextField()
  book = models.ForeignKey('books.Book', related_name='comments', on_delete=models.CASCADE)
  owner = models.ForeignKey('accounts.User', related_name='comments', on_delete=models.CASCADE)
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

books/admin.pyに以下を追記します。

class CommentModelAdmin(admin.ModelAdmin):
  list_display = ('body', 'book', 'owner','created_at', 'updated_at',)
  ordering = ('-created_at',)
  readonly_fields = ('id', 'created_at', 'updated_at', ) 

admin.site.register(Comment, CommentModelAdmin)

モデルを追加したのでマイグレートしておきましょう。

python manage.py makemigrations books
python manage.py migrate books

Bookと同じようにコメントのシリアライザを作成します。

from books.models import Book, Comment

class CommentSerializer(serializers.ModelSerializer):
  """ Serializer for Comment Model """

  owner = UserSerializer(read_only=True)
  book = BookSerializer(read_only=True)

  class Meta:
    model = Comment
    fields = ['id', 'body', 'book', 'owner']

viewは以下のように作成します。
books/<book_id>/comments/'というURLでアクセスしたいので、lookup_fieldlookup_url_kwargbook__idbook_idを設定しています。これにより、BookのUIDに紐づいたコメントを作成/取得できます。
createはBookViewSetのcreateとほとんど同じでbook instanceを取得してownerと同時に登録しています。
また、listでは、filter_querysetによりbook_idでフィルタリングを行い、bookに関連するコメントのみ取得しています。

class CommentListCreateAPIView(mixins.CreateModelMixin,
                                mixins.ListModelMixin,
                                viewsets.GenericViewSet):
    lookup_field = 'book__id'
    lookup_url_kwarg = 'book_id'
    queryset = Comment.objects.all()
    #permission_classes = (IsAuthenticated,)
    serializer_class = CommentSerializer

    def filter_queryset(self, queryset):
        # The built-in list function calls `filter_queryset`. Since we only
        # want comments for a specific article, this is a good place to do
        # that filtering.
        filters = {self.lookup_field: self.kwargs[self.lookup_url_kwarg]}

        return queryset.filter(**filters)
    
    def create(self, request, book_id=None):
        print("book_id", book_id)
        book = get_object_or_404(Book, id=book_id)
        serializer = self.serializer_class(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save(owner=request.user, book=book)

        return Response(serializer.data, status=status.HTTP_201_CREATED)

    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

class CommentDestroyAPIView(mixins.DestroyModelMixin,
                             viewsets.GenericViewSet):
    queryset = Comment.objects.all()
    #permission_classes = (IsAuthenticated,)
    serializer_class = CommentSerializer

    def destroy(self, request, pk):
        comment = get_object_or_404(Comment, pk=pk)

        comment.delete()

        return Response(None, status=status.HTTP_204_NO_CONTENT)

apiv1/urls.pyにViewを追加します。routerで登録しない場合はas_view()の中でpost, getなどを指定してあげる必要があります。

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

from . import views

router = routers.DefaultRouter()
router.register('books', views.BookViewSet)

app_name = 'apiv1'
urlpatterns = [
  path('', include(router.urls)),
  path('books/<book_id>/comments/', views.CommentListCreateAPIView.as_view({'post': 'create', 'get': 'list'})),
  path('books/comments/<pk>/', views.CommentDestroyAPIView.as_view({'get': 'destroy'})),
]

コメント追加

curl -X POST http://127.0.0.1:8000/api/v1/books/<book_id>/comments/ --data 'body=body1' -H 'Authorization: Bearer <access_token>'

コメント取得

curl -LX GET http://127.0.0.1:8000/api/v1/books/<book_id>/comments/

コメント削除

curl -LX GET http://127.0.0.1:8000/api/v1/books/comments/<comment_id>

タグ作成

本につけるタグを作成します。

まずはbooks/models.pyにTagモデルを作成していきます。
また、タグと本は多対多の関係なので、models.ManyToManyFieldを使用しています。

class Tag(models.Model):
  """ Tag Model """

  class Meta:
    db_table = 'tag'
    ordering = ['created_at',]
    verbose_name = verbose_name_plural = 'タグ'    

  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  content = models.CharField(max_length=255)
  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

  def __str__(self):
    return self.content


class Book(models.Model):
  """ Book Model """

  class Meta:
    db_table = 'book'
    ordering = ['created_at',]
    verbose_name = verbose_name_plural = '本'
  
  id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
  title = models.CharField(verbose_name="タイトル", max_length=50)
  content = models.TextField(verbose_name="内容", max_length=1000, null=True, blank=True)
  price = models.IntegerField(verbose_name='価格', null=True)
  owner = models.ForeignKey('accounts.User', verbose_name="作成者", on_delete=models.SET_NULL, null=True, related_name='books')

  tags = models.ManyToManyField('books.Tag', related_name='books')

  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

  def __str__(self):
    return self.title

apiv1/serializers.pyを以下のように作成します。
ここで、タグを本につけて登録する際に、今までにないタグの場合は同時に登録したいのでserializers.RelatedFieldを継承したFieldを作成しています。
継承している箇所は難しいのですが、既存のPrimaryKeyRelatedFieldの中身を見ると理解しやすいです。(こちらを参考にしました。)
to_representationはオブジェクトのpkを返すようになっています。また、to_internal_valueではそれをdataという引数で受け取って、モデルインスタンスを返す処理になっているようです。

class PrimaryKeyRelatedField(RelatedField):

    def to_internal_value(self, data):
        return self.get_queryset().get(pk=data)

    def to_representation(self, value):
        return value.pk

今回はTagを受け取った際にそのTagが未登録である場合も考えられます。
そのため、to_internal_valueの中でTag.objects.get_or_create(**data)を使用して、
未登録であればCreateする、登録済みであればGetするという処理になっています。

class TagSerializer(serializers.ModelSerializer):
  """ Serializer for Tag Model """

  class Meta:
    model = Tag
    fields = ['id', 'content']

class TagRelatedField(serializers.RelatedField):
    """ methodがいつ呼び出されるか不明?? """
    def get_queryset(self):
        return Tag.objects.all()

    def to_internal_value(self, data):
        """ tagがすでに登録されていないTagの時はcreate, ある時はget """
        # data = {"content": "something"}
        tag, created = Tag.objects.get_or_create(**data)

        return tag

    def to_representation(self, value):
        return value.content

class BookSerializer(serializers.ModelSerializer):
  """ Serializer for Book Model """

  owner = UserSerializer(read_only=True)
  tagList = TagRelatedField(many=True, source='tags')

  class Meta:
      model = Book
      # publisher_id: モデルには存在しない追加する新フィールド
      fields = ['id', 'title', 'content', 'price', 'owner', 'tagList']

  def create(self, validated_data):
    """ tagを同時に追加できるようにする """
    tags = validated_data.pop('tags', [])
    book = Book.objects.create(**validated_data)

    for tag in tags:
      book.tags.add(tag)

    return book

BookのViewは変える必要ないのですが、Tagを取得するViewだけ追加しておきます。
apiv1/views.pyに以下を追加します。

from books.models import Book, Comment, Tag
from .serializers import BookSerializer, CommentSerializer, TagSerializer

class TagFilter(filters.FilterSet):
  """ Tagモデル用のフィルタ """

  class Meta:
    model = Tag
    # フィルタリング可能なfieldを指定
    fields = '__all__'

class TagListAPIView(mixins.ListModelMixin,
                  viewsets.GenericViewSet):
    queryset = Tag.objects.all()
    #permission_classes = (IsAuthenticated,)
    serializer_class = TagSerializer

    def list(self, request):
        #queryset = self.filter_queryset(self.get_queryset())
        filterset = TagFilter(request.query_params, queryset=self.queryset)
        if not filterset.is_valid():
            raise ValidationError(filterset.errors)

        serializer = self.get_serializer(filterset.qs, many=True)
        return Response(serializer.data)

これに合うようにURLを追加しましょう。

app_name = 'apiv1'
urlpatterns = [
  path('', include(router.urls)),
  path('books/<book_id>/comments/', views.CommentListCreateAPIView.as_view({'post': 'create', 'get': 'list'})),
  path('books/comments/<pk>/', views.CommentDestroyAPIView.as_view({'get': 'destroy'})),
  path('tags/', views.TagListAPIView.as_view({'get': 'list'}))
]

タグ付きBook追加

curl -X POST http://127.0.0.1:8000/api/v1/books/ -d '{"title": "title7", "tagList": [{"content": "Action"}]}' -H 'Content-Type: application/json' -H 'Authorization: Bearer <accesss_token>'

タグの取得

curl -LX GET http://127.0.0.1:8000/api/v1/tags/

ユーザーに紐づくプロファイル作成

profilesアプリを作成します。また、既にユーザー等を作成している場合はDBをリセットする必要があります。

python manage.py startapp profiles

config/settinngs.pyにアプリを追加します。
また、画像をアップロードするためMEDIAを追加しています。

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

    # 3dr paty apps
    'rest_framework',
    'django_filters',
    'drf_spectacular',
    'djoser',
    'rest_framework_simplejwt',

    # my apps
    'accounts.apps.AccountsConfig',
    'apiv1.apps.Apiv1Config',
    'books.apps.BooksConfig',
    'profiles.apps.ProfilesConfig',
]

...

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

config/urls.pyにDEBUG時のMEDIAのPATHを追加します。

from django.conf import settings
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/auth/', include('accounts.urls')),
    path('api/v1/', include('apiv1.urls')),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

profiles/models.pyにユーザーと紐づくProfile Modelを作成します。

from statistics import mode
import uuid
from django.db import models


class Profile(models.Model):
  """ Profile Model """

  class Meta:
    db_table = 'profile'
    ordering = ['created_at',]
    verbose_name = verbose_name_plural = 'プロファイル'
  
  user = models.OneToOneField('accounts.User', on_delete=models.CASCADE)
  bio = models.TextField(blank=True, null=True)
  image = models.FileField(upload_to="images/", blank=True, null=True)

  created_at = models.DateTimeField(auto_now_add=True)
  updated_at = models.DateTimeField(auto_now=True)

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

また、ユーザー作成と同時にプロファイルも作成されるようにsignalを作成します。
accounts/sigals.pyに以下を追加します。djoser.signals.user_registeredによりユーザーが登録された際に実行する処理を書くことができます。

from django.dispatch import receiver
from djoser.signals import user_registered

from profiles.models import Profile


@receiver(user_registered)
def create_related_profile(user, request, **kwargs):
    Profile.objects.create(user=user)

また、signalを認識できるようにaccounts/apps.pyready()を書く必要があります。

from django.apps import AppConfig


class AccountsConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'accounts'

    def ready(self):
        try:
            from . import signals
        except ImportError:
            raise Exception("Error: Cannot import accounts/signals")

apiv1/serializers.pyに以下のシリアライザを定義します。
ここで、画像のアップロード用とそれ以外用の2つ作成します。

#
# Profile
#
class ProfileSerializer(serializers.ModelSerializer):
  """ Serializer for Profile Model """

  user = UserSerializer(read_only=True)

  class Meta:
    model = Profile 
    fields = ['user', 'bio', 'image'] 
    read_only_fields = ('image',) 

class ProfileImageSerializer(serializers.ModelSerializer):
  """ Serializer for Profile Model """
  
  user = UserSerializer(read_only=True)
  image = serializers.ImageField(use_url=True)

  class Meta:
    model = Profile 
    fields = '__all__' 

apiv1/views.pyにviewを定義します。
ファイルをアップロードするときはparserparser_classes = [FormParser, MultiPartParser]を定義します。

#
# Profile
#
class ProfileViewSet(mixins.RetrieveModelMixin,
                     mixins.UpdateModelMixin,
                     viewsets.GenericViewSet):
    queryset = Profile.objects.all()
    #permission_classes = (IsAuthenticated,)
    serializer_class = ProfileSerializer 
    
    def retrieve(self, request, username):

        profile = self.queryset.get(user__username=username)

        serializer = self.serializer_class(instance=profile)

        return Response(serializer.data, status=status.HTTP_200_OK)

    def update(self, request, username):
        instance = get_object_or_404(Profile, user__username=username)

        serializer = self.serializer_class(
            instance, 
            data=request.data, 
            partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data, status=status.HTTP_200_OK)

class ProfileImageViewSet(mixins.UpdateModelMixin,
                          viewsets.GenericViewSet):
    queryset = Profile.objects.all()
    #permission_classes = (IsAuthenticated,)
    serializer_class = ProfileImageSerializer 
    parser_classes = [FormParser, MultiPartParser]
    
    def update(self, request, username):

        instance = get_object_or_404(Profile, user__username=username)

        serializer = self.serializer_class(
            instance, 
            data=request.data,
            partial=True
        )
        serializer.is_valid(raise_exception=True)
        serializer.save()

        return Response(serializer.data, status=status.HTTP_200_OK)

apiv1/urls.pyに以下を定義します。<username>でユーザー名を受け取り処理できるようにしています。

app_name = 'apiv1'
urlpatterns = [
  path('', include(router.urls)),
  path('books/<book_id>/comments/', views.CommentListCreateAPIView.as_view({'post': 'create', 'get': 'list'})),
  path('books/comments/<pk>/', views.CommentDestroyAPIView.as_view({'get': 'destroy'})),
  path('tags/', views.TagListAPIView.as_view({'get': 'list'})),
  path('profiles/<username>/', views.ProfileViewSet.as_view({'get': 'retrieve', 'post': 'update'})),
  path('profiles/image/<username>/', views.ProfileImageViewSet.as_view({'post': 'update'})),
]

取得

curl -LX GET http://127.0.0.1:8000/api/v1/profiles/<username>/  -H 'Authorization: Bearer <access_token>'

アップロード

ファイルパスは@の後に書く必要があります。

curl -X POST http://127.0.0.1:8000/api/v1/profiles/image/<username>/ -F 'image=@<image_path>' -H 'Content-type: multipart/form-data' -H 'Authorization: Bearer <access_token>'

最後に

ここまででひとまずフロントエンド側の開発に進もうと思います。
まだダッシュボード用のAPIなどを用意してないのですが、追加したら追記していこうと思います。
DRFは英語・日本語ともに参考になる記事が少なく大変でした。同じことをしたい人に少しでも参考にしてもらえればと思います。

Discussion