1. Users와 Authentication
2. Votes와 Testing
polls_api/serializers.py에서 사용한,
PrimaryKeyRelatedField로 인해,User List에서
questions라는 필드에서
User가 작성한 question들의 id(pk)가 적혀있는 것을 확인할 수 있다.
polls_api/serializers.py class UserSerializer(serializers.ModelSerializer):
questions = serializers.PrimaryKeyRelatedField(many=True, queryset = Question.objects.all())
이를 수정해보자
polls_api/serializers.py
...
class UserSerializer(serializers.ModelSerializer):
#questions = serializers.PrimaryKeyRelatedField(many=True, queryset = Question.objects.all())
# StringRelatedField : 대상 모델에 정의된 str 메소드의 내용을 표시
#questions = serializers.StringRelatedField(many=True, read_only=True)
# SlugRelatedField : Question모델에 있는 field 중에 있는 아무거나 정해 그 내용을 표시
#questions = serializers.SlugRelatedField(many=True, read_only=True, slug_field = 'pub_date')
# HyperlinkedRelatedField : 하이퍼링크를 걸어 이동을 시킴 (urls.py에 정의된 name으로 지정)
questions = serializers.HyperlinkedRelatedField(many=True, read_only=True, view_name='question-detail')
class Meta:
model = User
fields = ['id','username','questions']
...
StringRelatedField: 대상 모델에 정의된 str 메소드의 내용을 표시
SlugRelatedField: Question모델에 있는 field 중에 있는 아무거나 정해 그 내용을 표시
HyperlinkedRelatedField: 하이퍼링크를 걸어 이동을 시킴 (urls.py에 정의된 name으로 지정)
Django의
related_name을 설정할 때 주의해야할 사항이 있다.
Django에서 모델을 설정할 때 Foreign키를 설정해주고 뒤에related_name을 설정해준다.
related_name을 설정해주는 것은 장고의 ORM기능을 활용하기 위해서이다.
하지만, 이related_name을 잘못 설정하는 경우가 많다.예를 들어, Question이란 모델이 있으면 그에 따른 선택지 Choice 모델이 있을 것이다.
그렇다면, Question의 Pk는 id일 것이고, Choice의 Foreign키는 Question.id일 것이다.그럼 Choice 모델을 적어보자
class Choice(models.Model): question = models.ForeignKey(Question, related_name = '?', on_delete=models.CASCADE)여기서
related_name엔 뭐가 들어가야할까?Choice의 외래키를 불러온 거니까 당연히 question이 들어가야하는거 아닌가?
그러나 이 생각은 잘못됐다.
related_name은 Django ORM모델을 위한 것이며,
ORM모델은 쿼리문 없이 장고에서 데이터베이스와 소통하기 위한 것이다.이렇게 작성한 경우, 특정 Question에 대한 모든 Choice를 호출할 때,
( Question은 Choice에 대한 정보가 없으므로,
Choice에서 역으로 특정 Question과 외래키가 일치한 Choice들을 모아야한다. )즉,
qustion = Question.objects.all()[0]이라 했을 때
Choices = question.question.all()이란 상황이 발생해버린다.왜 이렇게 되는걸까?
원래 우리가 특정 Question에 대한 모든 Choice를 호출하는 경우,
Choices = question.choice_set.all()이란 명령어를 사용했었다.Django ORM이
choice_set이란 것을 만들어, 특정 Question에 대한 Choice들을 모으기 쉽게 만들어 줬기 때문이다. ( Choice가 Question이란 외래키를 가지고 있는 것을 이용 )즉,
related_name이란 본래의choice_set에서 변경하고자 하는 이름인 것이다.좀 더
related_name을 정리하자면,
외래키로 등록하고자 하는 모델( Question )을 가리키는 이름이 아니라,
외래키로 등록하고자 하는 모델( Question )이 역으로자신을 참조한 모델의 집합( default : choice_set, related_name : choices )을 호출할 때 사용하는 이름인 것이다..그러므로 이렇게 적어야한다.
class Choice(models.Model): question = models.ForeignKey(Question, related_name = 'choices', on_delete=models.CASCADE)Django Shell :
Choices = question.choices.all()
Choices 추가
polls_api/serializers.py
from rest_framework import serializers
# Choice 추가
from polls.models import Question, Choice
from django.contrib.auth.models import User
from django.contrib.auth.password_validation import validate_password
class ChoiceSerializer(serializers.ModelSerializer) :
class Meta:
model = Choice
fields = ['choice_text','votes']
class QuestionSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source='owner.username')
# choices 추가
choices = ChoiceSerializer(many=True, read_only=True)
class Meta:
model = Question
# choices 필드 추가
fields = ['id','question_text','pub_date', 'owner', 'choices']
polls/models.py
...
class Choice(models.Model):
# related_name -> choice_set이란 이름을 choices로 사용하게끔 변경
question = models.ForeignKey(Question, related_name='choices', on_delete=models.CASCADE)
...

