3주차 금요일, 15일차 Today I Learned
[TIL_2023.11.03] 장고(Django) 프레임웍을 사용한 API 서버 만들기 (5)
: 장고 REST 프레임웍 - 사용자 (Users)와 인증 (Authenticatioin), 투표 (Votes)와 테스팅 (Testing)
✏️ 학습 내용
1. RelatedField
Question과 User 사이를 설명하는 필드에 대해 더 살펴보겠다. RelatedField는 모델 사이의 관계를 나타내기 위해 사용되는 클래스이다. 다양한 하위 클래스들을 가지고 있으며, 각 하위 클래스는 다양한 유형의 관계들을 표시하기 위해 사용된다.
UserSeriliazer에서 RelatedField를 사용하여, URL 링크를 클릭하면 해당 User가 가지고 있는 특정 Question 객체의 정보가 표시되도록 User 모델과 Question 모델의 관계를 정의했다. 이 때 RelatedField의 유형은 HyperlinkedRelatedField이다.
ChoiceSerializer와 QuestionSerializer 두 가지 시리얼라이저를 중첩하여 해당 Question과 그에 속해 있는 여러가지 Choice들의 관계를 표현하려고 한다. 이렇게 중첩된 시리얼라이저를 활용하면 Question 객체가 시리얼라이즈 될 때, 그에 속한 Choice 객체들도 함께 시리얼라이즈 되어 반환되는 결과를 얻을 수 있다.
- polls_api/serializers.py
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']
class UserSerializer(serializers.ModelSerializer):
#questions = serializers.PrimaryKeyRelatedField(many=True, queryset=Question.objects.all())
#questions = serializers.StringRelatedField(many=True, read_only=True)
#questions = serializers.SlugRelatedField(many=True, read_only=True, slug_field='pub_date')
questions = serializers.HyperlinkedRelatedField(many=True, read_only=True, view_name='question-detail')
class Meta:
model = User
fields = ['id', 'username', 'questions']
- polls_api/urls.py
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'))
]
- polls/models.py
...
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 self.choice_text
이제부터 투표와 테스팅에 대해 배우겠다.
2. 투표(Votes) 기능 구현하기 1 - Models
로그인을 한 사용자만 투표를 할 수 있도록 기능을 새로 구현해보도록 하겠다. 원래는 views.py에 의해 누구나 계속 투표를 할 수 있었는데, 이번에는 로그인을 한 사용자가 단 한 번만 투표를 할 수 있도록 수정할 것이다. 이를 위해서 Vote 클래스 안에 Meta 클래스로 제한을 주었다. question과 voter 필드의 조합이 유일해야 한다는 제약조건 UniqueConstraint를 추가한다. (하나의 Question에서 한 User는 하나의 vote만 가능하다.)
ChoiceSerializer는 get_votes_count에서 받은 값으로 실제 투표 횟수를 보여준다.
# 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 = [
models.UniqueConstraint(fields=['question', 'voter'], name='unique_voter_for_questions')
]
# polls_api/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
>>> from polls.models import *
>>> question = Question.objects.first()
>>> choice = question.choices.first()
>>> from django.contrib.auth.models import User
>>> user= User.objects.get(username='luke')
>>> Vote.objects.create(voter=user,question=question,choice=choice)
<Vote: Vote object (1)>
>>> question.id
1
3. 투표(Votes) 기능 구현하기 2 - Serializers & Views
# polls_api/serializers.py
from polls.models import Question,Choice, Vote
class VoteSerializer(serializers.ModelSerializer):
voter = serializers.ReadOnlyField(source='voter.username')
class Meta:
model = Vote
fields = ['id', 'question', 'choice', 'voter']
# polls_api/views.py
from polls.models import Question,Choice, Vote
from polls_api.serializers import VoteSerializer
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)
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.IsAuthenticatedOrReadOnly, IsVoter]
polls_api/permissions.py
class IsVoterOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.voter == request.user
# polls_api/urls.py
from django.urls import path, include
from .views import VoteList, VoteDetail
urlpatterns = [
...
path('vote/', VoteList.as_view()),
path('vote/<int:pk>/', VoteDetail.as_view()),
]
4. Validation
현재 두 가지 에러가 있어서, 이에 대한 방지가 필요하다.
1. 이미 투표를 한 유저가 또 다시 투표를 하려고 하면 500 에러가 발생한다. 하지만 이 때에는 사용자에게 책임이 있다는 400대 에러가 필요하므로 이에 대한 수정이 필요하다.
-> VoreSerializer에서 Meta 클래스 validators 내용을 추가해주었다. 그리고 perform_create() 대신 create()를 이용하여 voter를 가져온다.
2. Question에 속하지 않은 Choice를 선택하여 투표해도 작동이 된다. 그러므로 Question에 속하는 Choice만 보이도록 해야 한다. VoteSerializer 안에 validate 메서드를 작성했다.
# polls_api/serializers.py
from rest_framework.validators import UniqueTogetherValidator
class VoteSerializer(serializers.ModelSerializer):
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']
validators = [
UniqueTogetherValidator(
queryset=Vote.objects.all(),
fields=['question', 'voter']
)
]
# polls_api/views.py
from rest_framework import status
from rest_framework.response import Response
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 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.RetrieveUpdateDestroyAPIView):
queryset = Vote.objects.all()
serializer_class = VoteSerializer
permission_classes = [permissions.IsAuthenticated, IsVoter]
def perform_update(self, serializer):
serializer.save(voter=self.request.user)
5. Testing
그동안 직접 서버를 열어 실행해보거나 POSTMAN을 이용해서 테스트를 해왔다. 하지만 이제 컴퓨터가 스스로 테스트를 하는 자동화 테스트에 대해 생성할 것이다.
serializer에서 serializer 객체가 is_valid() 유효성 검사를 통과하여 True를 반환하는지 확인한다.
new_question에서 저장된 Question 객체의 id 필드가 None인지 아닌지 확인한다.
# polls_api/test.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)
# 테스트 실행하기
python manage.py test
6. Testing Serializers
setup() 메서드는 VoteSerializerTest 클래스 내부에서 사용할 User, Question, Choice 객체들을 생성하고, 테스트 메서드를 실행하기 전에 테스트 환경을 설정하기 위해 사용된다. 다른 메서드들이 실행되기 전에 항상 실행되어, 공통적으로 사용되는 객체나 데이터를 초기화한다.
# polls_api/tests.py
class VoteSerializerTest(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):
self.assertEqual(User.objects.all().count(), 1)
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):
self.assertEqual(User.objects.all().count, 1)
choice1 = Choice.objects.create(
quetsion=self.question,
choice_text='2'
)
Vote.objects.create(question=self.question, choice=self.choice, voter=self.user)
data = {
'question': self.question.id
'choice': self.choice.id
'voter': self.user.id
}
serializer = VoteSerializer(data=data)
self.assertTrue(serializer.is_valid())
def test_vote_serilaizer_with_unmatched_question_and_choice(self):
question2 = Question.objects.create(
question_text='abc',
owner=self.user,
)
choice2 = Choice.objects.create(
quetsion=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())
7. Testing Views
- 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
class QuestionListTest(APITestCase):
def setUp(self):
self.question_data = {'question_text': 'some question'}
self.url = reverse('queston-list')
def test_create_question(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.assertEqual((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_question(self):
question = Question.objects.create(question_text='Question1')
choice = Choice.objects.create(question=question, choice_text='Question1')
Question.objects.create(question_text='Question2')
response = self.client.post(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)
- Python 코드 커버리지 라이브러리 설치하기
pip install coverage
- Python 코드 커버리지 라이브러리 실행하기
coverage run manage.py test
- Python 코드 커버리지 결과 요약
coverage report
coverage는 작성한 코드의 어떤 부분이 얼마나 잘 테스트 되고 있는지를 판단하는 파이썬 라이브러리이다. Django 프로젝트의 테스트 코드를 실행하면서, 실행된 코드의 커버리지를 측정할 수 있다.
💡 배운 점
장고 REST 프레임워크의 마지막 파트인 투표와 테스팅에 대해서 배웠다.
🔖 잘한 것과 잘못한 것
📝 남아있는 의문과 개선점
☁️ 소감
강의를 모두 한 번씩 들었다. 이제 혼자서 실습을 해보면서 막혔던 부분을 적어보고, 회고를 해보도록 할 예정이다.