[데이터 엔지니어링 데브코스 2기] TIL-4주차-파트06 장고를 활용한 API서버 만들기

이재호·2023년 11월 3일
0

1. RelatedField

이전에 유저 목록을 조회할 때,


사진에서 처럼 1번 유저의 questions가 id로 출력되고 있는 것을 확인할 수 있습니다. 이를 question의 str로 출력되도록 바꿔 보겠습니다.

  • polls_api/serialziers.py 에서 UserSerializer를 수정합니다.
class UserSerializer(serializers.ModelSerializer):
    # StringRelatedField로 변경.
    questions = serializers.StringRelatedField(many=True, read_only=True)
    
    class Meta:
        model = User
        fields = ['id', 'username', 'questions']

수정 후 페이지를 다시 확인하면,

Question 모델의 __str__로 출력되는 것을 확인할 수 있습니다.

위 코드처럼 여러 방식으로 응용할 수 있습니다.

  • SlugRelatedField
class UserSerializer(serializers.ModelSerializer):
    # Slug를 이용하여 pub_date로 표시.
    questions = serializers.SlugRelatedField(many=True, read_only=True, slug_field='pub_date')
    
    class Meta:
        model = User
        fields = ['id', 'username', 'questions']

  • HyperlinkedRelatedField
class UserSerializer(serializers.ModelSerializer):
    # 하이퍼링크로 표시.
    questions = serializers.HyperlinkedRelatedField(many=True, read_only=True, view_name='question-detail')
    
    class Meta:
        model = User
        fields = ['id', 'username', 'questions']

다음으로 question에 대해서 Choice를 출력하도록 해 봅니다.

  • polls_api/serializers.py
# Choice에 대한 시리얼라이저 추가
class ChoiceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Choice
        fields = ['choice_text', 'votes']