UniqueConstraint를 사용하여 독립적인 vote를 구현
polls.models.py
...
from django.contrib.auth.models import User
...
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 = [
# question과 voter에 대해서 하나의 레코드만 생성이 됨
# ( 각 투표자는 질문들에 대해서 하나씩만 투표할 수 있다는 뜻 )
# ex) 1번 question에 1번 voter(투표자)는 하나만 레코드를 만들 수 있다.
# 2개를 만들면 UniqueConstraint가 레코드 생성을 막음
models.UniqueConstraint(fields=['question','voter'],name='unique_voter_for_questions')
]
Django Shell에서 확인
Djagno shell
from polls.models import *
question = Question.objects.first()
choice = question.choices.first()
from django.contrib.auth.models import User
user = User.objects.get(username='admin')
# Vote 생성
# admin이란 사용자가 1번 question에 1번 선택지를 투표함.
Vote.objects.create(voter=user, question=question, choice=choice)

Vote 객체가 생성은 되었지만,
votes가 Vote table을 확인해서 수를 세도록 만들어진 것이 아니기에
반영이 되지 않은 것을 확인, 이를 수정
polls_api/serializers.py
...
class ChoiceSerializer(serializers.ModelSerializer) :
# SerializerMethodField : 값이 Method에 의해 정해짐
# => get_votes_count()에 의해 값이 정해짐
votes_count = serializers.SerializerMethodField()
class Meta:
model = Choice
# fields에서 votes를 제거 ( 기존의 votes는 단순히 숫자 필드였기 때문 )
fields = ['choice_text','votes_count']
# 여기서 obj는 Choice
def get_votes_count(self, obj):
# Choice에 해당하는 모든 vote의 갯수
return obj.vote_set.count()
...

