1. RelatedField

  • polls_api/serializers.py
    • UserSerializer 수정

1) StringRelatedField

  • model의 str 값으로 표현
class UserSerializer(serializers.ModelSerializer):
    questions = serializers.StringRelatedField(many=True, read_only=True)

    class Meta:
        model = User
        fields = ['id', 'username', 'questions']

스크린샷 2023-11-05 오전 2 15 21

2) SlugRelatedField

  • 지정한 모델의 필드로 표현
class UserSerializer(serializers.ModelSerializer):
    questions = serializers.SlugRelatedField(many=True, read_only=True, slug_field='pub_date')

    class Meta:
        model = User
        fields = ['id', 'username', 'questions']

스크린샷 2023-11-05 오전 2 19 55

3) 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']

스크린샷 2023-11-05 오전 2 20 59

4) question_detail에서 choice의 필드들도 함께 표시하기

  • polls_api/serializers.py
    • ChoiceSerializer 생성
class ChoiceSerializer(serializers.ModelSerializer):
    class Meta:
        model = Choice
        fields = ['choice_text', 'votes']

class QuestionSerializer(serializers.ModelSerializer):
    owner = serializers.ReadOnlyField(source='owner.username')
    choices = ChoiceSerializer(many=True, read_only=True)
    
    class Meta:
        model = Question
        fields = ['id', 'question_text', 'pub_date', 'owner', 'choices']
  • polls/models.py
    • Choice의 related_name 지정
class Choice(models.Model):
    question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)

    def __str__(self):
        return f'[{self.question.question_text}] {self.choice_text}'

스크린샷 2023-11-05 오전 3 24 09

2. Vote 기능 구현하기 - Models

  • 로그인한 사용자만 투표할 수 있도록

polls/models.py

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/serializers.py

class ChoiceSerializer(serializers.ModelSerializer):
    votes_count = serializers.SerializerMethodField()

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

    def get_votes_count(self, obj):
        return obj.vote_set.count()

django shell에서 실행

  • migration
python manage.py makemigrations
python manage.py migrate
  • shell
from polls.models import *

question = Question.objects.first()
question
#<Question:  제목: 휴가를 어디서 보내고 싶으세요?, 날짜: 2023-11-01 07:37:20+00:00>

choice = question.choices.first()
choice
# <Choice: [휴가를 어디서 보내고 싶으세요?] 바다>

from django.contrib.auth.models import User

user = User.objects.get(username='user1')
user
# <User: user1>

Vote.objects.create(voter=user, question=question, choice=choice)
#<Vote: Vote object (1)>

Vote.objects.first()
# <Vote: Vote object (1)>

스크린샷 2023-11-05 오전 4 49 54

3. Vote 기능 구현하기 - Serializers & Views

polls_api/serializers.py

class VoteSerializer(serializers.ModelSerializer):
    voter = serializers.ReadOnlyField(source='voter.username')

    class Meta:
        model = Vote
        fields = ['id', 'question', 'choice', 'voter']

polls_api/views.py

class VoteList(generics.ListCreateAPIView):
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated]   # 로그인 안하면 조회 X

    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter=self.request.user)        


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

polls_api/permissions.py

class IsVoter(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.voter == request.user

polls_api/usrls.py

path('vote/', VoteList.as_view()),
path('vote/<int:pk>/', VoteDetail.as_view()),

추가!

스크린샷 2023-11-05 오전 4 49 54

4. Validation

  • 중복 투표 방지, 다른 질문에 대한 choice 선택 방지

polls_api/serilalizers.py

class VoteSerializer(serializers.ModelSerializer):
    def validate(self, attrs):
        if attrs['choice'].quesiton.id != attrs['question'].id:
            raise serializers.ValidationError("Questions과 Choice가 조합이 맞지 않습니다.")
        
        return attrs

    class Meta:
        model = Vote
        fields = ['id', 'question', 'choice', 'voter']
        validators = [
            UniqueTogetherValidator(
                queryset=Vote.objects.all(),
                fields=['question', 'voter']
            )
        ]

polls_api/views.py

class VoteList(generics.ListCreateAPIView):
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated]   # 로그인 안하면 조회 X

    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter=self.request.user)     

    # generics.ListCreateAPIView - mixins.CreateModelMixin - create 메소드 오버라이드
    ## is_valid 전에 voter를 넣어주어야 함
    def create(self, request, *args, **kwargs):
        new_data = request.data.copy()
        new_data['voter'] = request.user.id
        serializer = self.get_serializer(data=new_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.ListCreateAPIView):    
    queryset = Vote.objects.all()
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated, IsVoter]

    def perform_update(self, serializer):
        serializer.save(voter=self.request.user)