class QuestionSerializer(serializers.ModelSerializer):
    owner = serializers.ReadOnlyField(source='owner.username')
    # choice_set 이라는 Choice시리얼라이저.
    choice_set = ChoiceSerializer(many=True, read_only=True)

    class Meta:
        model = Question
        # fields에 choice_set 추가
        fields = ['id', 'question_text', 'pub_date', 'owner', 'choice_set']

  • choice_set 이라는 이름 대신에 choices로 수정해 봅니다.

    • polls/models.py

      class Choice(models.Model):
      # choices로 realted_name 설정.
      question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE) # 외래키 Question을 CASCADE로 참조한다.
      choice_text = models.CharField(max_length=200) 
      votes = models.IntegerField(default=0)
      def __str__(self):
          return self.choice_text
      			```
      
    • polls_api/serializers.py

      class QuestionSerializer(serializers.ModelSerializer):
      owner = serializers.ReadOnlyField(source='owner.username')
      # choices 이라는 Choice시리얼라이저.
      choices = ChoiceSerializer(many=True, read_only=True)
      
      class Meta:
          model = Question
          # fields에 choices 추가
          fields = ['id', 'question_text', 'pub_date', 'owner', 'choices']
      




2. 투표 기능 구현하기

2-1. Models(장고 기반)

코드를 다음과 같이 수정합니다.

  • polls/models.py 에서 Vote라는 새로운 모델 클래스를 정의합니다.
from django.db import models
from django.utils import timezone
import datetime
from django.contrib.auth.models import User
    
# Vote 기능의 모델 클래스 정의
class Vote(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice = models.ForeignKey(Choice, on_delete=models.CASCADE)
    voter = models.ForeignKey(User, on_delete=models.CASCADE)
    
    # 한 질문 당 한 번의 투표만 가능하도록 제약 조건을 걺.
    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['question', 'voter'], name='unique_voter_for_questions')
        ]
  • polls_api/serializer.py 파일에서 Choice시리얼라이저에 votes_count라는 투표수 필드를 추가합니다.
class ChoiceSerializer(serializers.ModelSerializer):
    # method에 의해서 값이 결정되는 votes_count
    votes_count = serializers.SerializerMethodField()

    class Meta:
        model = Choice
        fields = ['choice_text', 'votes_count']

    # method 정의
    def get_votes_count(self, obj):
        return obj.vote_set.count()
  • Django Shell에서 Vote 객체를 생성합니다.
>>> from polls.models import *

# Question, Choice 객체 입력
>>> question = Question.objects.first()
>>> question
<Question: 휴가를 보내고 싶은 장소는?>
>>> choice = Choice.objects.first()
>>> choice
<Choice: 바다>

# User 객체 입력
>>> from django.contrib.auth.models import User
>>> user = User.objects.get(username='admin')
>>> user
<User: admin>

# 위 객체들을 인수로 갖는 Vote 객체 생성.
>>> Vote.objects.create(voter=user, question=question, choice=choice)
<Vote: Vote object (1)>
>>> Vote.objects.first()
<Vote: Vote object (1)>
>>> question.id
1

위 이미지에서 특정 질문의 choices에 대한 votes_count를 확인할 수가 있습니다.

2-2. Serializers & Views(장고 rest framwork 기반)

이제 직접 투표를 할 수 있도록(CRUD가 가능하도록) django rest framework에서 활용해 봅니다.

  • polls_api/serializer.py 파일에서 Vote시리얼라이저를 정의합니다.
# Vote 모델 시리얼라이저 정의
class VoteSerializer(serializers.ModelSerializer):
    voter = serializers.ReadOnlyField(source='voter.username')

    class Meta:
        model = Vote
        fields = ['id', 'question', 'choice', 'voter']
  • polls_api/views.py 파일에서 Vote에 대한 뷰 클래스를 정의합니다.
# Vote 뷰 정의

class VoteList(generics.ListCreateAPIView):
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated]

    # 특정 유저에 대해서만 쿼리셋을 할 수 있도록 지정.
    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter=self.request.user)
    
    def perform_create(self, serializer):
        serializer.save(voter=self.request.user)

class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Vote.objects.all()
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated, IsVoter]
  • polls_api/permissions.py 파일에서 IsVoter에 대한 메서드를 정의합니다.
from rest_framework import permissions
   
class IsVoter(permissions.BasePermission):
    # 오직 해당 유저가 voter인 경우에만 가능하도록 권한 부여
    def has_object_permission(self, request, view, obj):
        return obj.voter == request.user
  • polls_api/urls.py 파일에서 Vote에 대한 path를 추가합니다.
from django.urls import path, include
from .views import *

urlpatterns = [
    path('question/', QuestionList.as_view(), name='question-list'),
    path('question/<int:pk>/', QuestionDetail.as_view(), name='question-detail'),
    path('users/', UserList.as_view(), name='user-list'),
    path('users/<int:pk>/', UserDetail.as_view()),
    path('register/', RegisterUser.as_view()),
    path('api-auth', include('rest_framework.urls')),
    path('vote/', VoteList.as_view()),
    path('vote/<int:pk>', VoteDetail.as_view()),
]
  • http://127.0.0.1:8000/rest/vote/ 에서 현재 admin 유저의 투표 내역을 확인할 수가 있으며 특정 question에 대한 choice를 추가할 수도 있습니다.

  • http://127.0.0.1:8000/rest/vote/1 에서 admin 계정의 특정 Vote에 대한 정보를 확인할 수 있습니다. 그리고 해당 vote를 PUT하여 수정하는 기능도 확인할 수가 있습니다.




3. Validation

현재 상황에서 다음과 같은 문제점들이 존재합니다.

  1. 이미 투표한 질문에 대해서 중복 투표를 하는 경우, 500 error(서버 에러)가 발생합니다. 하지만, 이는 서버 에러가 아닌 사용자의 잘못된 선택으로 인한 에러이기에 400번 대 error가 발생해야 합니다.

  2. 특정 질문에 대해서 이상한 Choice를 선택할 수가 있습니다. 특정 질문에만 해당되는 Choice만 고르도록 수정해야 합니다.

  • polls_api/serializer.py
from rest_framework import serializers
from polls.models import *
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
# validator 임포트
from rest_framework.validators import UniqueTogetherValidator

# Vote 모델 시리얼라이저 정의
class VoteSerializer(serializers.ModelSerializer):
    # 특정 질문에만 가능한 Choice인지 판단하는 메서드
    def validate(self, attrs):
        if attrs['choice'].question.id != attrs['question'].id:
            raise serializers.ValidationError("적절하지 않는 Question과 Choice의 조합입니다.")

        return attrs
    
    class Meta:
        model = Vote
        fields = ['id', 'question', 'choice', 'voter']
        # valid한지 체크하는 validator 정의. is_valid를 정의하는 것과 유사(?)
        # 고유한 question과 voter인지 확인. (중복 투표 방어)
        validotrs = [
            UniqueTogetherValidator(
                queryset=Vote.objects.all(),
                fields = ['question', 'voter']
            )
        ]
  • polls_api/views.py
from polls.models import *
from polls_api.serializers import *
from rest_framework import generics, permissions, status
from rest_framework.response import Response
from django.contrib.auth.models import User
from .permissions import *

# Vote 뷰 정의

class VoteList(generics.ListCreateAPIView):
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated]

    # 특정 유저에 대해서만 쿼리셋을 할 수 있도록 지정.
    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter=self.request.user)

    # 조상 클래스를 보면 perform_create 이전에 is_valid 메서드가 호출됨.
    # 따라서, create 메서드를 오버라이딩 함.
    def create(self, request, *args, **kwargs):
        new_data = request.data.copy()
        new_data['voter'] = request.user.id

        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer)
        headers = self.get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Vote.objects.all()
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated, IsVoter]

    # Update 시 해당 유저의 계정에 대해서만 가능하도록 정의.
    def perform_update(self, serializer):
        serializer.save(voter=self.request.user)
  • http://127.0.0.1:8000/rest/vote/1 에서 테스트를 해 보면, 옳지 않은 입력에 대해서 error 메시지를 잘 출력하고 있는 것을 확인할 수 있습니다.



4. Testing

여러 기능들을 수정하면 자동으로 Testing할 수 있도록 자동화된 testing을 만들어 봅니다.

  • polls_api/test.py 파일을 다음과 같이 수정합니다.
from django.test import TestCase
from polls_api.serializers import QuestionSerializer

# Create your tests here.
class QuestionSerializerTestCase(TestCase):
    def test_with_valid_data(self):
        serializer = QuestionSerializer(data={'question_text': 'abc'})
        self.assertEqual(serializer.is_valid(), True)
        new_question = serializer.save()
        self.assertIsNotNone(new_question.id)
    
    def test_with_invalid_data(self):
        serializer = QuestionSerializer(data={'question_text': ''})
        self.assertEqual(serializer.is_valid(), False)

    # test_ 로 시작되어야만 테스트 메서드로 인식함.
    # def some_method(self):
    #     print("This is some method")
  • Django Shell에서 test를 실행해 봅니다.
(django) django\mysite>python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
# 여기서 ..이 나오는데, 2 개의 test를 통과하였다는 의미입니다.
# 만약 실패가 된다면, .이 F 로 출력이 됩니다.
..
----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK
Destroying test database for alias 'default'...



5. Testing Serializers

Vote시리얼라이저를 테스트하는 방법은 다음과 같습니다.

  • polls_api/test.py 파일에 시리얼라이저 테스트 클래스를 정의합니다.
from django.test import TestCase
from polls_api.serializers import *
from django.contrib.auth.models import User
from polls.models import *

class VoteSerilaizerTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(username='testuser')
        self.question = Question.objects.create(
            question_text = 'abc',
            owner = self.user
        )
        self.choice = Choice.objects.create(
            question = self.question,
            choice_text = '1'
        )
        
    def test_vote_serializer(self):
        data = {
            'question' : self.question.id,
            'choice' : self.choice.id,
            'voter' : self.user.id,
        }

        serializer = VoteSerializer(data=data)
        self.assertTrue(serializer.is_valid()) # ==True?
        vote = serializer.save()

        self.assertEqual(vote.question, self.question)
        self.assertEqual(vote.choice, self.choice)
        self.assertEqual(vote.voter, self.user)
        
    def test_vote_serializer_with_duplicate_vote(self):
        choice1 = Choice.objects.create(
            question = self.question,
            choice_text = '2'
        )
        Vote.objects.create(question=self.question, choice=self.choice, voter=self.user)

        data = {
            'question' : self.question.id,
            'choice' : choice1.id,
            'voter' : self.user.id,
        }

        serializer = VoteSerializer(data=data)
        self.assertFalse(serializer.is_valid()) # ==False?

    def test_vote_serializer_with_unmatched_question_and_choice(self):
        question2 = Question.objects.create(
            question_text = 'abc',
            owner = self.user
        )
        choice2 = Choice.objects.create(
            question = question2,
            choice_text = '1'
        )
        data = {
            'question' : self.question.id,
            'choice' : choice2.id,
            'voter' : self.user.id,
        }
        serializer = VoteSerializer(data=data)
        self.assertFalse(serializer.is_valid()) # ==False?
  • 터미널에서 결과를 확인합니다.
(django) django\mysite>python manage.py test
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...F.
======================================================================
FAIL: test_vote_serializer_with_duplicate_vote (polls_api.tests.VoteSerilaizerTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\lb948\Desktop\Programmers\django\mysite\polls_api\tests.py", line 47, in test_vote_serializer_with_duplicate_vote
    self.assertFalse(serializer.is_valid()) # ==False?
AssertionError: True is not false

----------------------------------------------------------------------
Ran 5 tests in 0.011s

FAILED (failures=1)
Destroying test database for alias 'default'...

원래는 .....으로 출력이 되는 것이 맞으나, 어떠한 원인으로 인해 중복 테스트에서 에러가 발생했습니다. 아직까지 에러를 해결하지 못하여서 계속해서 자료를 찾아 보는 중입니다.




6. Testing Views

이번에는 View를 테스트하는 방법을 알아 봅니다.

  • polls_api/test.py
from django.test import TestCase
from polls_api.serializers import *
from django.contrib.auth.models import User
from polls.models import *
from rest_framework.test import APITestCase
from django.urls import reverse
from rest_framework import status
from django.utils import timezone

class QuestionListTest(APITestCase):
    def setUp(self):
        self.question_data = {'question_text':'some question'}
        self.url = reverse('question-list')

    def test_create_qeustion(self):
        user = User.objects.create(username='testuser', password='testpass')
        self.client.force_authenticate(user=user)
        response = self.client.post(self.url, self.question_data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Question.objects.count(), 1)
        question = Question.objects.first()
        self.assertEqual(question.question_text, self.question_data['question_text'])
        self.assertLess((timezone.now() - question.pub_date).total_seconds(), 1)

    def test_create_question_without_authentication(self):
        response = self.client.post(self.url, self.question_data)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_list_questions(self):
        question = Question.objects.create(question_text = 'Question1')
        choice = Choice.objects.create(question=question, choice_text='Choice1')
        Question.objects.create(question_text = 'Question2')
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(len(response.data), 2)
        self.assertEqual(response.data[0]['choices'][0]['choice_text'], choice.choice_text )

class VoteSerilaizerTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(username='testuser')
        self.question = Question.objects.create(
            question_text = 'abc',
            owner = self.user
        )
        self.choice = Choice.objects.create(
            question = self.question,
            choice_text = '1'
        )
        
    def test_vote_serializer(self):
        data = {
            'question' : self.question.id,
            'choice' : self.choice.id,
            'voter' : self.user.id,
        }

        serializer = VoteSerializer(data=data)
        self.assertTrue(serializer.is_valid()) # ==True?
        vote = serializer.save()

        self.assertEqual(vote.question, self.question)
        self.assertEqual(vote.choice, self.choice)
        self.assertEqual(vote.voter, self.user)
        
    def test_vote_serializer_with_duplicate_vote(self):
        choice1 = Choice.objects.create(
            question = self.question,
            choice_text = '2'
        )
        Vote.objects.create(question=self.question, choice=self.choice, voter=self.user)

        data = {
            'question' : self.question.id,
            'choice' : choice1.id,
            'voter' : self.user.id,
        }

        serializer = VoteSerializer(data=data)
        self.assertFalse(serializer.is_valid()) # ==False?

    def test_vote_serilaizer_with_unmatched_question_and_choice(self):
        question2 = Question.objects.create(
            question_text='abc',
            owner=self.user,
        )
    
        choice2 = Choice.objects.create(
            question=question2,
            choice_text='1'
        )
        data = {
            'question': self.question.id,
            'choice': self.choice.id,
            'voter': self.user.id,
        }
        serializer = VoteSerializer(data=data)
        self.assertTrue(serializer.is_valid())

class QuestionSerializerTestCase(TestCase):
    def test_with_valid_data(self):
        serializer = QuestionSerializer(data={'question_text': 'abc'})
        self.assertEqual(serializer.is_valid(), True)
        new_question = serializer.save()
        self.assertIsNotNone(new_question.id)
    
    def test_with_invalid_data(self):
        serializer = QuestionSerializer(data={'question_text': ''})
        self.assertEqual(serializer.is_valid(), False)

    # test_ 로 시작되어야만 테스트 메서드로 인식함.
    # def some_method(self):
    #     print("This is some method")
  • 터미널에서 coverage 라이브러리를 인스톨합니다.
    pip install coverage

  • 터미널에서 결과를 확인합니다.

(django) C:\Users\lb948\Desktop\Programmers\django\mysite>coverage run manage.py test
Found 8 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
......F.
======================================================================
FAIL: test_vote_serializer_with_duplicate_vote (polls_api.tests.VoteSerilaizerTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "C:\Users\lb948\Desktop\Programmers\django\mysite\polls_api\tests.py", line 79, in test_vote_serializer_with_duplicate_vote
    self.assertFalse(serializer.is_valid()) # ==False?
AssertionError: True is not false

----------------------------------------------------------------------
Ran 8 tests in 0.051s

FAILED (failures=1)
Destroying test database for alias 'default'...
(django) C:\Users\lb948\Desktop\Programmers\django\mysite>coverage report
Name                                                                       Stmts   Miss  Cover
----------------------------------------------------------------------------------------------
manage.py                                                                     12      2    83%
mysite\__init__.py                                                             0      0   100%
mysite\settings.py                                                            21      0   100%
mysite\urls.py                                                                 3      0   100%
polls\__init__.py                                                              0      0   100%
polls\admin.py                                                                13      0   100%
polls\apps.py                                                                  4      0   100%
polls\migrations\0001_initial.py                                               6      0   100%
polls\migrations\0002_question_owner_alter_question_pub_date_and_more.py       6      0   100%
polls\migrations\0003_alter_choice_question_vote_and_more.py                   6      0   100%
polls\migrations\__init__.py                                                   0      0   100%
polls\models.py                                                               25      4    84%
polls\tests.py                                                                 1      0   100%
polls\urls.py                                                                  5      0   100%
polls\views.py                                                                31     15    52%
polls_api\__init__.py                                                          0      0   100%
polls_api\migrations\__init__.py                                               0      0   100%
polls_api\permissions.py                                                       9      4    56%
polls_api\serializers.py                                                      47      8    83%
polls_api\tests.py                                                            66      0   100%
polls_api\urls.py                                                              3      0   100%
polls_api\views.py                                                            43      9    79%
----------------------------------------------------------------------------------------------
TOTAL                                                                        301     42    86%
profile
천천히, 그리고 꾸준히.

0개의 댓글