개발 기록장

05. Python Django 프레임웍을 사용해서 API 서버 만들기(5) 본문

데브코스(DE)/장고 활용한 API 서버 제작

05. Python Django 프레임웍을 사용해서 API 서버 만들기(5)

jxwxnk 2024. 4. 12. 17:09
반응형
학습 주제:  Django Rest Framework(DRF), RelatedField, 투표(Vote) 기능 구현, Validation, Testing

RelatedField

: 관계 필드 

: 유저와 질문간의 관계

  • polls_api/serializers.py

         - UserSerializer 수정

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

 

  • PrimaryKeyRelatedField

         - 유저 리스트에서 유저가 작성한 question이 id 값으로 나타남

PrimaryKeyRelatedField

 

  • StringRelatedField

         - 유저 리스트에서 유저가 작성한 question이 제목으로 나타남

StringRelatedField

  • SlugRelatedField

         - 유저 리스트에서 유저가 작성한 question이 날짜/시간으로 나타남

SlugRelatedField

  • HyperlinkedRelatedField

         - 유저 리스트에서 유저가 작성한 question이 Hyper링크로 연결됨

HyperRelatedField

 

  • polls_api/urls.py

         - 회원 기능 어디에/어떻게 사용할 것인가 정의

  • polls_api/serializers.py

         - 회원 리스트: ex)http://127.0.0.1:8000/rest/users/

         - 회원 상세(detail) 페이지: ex)http://127.0.0.1:8000/rest/users/1

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(), name = 'signup'),
    path('register/',RegisterUser.as_view()),
    path('api-auth/', include('rest_framework.urls')),
]

 

  • polls/models.py

         - question/related_name ='choices': Choice 불러올때 이름을 choices로 불러오도록 함.

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}'

Vote

: 질문에 대한 선택지 투표 기능

  • Models

        - django.contrib.auth에서 지원하는 UserCreation폼을 사용하여 회원가입

  • polls/models.py

         - UniqueConstraint: User가 하나의 질문에 한번씩만 투표할 수 있도록 함.

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)
    
    #하나의 질문에 User가 한번 투표할 수 있도록(ex. 1번 질문에 1번유저 1번만 투표가능)
    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['question','voter'], name = 'unique_voter_for_questions')
        ]

 

  • polls_api/serializers.py

         - votes_count: 투표 결과 count 값 표시

class ChoiceSerializer(serializers.ModelSerializer):
    votes_count = serializers.SerializerMethodField() #count 표시
    
    class Meta:
        model = Choice
        fields = ['choice_text','votes_count']
    
    def get_votes_count(self,obj):
        return obj.vote_set.count()

 

  • Django Shell

         -  유저당 1질문 1투표 확인

>>> from polls.models import *
>>> question = Question.objects.first()
>>> choice = question.choices.first()
>>> 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)>
>>> question.id
1

 

  • Serializers & Views

        - Rest API로  회원가입

  • 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

         - class VoteList(): 로그인된 사용자의 투표 리스트

         - class VoteDetail(): 로그인된 사용자의 투표 상세 내용 Read, Update, Delete

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]
    
    #내가 작성한 vote만 보여줌
    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter=self.request.user)
   
   #투표한 사람(로그인된) voter로 저장
    def perform_create(self, serializer):
        serializer.save(voter=self.request.user)
  
class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Vote.objects.all()
    serializer_class = VoteSerializer
    #상세정보 Read, Update, Delete 내가 작성한 Vote만 가능
    permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsVoter]

 

  • polls_api/permission.py

         - 새로운 권한 생성         

         - IsVoterOrReadOnly: voter 만이 접근할 수 있는 권한

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

 

  • polls_api/urls.py

         -  투표 리스트: http://127.0.0.1:8000/rest/vote/

         -  투표 상세페이지: http://127.0.0.1:8000/rest/vote/1

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(), name = 'signup'),
    path('register/',RegisterUser.as_view()),
    path('api-auth/', include('rest_framework.urls')),
    path('vote/',VoteList.as_view()), #추가
    path('vote/<int:pk>/',VoteDetail.as_view()), #추가
]

Validation

: 검증

  • polls_api/serializers.py

         - 사용자가 투표시 질문과 일치하지 않는 조합의 답변 선택 가능 오류 수정

         - UniqueTohetherValidator: question, voter 두 필드에서 서로 값이 유일하게 존재하는가 판단

         - 투표 값 생성시, Question에 일치하지(존재X) 않는 값이면 생성 불가

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

         - 1질문 1투표 가능 이때, 나오는 에러 400 Bad  Request "voter": ["This field is required"]  에러 수정

            ->400 Bad  Request  "non_field_errors": "[The fields quetion, voter must make a unique set"]

         - serializer가 is_valid()하기 전에 voter가 존재하는지 확인하는 절차 수행