Django Shell을 통한 Vote 생성이 아닌,
Serializers & Views를 사용해 직접 Vote 기능 구현
polls_api/Serializers.py
# Vote 추가
from polls.models import Question, Choice, Vote
...
class VoteSerializer(serializers.ModelSerializer):
# voter의 이름을 가져와서 readonly형식의 field로 만듦
voter = serializers.ReadOnlyField(source='voter.username')
class Meta:
model = Vote
fields = ['id','question','choice','voter']
...
polls_api/permissions.py
...
# 커스텀 접근 권한 설정
class IsVoter(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# 모든 경우에 대해서 user가 voter인 경우에만 허용하겠다.
return obj.voter == request.user
polls_api/views.py
...
# IsVoter 추가
from .permissions import IsOwnerOrReadOnly, IsVoter
# 내가 작성한 Vote들만 목록으로 보여주는 기능
class VoteList(generics.ListCreateAPIView):
serializer_class = VoteSerializer
# Vote는 나만 봐야하기 때문에 readonly를 할 필요도 없음
permission_classes = [permissions.IsAuthenticated]
# Vote는 내 Vote만 볼 수 있도록
# List를 표현할 때 사용할 Queryset을 정의해야함
def get_queryset(self, *args, **kwargs):
# 요청한 user가 작성한 vote만 보여주겠다.
return Vote.objects.filter(voter=self.request.user)
# vote 생성
def perform_create(self, serializer):
serializer.save(voter=self.request.user)
# 각 Vote의 세부 내용
class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Vote.objects.all()
serializer_class = VoteSerializer
# Vote에 대해 CRUD를 하기 위해선, 나만 되도록 해야함
# 특별한 권한 추가
permission_classes = [permissions.IsAuthenticated, IsVoter]
...
polls_api/urls.py
...
urlpatterns = [
...
# vote 경로 추가
path('vote/', VoteList.as_view()),
path('vote/<int:pk>/', VoteDetail.as_view()),
]
Vote List : Question에 대한 Choice를 골라 Vote를 생성할 수 있음
Vote Detail : 특정 Vote에 대한 결정을 변경할 수 있음주의 사항 :
이미 Vote한 Question에 다시 Vote할 경우, UNIQUE constraint failed 발생
위에서 만든 Vote 기능은 몇 가지 문제가 있음.
1. 이미 Vote한 Question에 다시 Vote할 경우, UNIQUE constraint failed 발생
-> 여기서, 사용자에게 책임이 있다는 400대 Error가 발생해야하는데 500 Error가 발생
- Question에 속하지 않은 Choice를 Vote하더라도 정상적으로 실행이 되는 문제 발생
이 두 가지를 방어해보자
polls_api/serializers.py
# UniqueTogetherValidator를 불러옴
from rest_framework.validators import UniqueTogetherValidator
...
class VoteSerializer(serializers.ModelSerializer):
# voter의 이름을 가져와서 readonly형식의 field로 만듦
voter = serializers.ReadOnlyField(source='voter.username')
class Meta:
model = Vote
fields = ['id','question','choice','voter']
# 이것도 Mixins의 create의 .is_valid()를 상속받아서 오버라이딩하는 것
validators = [
# question과 voter가 unique한지 check
UniqueTogetherValidator(
queryset=Vote.objects.all(),
fields=['question', 'voter']
)
]

Validator를 추가하여 500 에러를 400대 에러로 변경하였지만,
내용이 아직 이상하다. 왜 필드가 비어있다고 나와있을까?
def perform_create(self, serializer): serializer.save(voter=self.request.user)
views.py의 VoteList의 perform_create()에 분명 voter를 집어넣었음에도 불구하고,
비어있다고 나오는 이유는,
상속받은 Mixins의 create()를 살펴보면 validation을 수행하는 코드가
perform_create()를 실행하는 것보다 앞서기 때문에,
voter의 값을 읽혀지기 전에 validaton이 먼저 수행이 된다는 것이다.
( voter가 ReadOnlyField로 되어있기에 perform_create() 없이는 알 수 없음 )그러므로 perform_create말고 create를 오버라이딩을 진행한다.
polls_api/views.py
from polls.models import Question, Vote
from polls_api.serializers import *
# status 추가
from rest_framework import generics, permissions, status
# Response 추가
from rest_framework.response import Response
from django.contrib.auth.models import User
from .permissions import IsOwnerOrReadOnly, IsVoter
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()이 아닌 create()를 직접 오버라이딩
def create(self, request, *args, **kwargs):
# 깊은 복사를 위해 copy()
new_data = request.data.copy()
# voter 정보를 넣어줌
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)
...
polls_api/serializers.py
...
class VoteSerializer(serializers.ModelSerializer):
# readonlyfield로 만든 voter를 냅두면 validation 검사를 건너뛰기에 삭제
# voter = serializers.ReadOnlyField(source='voter.username')
...
...

정상적으로 에러메세지가 출력이 되었으나,
업데이트를 할 때, 사용자(Voter)를 마음대로 바꿀 수 있게되는 문제가 생겨버렸다.
코드에서 업데이트를 할 때, Voter를 강제로 지정해주는 작업이 필요.
polls_api/views.py...
class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
...
# update를 상속받아 업데이트 시 voter를 사용자로 고정
def perform_update(self, serializer):
serializer.save(voter=self.request.user)다음으로, Question과 Choice가 맞지않으면 저장이 안되도록 방어
polls_api/serializers.py
...
class VoteSerializer(serializers.ModelSerializer):
# ModelSerializer의 validate를 오버라이드
def validate(self, attrs):
if attrs['choice'].question.id != attrs['question'].id:
raise serializers.ValidationError("Question과 Choice가 조합이 맞지 않습니다.")
return attrs
...
...

지금까지의 방식으로 손으로 하나 하나 테스트하는 건,
기능이 많아질 수록 어려운 방식,
하나의 수정 때문에 모든 것을 다 직접 테스트해볼 수는 없음
-> 자동화된 테스트를 돌려야함.
Django에서 제공하는 TestCase는
test_로 시작하는 메소드만 실행한다.
각 메소드의 실행이 성공적으로 되었으면.을 출력한다.
polls_api/tests.py
from django.test import TestCase
from polls_api.serializers import QuestionSerializer
# TestCase를 상속받음
class QuestionSerializerTestCase(TestCase):
def test_with_valid_data(self):
serializer = QuestionSerializer(data={'question_text':'abc'})
# valid하지않으면 fail
self.assertEqual(serializer.is_valid(), True)
# 성공시 id를 저장
new_question = serializer.save()
# id가 저장이 되지 않았으면 Fail
self.assertIsNotNone(new_question.id)
def test_with_invalid_data(self):
serializer = QuestionSerializer(data={'question_text': ''})
# valid하면 fail
self.assertEqual(serializer.is_valid(), False)
test 실행 명령어
python manage.py test

