[4/12] TIL - Django를 이용한 API 만들기 [5]

Sangwon Jwa·2024년 4월 12일

데브코스 TIL

목록 보기
15/54
post-thumbnail

📖 학습 주제


  1. 연관 필드 처리
  2. 투표(Votes) 기능 구현하기
  3. Validation
  4. Testing

✏️ 주요 메모 사항 소개


RelatedField

저번에 작성한 코드대로 사용자 목록을 불러왔을 때는 user 내의 questions 에 question id 가 들어있었다. 이 항목을 id 가 아닌 다른 값으로 변경해서 가독성을 올리려 한다.

  • serializers.pyUserSerializer의 questions를 받아오는 처리에 serializers.StringRelatedField()를 사용하면 Question 모델의 ''__str__'' 메서드 값을 가져올 수 있다.
class UserSerializer(serializers.ModelSerializer):
    # question들을 가져오는 필드의 정보는 User 테이블에 있는게 아니기 때문에 따로 불러오는 처리가 필요 
    # StringRelatedField 이용하여 Question 모델의 __str__ 메서드를 호출하여 question_text를 가져온다.
    questions = serializers.StringRelatedField(many=True, read_only=True)

    class Meta:
        model = User
        fields = ('id', 'username', 'questions')

[result]

  • 만약 나타내는 내용을 따로 지정하고 싶다면 serializers.SlugrelatedField() 를 사용하면 된다.
class UserSerializer(serializers.ModelSerializer):
    # question들을 가져오는 필드의 정보는 User 테이블에 있는게 아니기 때문에 따로 불러오는 처리가 필요 
    # SlugRelatedField를 사용하여 question_text가 아닌 pub_date를 가져옴
    questions = serializers.SlugRelatedField(many=True, read_only=True, slug_field='pub_date')

    class Meta:
        model = User
        fields = ('id', 'username', 'questions')

[result]

  • question을 나타내고 그 question을 조회할 수 있는 링크까지 제공하고 싶다면 serializers.HyperlinkedRelatedField() 를 사용하면 된다.
class UserSerializer(serializers.ModelSerializer):
    # question들을 가져오는 필드의 정보는 User 테이블에 있는게 아니기 때문에 따로 불러오는 처리가 필요 
    questions = serializers.HyperlinkedRelatedField(many=True, read_only=True, view_name='question-detail')

    class Meta:
        model = User
        fields = ('id', 'username', 'questions')

[result]

  • User 모델처럼 Question 모델에도 Choice 정보까지 출력하도록 설정하기
  1. Choice 모델 변경 (question 필드에 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 self.choice_text
  1. QuestionSerializer에 choice_set 변수 추가
class QuestionSerializer(serializers.ModelSerializer):
    owner = serializers.ReadOnlyField(source='owner.username')
    # choice_set 변수 추가
    choices = ChoiceSerializer(many=True, read_only=True)

    class Meta:
        model = Question
        fields = ('id', 'question_text', 'pub_date', 'owner', 'choices')

[result]


투표 (votes) 기능 구현하기

  1. 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)

    # 하나의 question에 대해 사용자는 하나의 vote만 가질 수 있게 설정.
    class Meta:
        constraints =[
            models.UniqueConstraint(fields=['question', 'voter'], name='unique_voter_for_questions'),
        ]

 

  1. ChoiceSerializer 변경해서 vote의 수 가져오기
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()

  1. VoteSerializer 구현
class VoteSeriazlier(serializers.ModelSerializer):
    voter = serializers.ReadOnlyField(source='voter.username')

    class Meta:
        model = Vote
        fields = ('id', 'question', 'choice', 'voter')

 

  1. view 구현 (VoteList, VoteDetail)
# polls_api/views.py

class VoteList(generics.ListCreateAPIView): 
    serializer_class = VoteSeriazlier
    permissions_classes = [permissions.IsAuthenticated]

    # 아무나 모든 vote를 보면 안되기 때문에 내 vote만 보도록 설정
    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 = VoteSeriazlier
    permissions_classes = [permissions.IsAuthenticated, IsVoter]

 

  1. 커스텀 권할 설정 permissions.py에 추가
from rest_framework import permissions

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

 

  1. url 연결
from django.urls import path, include
from .views import *

urlpatterns = [
    # ...
    
    path('vote/', VoteList.as_view()),
    path('vote/<int:pk>/', VoteDetail.as_view()),
]

