Django를 사용해 API 서버 만들기 (TIL 15)

석형원·2024년 4월 12일

TIL

목록 보기
15/52

✏️ 오늘 학습한 내용

1. Users와 Authentication
2. Votes와 Testing


🔎 Users와 Authentication

RelatedField

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

🔎 Votes와 Testing

Votes 기능 구현 - Models

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

Votes 기능 구현 - Serializers & Views

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 발생

Validation

위에서 만든 Vote 기능은 몇 가지 문제가 있음.
1. 이미 Vote한 Question에 다시 Vote할 경우, UNIQUE constraint failed 발생
-> 여기서, 사용자에게 책임이 있다는 400대 Error가 발생해야하는데 500 Error가 발생

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

Testing

지금까지의 방식으로 손으로 하나 하나 테스트하는 건,
기능이 많아질 수록 어려운 방식,
하나의 수정 때문에 모든 것을 다 직접 테스트해볼 수는 없음
-> 자동화된 테스트를 돌려야함.

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

Testing Serializers

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

Testing Views

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)
  • 원하는 Test만 실행하고 싶은 경우, 직접 메소드를 지정
    python manage.py test Path
    e.g.)
    python manage.py test polls_api.tests.QuestionListTest

Test 코드는 코드가 구현하고 있는 바를 모두 표현할 수 있도록 상세히 적어야함

Test가 얼마나 잘 진행됬는지 확인하는 법

  • coverage 설치
    pip install coverage

  • 실행
    coverage run manage.py test

  • cover가 얼마나 됐는 지 coverage 확인
    coverage report

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

profile
데이터 엔지니어를 꿈꾸는 거북이, 한걸음 한걸음

0개의 댓글