[day-50] DRF로 블로그 만들기, TDD

Joohyung Park·2024년 3월 18일
0

[모두연] 오름캠프

목록 보기
81/95

DRF를 이용하여 간단한 블로그를 설계한다.

URL 설계

URL기능비회원 접근회원 접근작성자 접근
/notice자유 게시판RR, C-
/notice/int:post_pk자유 게시물 상세보기RRR, U, D
/blog회원 게시판-R, C-
/blog/int:post_pk회원 게시물 상세보기-RR, U, D

기능 설명:

  • R: Read (읽기)
  • C: Create (생성)
  • U: Update (수정)
  • D: Delete (삭제)

구현 과정

DRF 설치 및 프로젝트 생성

mkdir drf
cd drf

python -m venv venv
# source venv/Scripts/activate
.\venv\Scripts\activate

pip install django
pip install djangorestframework

django-admin startproject drf_tutorial .

python manage.py startapp notice
python manage.py startapp blog

python manage.py migrate

python manage.py createsuperuser
# drf_tutorial > settings.py

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # third party apss
    "rest_framework",
    # my apps
    "notice",
    "blog",
]

# ... 중략 ...

LANGUAGE_CODE = 'ko-kr'

TIME_ZONE = 'Asia/Seoul'

USE_I18N = True

USE_TZ = True

# ... 중략 ...

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

모델 생성

# blog > models.py

from django.db import models
from django.conf import settings