Validation (에러 처리)

  • 현재까지 진행한 Vote 서비스의 문제점은 사용자가 같은 질문에 한번 더 Vote를 했을 때 에러를 잘 처리하지 않았고, 질문에 엉뚱한 대답을 해도 그대로 저장이 된다. 이를 고쳐보자.
  1. Serializers.py에 유효성 검사 코드 추가

    class VoteSeriazlier(serializers.ModelSerializer):
       voter = serializers.ReadOnlyField(source='voter.username')
    
       class Meta:
           model = Vote
           fields = ('id', 'question', 'choice', 'voter')
           validators = [
           	# question 과 voter가 Unique한 지를 유효성 검사 진행
               UniqueTogetherValidator(
                   queryset = Vote.objects.all(),
                   fields=['question', 'voter']
               )
           ]
  • 이렇게 코드를 변경 시 create 하기 이전에 유효성 검사를 진행하게 되므로 view 에서 perform_create를 변경해야 한다.
  1. views.py 수정
class VoteList(generics.ListCreateAPIView): 
    serializer_class = VoteSeriazlier
    permissions_classes = [permissions.IsAuthenticated]

    # 아무나 모든 vote를 보면 안되기 때문에 내 vote만 보도록 설정
    def get_queryset(self, *args, **kwargs):
        return Vote.objects.filter(voter=self.request.user)
	
    # perform_create 가 아닌 create 메서드를 바로 오버라이드
    def create(self, request, *args, **kwargs):
    	
        현재 접속한 id를 voter로 설정
        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)
  1. 위의 VoteSerializer의 voter 필드를 삭제
 class VoteSeriazlier(serializers.ModelSerializer):
    class Meta:
        model = Vote
        fields = ('id', 'question', 'choice', 'voter')
        validators = [
        	# question 과 voter가 Unique한 지를 유효성 검사 진행
            UniqueTogetherValidator(
                queryset = Vote.objects.all(),
                fields=['question', 'voter']
            )
        ]
  1. voter를 변경할 수 없도록 강제적으로 지정하는 처리
# polls_api/views.py

class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Vote.objects.all()
    serializer_class = VoteSeriazlier
    permissions_classes = [permissions.IsAuthenticated, IsVoter]
	
    # 접속한 사용자를 voter로 강제적으로 지정
    def perfrom_update(self, serializer):
        serializer.save(voter=self.request.user)
  1. question에 대한 choice 값이 유효한 지를 검사하기
class VoteSeriazlier(serializers.ModelSerializer):
    def validate(self, attrs):
        if attrs['choice'].question.id != attrs['question'].id:
            raise serializers.ValidationError({'Question과 Chocie가 조합이 맞지 않습니다.'})
        return attrs

Testing

기능을 추가할 때 마다 직접 테스트를 해보는 것은 매우 어려워진다. 또한 수정 시 전혀 예상치 못한 곳에서 버그가 발생하기도 한다. 그 때 마다 전체를 테스트하는 것은 비효율적이다. Django 프로젝트는 test.py 파일에 테스트 케이스를 작성해서 테스트를 수행한다.

  1. test.py에 test 작성
from django.test import TestCase

# Create your tests here.

class QuestionSeriazlierTestCase(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")

 

  1. test 명령어 수행 python manage.py test

  • 앞서 작성한 some_method는 실행이 되지 않는데 이는 django에서 이름에 test가 들어있지 않는 함수는 test로 판단하지 않기 때문이다.

  1. 실패할 경우를 처리하려면 self.assertEqual( obj, obj )를 사용한다. 이는 인자로 들어온 두 값이 다를 경우 Fail이 발생한다.
from django.test import TestCase

# Create your tests here.

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

    def test_b(self):
        pass

앞서 만든 웹 서비스 테스트

Testing Serializers

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):
    # setUp 메서드는 각 테스트에 공통부분을 작성해서 일괄적으로 적용시키는 함수이다.
    # 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_with_valid_data(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())

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

        

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)


Testing Views

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
from rest_framework.test import APITestCase
from django.urls import reverse
from rest_framework import status
from django.utils import timezone

#Serializer Test 

#...

# View Test
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_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.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)


💦 공부하며 어려웠던 내용

역시 테스트 케이스를 만들고 테스트를 실행하는 데 꼼꼼하게 따져봐야 한다는 것을 다시 한번 깨달았다. 테스트에서 에러가 났을 때 이게 내가 테스트를 잘못 만든건지, 아니면 소스코드에 문제가 있는건지 계속 왔다 갔다 하면서 어디에서 오류가 생겼는지를 찾는 것이 제일 어려웠던 것 같다. 찾고나면 변수이름을 살짝 다르게 설정했다던지... , () 괄호를 빼먹엇다던지 ... 사소한 부분에서 오류가 생겨서 참 작성할 때부터 정확하게 타이핑 할 껄 이라는 생각이 들었다.


0개의 댓글