5. Serializer Testing

  • 자동으로 테스트를 진행하도록!

example1

  • polls_api/tests.py
from django.test import TestCase

class QuestionSerializerTestCase(TestCase):
    def test_a(self):
        print("This is test a")

    def test_b(self):
        print("This is test b")

    def some_method(self):
        print("This is some method")
  • django shell
python manage.py test

결과

Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
This is test a
.This is test b
.
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK
Destroying test database for alias 'default'...
  • some_method는 시작하지 않음
    • TestCase 클래스에서 test_로 시작하는 메소드만 자동으로 test라고 판단하여 실행하기 때문
  • .
    • test가 별 문제없이 실행되었다는 뜻

example2

  • polls_api/tests.py
from django.test import TestCase

class QuestionSerializerTestCase(TestCase):
    def test_a(self):
        self.assertEqual(1, 2)

    def test_b(self):
        print("This is test b")
  • django shell
python manage.py test

결과

Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
FThis is test b
.
======================================================================
FAIL: test_a (polls_api.tests.QuestionSerializerTestCase.test_a)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/bokyung/django-projects/mysite/polls_api/tests.py", line 5, in test_a
    self.assertEqual(1, 2)
AssertionError: 1 != 2

----------------------------------------------------------------------
Ran 2 tests in 0.001s

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

QuestionSerializer

  • polls_api/tests.py
from django.test import TestCase
from polls_api.serializers import QuestionSerializer

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)
  • django shell
python manage.py test

결과

Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

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

VoteSerializer

정상적 경우

from django.test import TestCase
from polls_api.serializers import QuestionSerializer, VoteSerializer
from django.contrib.auth.models import User
from polls.models import Question, Choice, Vote

class VoteSerializerTestCase(TestCase):
    def test_vote_serializer(self):
        user = User.objects.create(username='testuser')
        question = Question.objects.create(
            question_text = "abc", 
            owner = user,
        )
        choice = Choice.objects.create(
            question = question,
            choice_text='1'
        )
        data = {
            'question' : question.id,
            'choice' : choice.id,
            'voter' : user.id,
        }
        serializer = VoteSerializer(data=data)
        self.assertTrue(serializer.is_valid())
        vote = serializer.save()

        self.assertEqual(vote.question, question)
        self.assertEqual(vote.choice, choice)
        self.assertEqual(vote.voter, user)

결과

Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.004s

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

비정상적 경우

1) 같은 유저가 같은 질문에 대해 다시 투표하는 경우

class VoteSerializerTestCase(TestCase):
    def test_vote_serializer(self):
        user = User.objects.create(username='testuser')
        question = Question.objects.create(
            question_text = "abc", 
            owner = user,
        )
        choice = Choice.objects.create(
            question = question,
            choice_text='1'
        )
        data = {
            'question' : question.id,
            'choice' : choice.id,
            'voter' : user.id,
        }
        serializer = VoteSerializer(data=data)
        self.assertTrue(serializer.is_valid())
        vote = serializer.save()

        self.assertEqual(vote.question, question)
        self.assertEqual(vote.choice, choice)
        self.assertEqual(vote.voter, user)

    def test_vote_serializer_with_duplicate_vote(self):
        user = User.objects.create(username='testuser')
        question = Question.objects.create(
            question_text = "abc", 
            owner = user,
        )
        choice = Choice.objects.create(
            question = question,
            choice_text='1'
        )
        choice1 = Choice.objects.create(
            question = question,
            choice_text='2'
        )
        Vote.objects.create(question=question, choice=choice, voter=user)

        data = {
            'question' : question.id,
            'choice' : choice1.id,
            'voter' : user.id,
        }
        serializer = VoteSerializer(data=data)
        self.assertFalse(serializer.is_valid())