from rest_framework import status
from rest_framework.response import Response

class VoteList(generics.ListCreateAPIView):
    serializer_class = VoteSerializer
    permission_classes = [permissions.IsAuthenticated] #IsAuthenticated 로그인 X일 땐 못 봄

    #내가 작성한 vote만 보여줌
    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter =self.request.user)
    
    #투표한 사람(로그인된) 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하기 전에 voter가 있는지 확인
        serializer.is_valid(raise_exception=True)
        self.perform_create(serializer) #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
    #상세정보 Read, Update, Delete 내가 작성한 Vote만 가능
    permission_classes = [permissions.IsAuthenticated, IsVoter]

    #수정 시: 투표자 로그인된 사람으로 지정
    def perform_update(self, serializer):
        serializer.save(vote

 


Testing

: Unit Test

: 작성한 메소드/ 클래스들을 테스트 코드를 작성하여 테스트 함.

polls_api/tests.py

  • 테스트 실행하기

         - tests.py 전체 모듈 테스트

python manage.py test

 

 

  • tests.py에서 특정 모듈만 테스트 
python manage.py test polls_api.tests.QuestionListTest

 

  • Python Code Coverage 라이브러리

         - 테스트 코드 커버리지 확인

         - 테스트 케이스가 요구사항에 충족된 정도를 나타내는 수치

 

  • 코드 커버리지 라이브러리 설치
pip install coverage

 

  • 코드 커버리지 라이브러리 실행
coverage run manage.py test

 

  • 코드 커버리지 라이브러리 실행결과 확인
coverage report

 

         - ex) polls_api/serializers.py에 대한 커버리지는 85% 충족됨

         - 커버리지 100%이 가장 이상적인 상태

코드 커버리지 결과 예시

 

 

  • Basic of Testing 

         - 테스트 시 생성된 데이터는 DB에 저장되지 않고, 테스트 종료 시 삭제된다.

         - 테스트 케이스를 나타내는 함수명은 def test_***의 형태로 작성되어야 TestCase로 인지하고 python이 실행

  • Method

         - def setUp(): 클래스 내의 test case 함수 실행시 먼저 실행되는 설정 값이다.(해당 test 함수 종료시 내용 삭제됨)

         - assertEqual(A, B): A객체와 B객체의 값이 같은지 확인(같지 않으면 에러 발생)

         - assertTrue(A): A객체가 False이면 에러 발생(해당 Case가 True이어야 함)

         - assertFalse(A): A객체가 True이면 에러 발생(해당 Case가 False이어야 함)

         - assertLess(A, B): A가 B보다 작다면 True

         - assertIsNotNone(A): A가 None이 아니라면 True

 

  • Testing Serializers

         - Serializer와 관련한 Testing

         - Serializer test는 Class인자 TestCase

 

  • class QuestionSerializerTestCase(TestCase)

         - 질문 생성시 데이터 유효성 검증 Serializer Test

         - test_with_valid_data(): 유효한 데이터가 존재할 때 질문 생성

         - test_with_invalid_data(): 유효한 데이터가 존재하지 않을 때 질문 생성

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)

 

 

  • class VoteSerializerTest(TestCase)

         - 투표 Serializer Test

         - test_vote_serializer(): 정상적인 투표

         - test_vote_serializer_with_duplicate_vote(): 같은 질문에 중복 투표

         - test_vote_serializer_with_unmatched_question(): 질문에 맞지 않는 답변 선택

        
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(
            question = 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.assertFalse(serializer.is_valid()) #해당 Case가 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())

 

 

  • Testing Views

         - Views와 관련한 Testing

         - View test는 Class인자 APITestCase(해당 클래스는 generics.ListCreateAPIView를 상속받았기 때문)

 

  • class VoteList(APITestCase)

         - 로그인(인증)관련 질문 생성 Serializer Test

         - test_create_question(): 로그인된 유저에 의한 질문 생성

         - test_create_question_without_authentication(): 로그인되지 않은 상태로 질문 생성

         - test_list_question(): 질문 목록 받아오기

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_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.assertLess((timezone.now()-question.pub_data).total_seconds(),1) #앞의 인자가 뒤의 인자보다 작다면 True
    
    #로그인되지 않은 상태로 질문 만들기 -> Error 발생(정상)
    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 = '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)

 

공부하며 느낀점

 

지금까지 개발할 때 모든 것을 직접 해보면서 Unit Test를 했는데, 오늘 학습을 통해 Test 코드의 작성 방법과 필요성을 알게 되어 다음부터는 꼭 Test 코드를 작성하면서 개발을 진행해야겠다고 생각했다.

반응형