class Post(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    title = models.CharField(max_length=100)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

blog앱과 마찬가지로 같은 모델을 notice앱에도 생성한다. 클래스명만 Post와 Notice로 다르다.

모델의 필드 직렬화

# blog > serializers.py
from rest_framework import serializers
from .models import Post

class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = '__all__' # Test에서 사용하기 위해 모든 필드를 사용하도록 설정

notice앱도 마찬가지로 정의하고 import하는 이름만 다르다.

관리자 페이지에 모델 등록

from django.contrib import admin
from .models import Post

admin.site.register(Post)

앞과 비슷하다.

마이그레이션 후 서버 실행

python manage.py makemigrations
python manage.py migrate

python manage.py runserver

접속한 후 후에 테스트를 위해 게시물을 3개 생성한다. 이후, 서버를 종료한 후 아직 정의안한 urls와 views를 정의한다.

url 정의

# blog > urls.py

from django.urls import path
from . import views

urlpatterns = [
    path('post/', views.post_list, name='post_list'),
    path('post/<int:pk>/', views.post_detail, name='post_detail'),
]

view 정의 (Blog)

# blog > views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework import status
from .models import Post
from .serializers import PostSerializer


@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def post_list(request):
    if request.method == "GET":
        posts = Post.objects.all()
        serializer = PostSerializer(posts, many=True)
        return Response(serializer.data)

    elif request.method == "POST":
        serializer = PostSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save(author=request.user)
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


@api_view(["GET", "PUT", "DELETE"])
@permission_classes([IsAuthenticated, IsAuthenticatedOrReadOnly])
def post_detail(request, pk):
    try:
        post = Post.objects.get(pk=pk)
    except Post.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

    if request.method == "GET":
        serializer = PostSerializer(post)
        return Response(serializer.data)

    elif request.method == "PUT":
        if post.author != request.user:
            return Response(status=status.HTTP_403_FORBIDDEN)
        serializer = PostSerializer(post, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    elif request.method == "DELETE":
        if post.author != request.user:
            return Response(status=status.HTTP_403_FORBIDDEN)
        post.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

DRF에 익숙하지 않으므로 먼저, FBV로 정의해주었다.

  • IsAuthenticated : 로그인한 사용자만 접근 가능
  • IsAuthenticatedOrReadOnly : 게시글 상세보기(post_detail)는 로그인 여부와 상관없이 가능하지만, 수정/삭제는 작성자만 가능
  1. post_list 함수
  • GET 요청: 모든 Post 객체를 조회하며 인증된 사용자만 접근할 수 있다.
  • POST 요청: 새로운 Post 객체를 생성한다. 인증된 사용자만 접근할 수 있으며, author 필드에 현재 사용자를 자동으로 설정한다.
  1. post_detail 함수
  • GET 요청: 특정 Post 객체를 조회한다. 인증된 사용자와 인증되지 않은 사용자 모두 접근할 수 있다.
  • PUT 요청: 특정 Post 객체를 수정한다. 인증된 사용자 중에서도 해당 Post의 author인 경우에만 접근할 수 있다.
  • DELETE 요청: 특정 Post 객체를 삭제한다. 인증된 사용자 중에서도 해당 Post의 author인 경우에만 접근할 수 있다.

view정의 (Notice)

from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework import status
from .models import Notice
from .serializers import NoticeSerializer


@api_view(["GET", "POST"])
def notice_list(request):
    if request.method == "GET":
        notices = Notice.objects.all()
        serializer = NoticeSerializer(notices, many=True)
        return Response(serializer.data)

    elif request.method == "POST":
        if not request.user.is_authenticated:
            return Response(status=status.HTTP_401_UNAUTHORIZED)
        serializer = NoticeSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save(author=request.user)
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


@api_view(["GET", "PUT", "DELETE"])
@permission_classes([IsAuthenticatedOrReadOnly])
def notice_detail(request, pk):
    try:
        notice = Notice.objects.get(pk=pk)
    except Notice.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

    if request.method == "GET":
        serializer = NoticeSerializer(notice)
        return Response(serializer.data)

    elif request.method == "PUT":
        if not request.user.is_authenticated or notice.author != request.user:
            return Response(status=status.HTTP_403_FORBIDDEN)
        serializer = NoticeSerializer(notice, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    elif request.method == "DELETE":
        if not request.user.is_authenticated or notice.author != request.user:
            return Response(status=status.HTTP_403_FORBIDDEN)
        notice.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)
  1. notice_list 함수
  • GET 요청: 인증 여부와 상관없이 모든 사용자가 접근할 수 있다.
  • POST 요청: 인증된 사용자만 접근할 수 있으며, author 필드에 현재 사용자를 자동으로 설정한다. 인증되지 않은 사용자가 접근하면 HTTP_401_UNAUTHORIZED 응답을 반환한다.
  1. notice_detail 함수
  • GET 요청: 인증된 사용자와 인증되지 않은 사용자 모두 접근할 수 있다.
  • PUT 요청: 인증된 사용자 중에서도 해당 Notice의 author인 경우에만 접근할 수 있다. 그렇지 않은 경우 HTTP_403_FORBIDDEN 응답을 반환한다.
  • DELETE 요청: 인증된 사용자 중에서도 해당 Notice의 author인 경우에만 접근할 수 있다. 그렇지 않은 경우 HTTP_403_FORBIDDEN 응답을 반환한다.

접근 권한 테스트

thunder client로 테스트를 해 보았다.

  1. 자유 게시판(/notice) 테스트
  • http://127.0.0.1:8000/notice/post/
    • GET 요청: 비회원도 접근 가능하므로 200(OK) 응답
    • POST 요청 (비회원): 비회원은 작성 권한이 없으므로 401(Unauthorized) 응답
    • POST 요청 (회원):
      • 인증 방식: Basic Authentication
      • 사용자 이름: 이름
      • 비밀번호: 비밀번호
      • JSON 데이터: {"title": "test title", "content": "test content", "author": 1}
      • 회원 계정으로 인증하면 201(Created) 응답
  • 참고: package.json은 Thunder Client에서 요청 본문(request body)을 저장하는 데 사용된다.
  1. 회원 게시판(/blog) 테스트


TDD

thunder client로 테스트 했던 것을 코드 형태로 짜보았다. 보통의 경우 이런 테스트 코드는 직접 코드를 짜기 전에 작성한다.

규모가 어느정도 커지면 테스트 코드의 장점이 극대화되며 작은 경우는 안쓰는게 좋을 수 있다.

# blog > tests.py
from django.test import TestCase
from rest_framework.test import APIClient
from django.contrib.auth.models import User
from blog.models import Post


class BlogTest(TestCase):
    def setUp(self):
        print("-- main app 테스트 BEGIN --")
        self.client = APIClient()
        self.user = User.objects.create_user(
            username="hojun",
            password="dlghwns1234!",
        )
        self.user.save()

        self.blog = Post.objects.create(
            title="test blog title setup",
            content="test blog content setup",
            author=self.user,
        )
        self.blog.save()

        print("-- main app 테스트 END --")

    def test_blog_read(self):
        """
        blog list Read 가능 테스트
        """
        print("-- blog read 테스트 BEGIN --")
        print("-- 비회원 읽기 테스트 --")
        response = self.client.get("/blog/post/")
        self.assertEqual(response.status_code, 403)

        print("-- 회원 읽기 테스트 --")
        self.client.login(username="hojun", password="dlghwns1234!")
        response = self.client.get("/blog/post/")
        self.assertEqual(response.status_code, 200)
        print("--// blog read 테스트 END --")

    def test_blog_create(self):
        """
        blog Create 가능 테스트
        """
        print("-- blog create 테스트 BEGIN --")
        print("-- 비회원 작성 테스트 --")
        response = self.client.post(
            "/blog/post/",
            {
                "title": "test blog title create",
                "content": "test blog content create",
                "author": self.user.id,
            },
            format="json",
        )
        self.assertEqual(response.status_code, 403)

        print("-- 회원 작성 테스트 --")
        self.client.login(username="hojun", password="dlghwns1234!")
        response = self.client.post(
            "/blog/post/",
            {
                "title": "test blog title create",
                "content": "test blog content create",
                "author": self.user.id,
            },
            format="json",
        )
        self.assertEqual(response.status_code, 201)
        posts = Post.objects.all()
        for i in posts:
            print(i.title)
        print("--// blog create 테스트 END --")

setUp() 메서드에서 테스트에 필요한 사용자와 블로그 게시글을 미리 생성하여 초기화한다.

APIClient는 실제 HTTP 요청을 보내고 응답을 받을 수 있다.

마지막의 반복문은 명확하게 보기 위함이다.

# notice > tests.py
from django.test import TestCase
from rest_framework.test import APIClient
from django.contrib.auth.models import User
from notice.models import Notice as Post


class NoticeTest(TestCase):
    def setUp(self):
        print("-- main app 테스트 BEGIN --")
        self.client = APIClient()
        self.user = User.objects.create_user(
            username="hojun",
            password="dlghwns1234!",
        )
        self.user.save()

        self.notice = Post.objects.create(
            title="test notice title setup",
            content="test notice content setup",
            author=self.user,
        )
        self.notice.save()

        print("-- main app 테스트 END --")

    def test_notice_read(self):
        """
        notice list Read 가능 테스트
        """
        print("-- notice read 테스트 BEGIN --")
        print("-- 비회원 읽기 테스트 --")
        response = self.client.get("/notice/post/")
        self.assertEqual(response.status_code, 200)

    def test_notice_create(self):
        """
        notice Create 가능 테스트
        """
        print("-- notice create 테스트 BEGIN --")
        print("-- 비회원 작성 테스트 --")
        response = self.client.post(
            "/blog/post/",
            {
                "title": "test blog title create",
                "content": "test blog content create",
                "author": self.user.id,
            },
            format="json",
        )
        self.assertEqual(response.status_code, 403)

        print("-- 회원 작성 테스트 --")
        self.client.login(username="hojun", password="dlghwns1234!")
        response = self.client.post(
            "/blog/post/",
            {
                "title": "test blog title create",
                "content": "test blog content create",
                "author": self.user.id,
            },
            format="json",
        )
        self.assertEqual(response.status_code, 201)
        posts = Post.objects.all()
        for i in posts:
            print(i.title)
        print("--// notice create 테스트 END --")
profile
익숙해지기 위해 기록합니다

0개의 댓글