결과

Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.006s

OK
Destroying test database for alias 'default'...
  • VoteSerializer의 UniqueTogetherValidator기 잘 동작함을 알 수 있음!


2) 조합이 맞지 않는 question과 choice 요청

class VoteSerializerTestCase(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())
        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):
        choice = Choice.objects.create(
            question = self.question,
            choice_text='1'
        )
        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())

    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())

결과

Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.008s

OK
Destroying test database for alias 'default'...
  • VoteSerializer의 validate 메소드가 잘 실행됨을 알 수 있음

serUp 메소드

  • 각 테스트가 실행되기 전 한 번 실행되는 메소드
  • 테스트마다 독립적!
class VoteSerializerTestCase(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'
        )
        print("--setUp 실행--")

    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())
        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):
        choice = Choice.objects.create(
            question = self.question,
            choice_text='1'
        )
        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())

결과

Found 4 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..--setUp 실행--
.--setUp 실행--
.
----------------------------------------------------------------------
Ran 4 tests in 0.006s

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

6. View Testing

정상

from rest_framework.test import APITestCase
from django.urls import reverse   # 메소드 안에서는 reverse_lazy 대신 reverse
from rest_framework import status

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

    def test_create_question(self):
        user = User.objects.create(username='testuser', password='testpass')
        self.client.force_authenticate(user=user)   # 사용자 강제로 로그인! (APITestCase 쓴 이유)  
        response = self.client.post(self.url, self.question_data)   # url로 data가 POST로 날아감
        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)
python manage.py test polls_api.tests.QuestionListTest

결과

Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.008s

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

비정상

  • 로그인하지 않고 요청
class QuestionListTest(APITestCase):
    def setUp(self):
        self.question_data = {'question_text': 'some question'}
        self.url = reverse('question-list')

    def test_create_question(self):
        user = User.objects.create(username='testuser', password='testpass')
        self.client.force_authenticate(user=user)   # 사용자 강제로 로그인! (APITestCase 쓴 이유)  
        response = self.client.post(self.url, self.question_data)   # url로 data가 POST로 날아감
        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.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)
        print(f'$$$$$$${response.data}\n')

결과

Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..$$$$$$$[OrderedDict([('id', 1), ('question_text', 'Question1'), ('pub_date', '2023-11-04T22:03:05.149758Z'), ('choices', [OrderedDict([('choice_text', 'choice1'), ('votes_count', 0)])])]), OrderedDict([('id', 2), ('question_text', 'Question2'), ('pub_date', '2023-11-04T22:03:05.150064Z'), ('choices', [])])]

.
----------------------------------------------------------------------
Ran 3 tests in 0.012s

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

질문 리스트

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

    def test_create_question(self):
        user = User.objects.create(username='testuser', password='testpass')
        self.client.force_authenticate(user=user)   # 사용자 강제로 로그인! (APITestCase 쓴 이유)  
        response = self.client.post(self.url, self.question_data)   # url로 data가 POST로 날아감
        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)

결과

Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.012s

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

7. converage

  • test가 잘 진행되고 있는지 확인
pip install coverage

coverage run manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.032s

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


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                                                               29      6    79%
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/admin.py                                                             1      0   100%
polls_api/apps.py                                                              4      0   100%
polls_api/migrations/__init__.py                                               0      0   100%
polls_api/models.py                                                            1      0   100%
polls_api/permissions.py                                                       9      4    56%
polls_api/serializers.py                                                      48      7    85%
polls_api/tests.py                                                            67      0   100%
polls_api/urls.py                                                              3      0   100%
polls_api/views.py                                                            43      9    79%
----------------------------------------------------------------------------------------------
TOTAL                                                                        313     43    86%