Serializer를 만들어 VoteSerializer를 테스트
( 정상적인 경우에 대한 실험 )
polls_api/tests.py
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 VoteSerializerTest(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)
# valid한지 check
self.assertTrue(serializer.is_valid())
vote = serializer.save()
# question choice user가 일치한지 확인
self.assertEqual(vote.question, question)
self.assertEqual(vote.choice, choice)
self.assertEqual(vote.voter, user)
같은 사용자가 또 투표를 하려는 경우 -
UniqueTogetherValidator에러를 발생시켜보기
( 비정상적인 경우에 대한 실험 )
polls_api/tests.py
...
class VoteSerializerTest(TestCase):
...
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'
)
# Question에 대한 vote를 이미 진행
Vote.objects.create(question=question, choice=choice, voter=user)
data = {
'question' : question.id,
'choice' : choice1.id,
'voter' : user.id,
}
# 이미 한 vote에 대해 vote를 시도
serializer = VoteSerializer(data=data)
# valid하지 않은 경우 성공
self.assertFalse(serializer.is_valid())
TestCase에서 지원하는
setup을 사용하여 반복되는 코드를 줄일 수 있다.
polls_api/tests.py
class VoteSerializerTest(TestCase):
# setup : 각 테스트가 실행될 때마다 한번 반복해서 실행됨.
# 대신 객체마다 self.을 붙여줘야함
# setup은 각 테스트마다 독립적으로 실행됨(시행 시마다 초기화됨).
# 다음 테스트에 이전 테스트의 영향이 남아있지않음.
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):
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())
조합이 맞지 않는 Choice와 Question에 대해 요청을 했을 때,
is_valid가 false가 나오는지 테스트
polls_api/tests.py
class VoteSerializerTest(TestCase):
...
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='test'
)
# choice2는 question2에 대한 선택지이므로 question가 매칭되지 않음
data = {
'question' : self.question.id,
'choice' : choice2.id,
'voter' : self.user.id,
}
serializer = VoteSerializer(data=data)
self.assertFalse(serializer.is_valid())
Question이 잘 생성 되었는지 확인
polls_api/tests.py
from rest_framework.test import APITestCase
from django.urls import reverse
from rest_framework import status
from django.utils import timezone
# APITestCase에서 제공하는 메소드를 사용
class QuestionListTest(APITestCase):
def setUp(self):
self.question_data = {'question_text' : 'some question'}
# urls의 name에 해당하는 url를 찾아 건네줌
self.url = reverse('question-list')
# 로그인 된 상황
def test_create_question(self):
# 계정 생성
user = User.objects.create(username='testuser', password='testpass')
# APITestCase를 사용한 이유
# 사용자를 강제로 로그인하는 기능
# 사용자를 로그인 상태로 유지시키기 위함
self.client.force_authenticate(user=user)
# 요청이 post으로 날라가서, 결과를 response로 받아옴
response = self.client.post(self.url, self.question_data)
# response 안에는 status 정보도 있음
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Question이 잘 생성됬는지도 확인
self.assertEqual(Question.objects.count(),1)
# 제목도 잘 들어갔는지 확인
question = Question.objects.first()
self.assertEqual(question.question_text, self.question_data['question_text'])
# 생성이 1초가 걸리지 않을 것이기 때문에,
# 방금 전으로, pob_date가 잘 기록이 되어있는지 확인
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)
# 로그인이 되어 있지 않을 때는, 403이 발생함
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')
# 요청을 get으로 날려서, 결과를 response로 받아옴
# get으로 요청시, 목록을 날리도록 구현했었음
# 목록을 요청하는 것이므로 데이터를 담을 필요는 없음
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# question을 2개 만들었으므로 2개여야함
self.assertEqual(len(response.data),2)
# choice의 text도 일치한지 확인
self.assertEqual(response.data[0]['choices'][0]['choice_text'], choice.choice_text)
python manage.py test Pathpython manage.py test polls_api.tests.QuestionListTestTest 코드는 코드가 구현하고 있는 바를 모두 표현할 수 있도록 상세히 적어야함
Test가 얼마나 잘 진행됬는지 확인하는 법
coverage 설치
pip install coverage
실행
coverage run manage.py test
cover가 얼마나 됐는 지 coverage 확인
coverage report

Cover가 모두 100%가 될 수 있도록 보완하는 것이 좋다.