1인 1투표 기능 구현하기
Vote 모델 만들기
이전에 투표 기능을 구현할 때([Django] 폼(Forms))는 User의 정보를 따로 받지않아 1명의 User가 여러 번 투표를 할 수 있었다. 이번에는 RESTful API framework를 이용해 계정 정보를 저장하는 모델을 따로 만들어 1개의 계정이 여러 번 투표를 할 수 없도록 하려고 한다.
먼저, 투표한 질문, 답변, 계정 정보를 담을 테이블을 생성하기 위해 모델을 만들어준다.
# mysite/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, related_name='votes', on_delete=models.CASCADE)
class Meta:
# question과 voter 조합이 유니크한 값이어야 함(1개만 존재해야 함)
constraints = [
models.UniqueConstraint(fields=['voter', 'question'], name='unique_voter_question')
]
Vote 모델의 필드로는 question, choice, voter가 있으며 모델의 메타데이터(class Meta)에서 'voter'와 'question' 조합이 유일할 것을 제약 조건(constraints)으로 설정했다.
모델을 새로 만들었으므로 마이그레이션도 해준다.
❯ python manage.py makemigrations
❯ python manage.py migrate
이제 Vote모델에 레코드를 하나 생성해보자. (Shell 사용)
# 질문/답변 선택
>>> from polls.models import *
>>> question = Question.objects.first()
>>> choice = question.choices.first()
>>> print(question, choice)
[2024-10-07 04:20:27+00:00] 가장 추천하는 가을 캠핑장은 어디인가요? [가장 추천하는 가을
캠핑장은 어디인가요?] 포천
# 투표자 선택
>>> from django.contrib.auth.models import User
>>> user = User.objects.get(username='user1')
# Vote 모델에 레코드 생성
>>> Vote.objects.create(voter=user, question=question, choice=choice)
<Vote: Vote object (1)>
>>> Vote.objects.first()
<Vote: Vote object (1)>
Vote 모델에 레코드가 정상적으로 생성되는 것을 확인했다. 하지만 아직 Vote 모델과 Choice 모델의 'votes' 필드는 연결되지 않은 상태이기 때문에, Vote 모델에 새로운 레코드가 생성되어도 Choice의 votes 숫자는 전혀 변하지 않는다.
시리얼라이저에서 votes 필드를 가져올 때, Vote 모델을 참조해 해당 답변의 수가 몇 개인지 카운트하는 기능이 필요하다. serializers.py로 이동해 다음과 같이 코드를 수정해보자.
# mysite/polls_api/serializers.py
class ChoiceSerializer(serializers.ModelSerializer):
votes_count = serializers.SerializerMethodField()
def get_votes_count(self, obj):
return obj.vote_set.count()
class Meta:
model = Choice
fields = ['choice_text', 'votes_count']
votes_count를 SerializerMethodField() 메서드를 사용해 구한다. 이 메서드는 특정 필드의 값을 구할 때 사용되는데, get_{필드 이름}() 메서드를 오버라이딩해 커스텀할 수 있다. 이를 이용해 get_votes_count로 해당 답변을 카운트한다.
Django 서버에서 해당 질문을 살펴봤을 때 Vote모델에 등록했던 '포천' 답변의 votes_count가 1 증가한 것을 알 수 있다.
Vote Serializer 만들고 직접 Vote 생성해보기
이번에는 Vote Serializer를 만들고 그에 대한 view와 url을 설정해 RESTful API를 통해 Vote를 직접 생성해보고자 한다.
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']
먼저 Vote Serializer를 생성해준다. voter 필드에 한해서만 고정적일 수 있게 ReadOnlyField 설정을 지정해주었다.
from polls.models import Question, Vote
from .serializers import QuestionSerializer, UserSerializer, RegisterSerializer, VoteSerializer
from .permissions import IsOwnerOrReadOnly, IsVoteOwner
class VoteList(generics.ListCreateAPIView):
serializer_class = VoteSerializer
# IsAuthenticated 클래스는 인증된 사용자만 투표 가능하고 볼 수 있음
permission_classes = [permissions.IsAuthenticated]
# 투표 목록을 가져올 때 투표자가 인증된 현재 사용자인 경우만 가져옴
def get_queryset(self):
return Vote.objects.filter(voter=self.request.user)
def perform_create(self, serializer):
# save 메서드는 주어진 필드를 모두 사용하므로 readonly와 관계없이 지정 가능
serializer.save(voter=self.request.user)
class VoteDetail(generics.RetrieveDestroyAPIView):
queryset = Vote.objects.all()
serializer_class = VoteSerializer
permission_classes = [permissions.IsAuthenticated, IsVoteOwner]
이후 views.py에서 VoteList와 VoteDetail을 생성해주었다. 기본적으로 QuestionList, QuestionDetail과 비슷하다. 하지만 투표의 경우 현재 로그인된 유저에 해당되는 결과만 가져와야 하기 때문에, 몇 가지 조건이 추가됭었다.
VoteList의 경우 queryset을 get_queryset() 메서드를 통해 가져오게 된다. 모델 필터링을 통해 voter가 현재 요청한 유저와 동일할 경우에만 그 리스트를 출력한다.
VoteDetail의 경우 해당하는 투표를 모두 가져오되, permission이 2가지가 걸려있다. 우선 로그인한 계정이어야 하고, 투표의 주인이어야 한다.(IsVoteOwner) IsVoteOwner는 permissions.py를 통해 따로 구현해 주었다. SAFE_METHOD를 허용했던 IsOwnerOrReadOnly와는 다르게, 완벽히 사용자가 같은 경우에만 허용한다.
# mysite/polls_api/permissions.py
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# SAFE_METHODS는 GET, OPTIONS, HEAD 메서드(읽기 전용)
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
# 조건 추가
class IsVoteOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# 투표자와 현재 사용자가 같은 경우에만 True 반환
return obj.voter == request.user
이어서 urls.py에 해당 URL을 추가해준다.
from django.urls import path, include
from .views import *
urlpatterns = [
...
path('vote/', VoteList.as_view(), name='vote-list'),
path('vote/<int:pk>/', VoteDetail.as_view(), name='vote-detail'),
]
이제 서버를 통해 '/vote' URL로 접속해보자. (User1로 로그인한 상태)
위 이미지에서 보듯이 로그인 상태에서 Vote list 페이지에 접속했을 때 자신이 답변한 질문에 대해서만 리스트가 노출되고 있는 것을 알 수 있다.
상세페이지에 진입해보자.
상세페이지에서도 내 답변이 있는 경우에는 보여주고, 없는 경우에는 보여주지 않는다는 것을 알 수 있다.
Validation 적용해 예외사항 방어하기
위에서 구현한 투표 기능은 몇 가지 문제점이 있다.
첫째로, 만약 이미 답변 완료한 질문에 또 한번 답변을 하게 된다면 아래와 같은 에러가 발생한다.
위에서 설정한 규칙에 의해 1개의 계정은 1개의 질문에 대해 하나의 답변을 가질 수 있는게 맞긴 하지만, 에러 코드를 살펴보면 해당 규칙을 어겨서가 아닌, 서버 에러로 응답하고 있다.
이미 답변한 질문에 대해서 또 한번 답변을 하는 것은 서버 에러가 아닌 사용자의 책임이 있는 것이므로, 500대 에러코드가 아닌 400대 에러코드가 반환되어야 할 것이다.
둘째로, 각 질문에 속한 답변이 아닌 다른 질문의 답변도 선택해 저장할 수 있다는 것이다.
위 이미지에서 선택된 질문은 '야구 vs 축구'라는 질문이지만, 해당 질문의 답변 레코드인 '야구'와 '축구' 이외에도 다른 질문의 답변들이 선택 가능하다. 저장도 정상적으로 되는 모습을 확인할 수 있다.
이 문제들은 Validation 이라는 개념을 활용하면 방어가 가능하다.
먼저 첫 번째 문제를 해결하기 위해 시리얼라이저에 validator를 추가한다.
# mysite/polls_api/serializers.py
from rest_framework.validators import UniqueTogetherValidator
class VoteSerializer(serializers.ModelSerializer):
voter = serializers.ReadOnlyField(source='voter.username')
class Meta:
model = Vote
fields = ['id', 'question', 'choice', 'voter']
# is_valid() 메서드가 호출될 때 유효성 검사방법을 정의
validators = [
UniqueTogetherValidator(
queryset=Vote.objects.all(),
fields=['voter', 'question']
)
]
Validator란, 투표 레코드를 create할 때 유효성 검사를 하는 부분인 is_valid() 메서드를 호출할 때 그 검사 방법을 정의하는 것이라고 볼 수 있다. UniqueTogetherValidator()를 사용해 Vote모델의 모든 object를 대상으로 'voter'와 'question'의 조합이 중복되지 않는지 검사하고, 중복될 경우 레코드 생성을 허가하지 않는다.
이후 첫 번째 문제와 같은 방법으로 POST 요청을 보냈을 때 더 이상 이전과 같은 에러가 발생하지 않고, 400 에러 코드가 응답되는 것을 볼 수 있다. 하지만 조금 이상하게 느껴지는게 있다. 응답 메시지를 살펴보면 voter 필드가 비어 있다고 출력되는 점이다.
이 이유는 현재 views.py의 VoteList 클래스에서는 CreateModelMixin 클래스의 perform_create() 메서드를 상속 받아 사용하는데, 이 메서드의 수행 순서가 is_valid() 메서드의 뒷 순서이기 때문에 ReadOnlyField로 설정된 Voter 필드가 전달되지 않는 것이다.(is_valid() 메서드에 사용하는 validated_data에 읽기 전용 필드는 포함되지 않음)
(아래 코드의 create, perform_create 메서드 참고)
class CreateModelMixin:
"""
Create a model instance.
"""
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.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)
def perform_create(self, serializer):
serializer.save()
def get_success_headers(self, data):
try:
return {'Location': str(data[api_settings.URL_FIELD_NAME])}
except (TypeError, KeyError):
return {}
따라서, 'voter' 필드가 전달되지 않아 에러가 발생하는 방식이 아닌 의도한 바대로의 에러 응답을 내기 위해서는 create() 메서드를 상속받아 사용해야 하고, voter 필드를 읽기 전용 필드로 설정하지 않아야 한다.
# mysite/polls_api/views.py
class VoteList(generics.ListCreateAPIView):
serializer_class = VoteSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
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)
def perform_create(self, serializer):
# save 메서드는 주어진 필드를 모두 사용하므로 readonly와 관계없이 지정 가능
serializer.save(voter=self.request.user)
현재 request의 data를 복사한 new_data에 'voter'필드를 현재 요청을 보낸 사용자의 id로 덮어쓴다. 클라이언트가 제공한 값이 아닌 요청자의 id를 사용해 안전성을 확보하는 것이다.
class VoteSerializer(serializers.ModelSerializer):
# voter = serializers.ReadOnlyField(source='voter.username')
class Meta:
model = Vote
fields = ['id', 'question', 'choice', 'voter']
# is_valid() 메서드가 호출될 때 유효성 검사방법을 정의
validators = [
UniqueTogetherValidator(
queryset=Vote.objects.all(),
fields=['voter', 'question']
)
]
그리고 VoteSerializer의 voter 필드를 읽기 전용 필드로 설정한 부분을 주석처리해 validated_data에 voter 필드가 포함되도록 한다. 이제 유효성 검사가 정상적으로 작동할 것이다.
서버에서 위와 같은 요청을 다시 보냈을 때, 해당 필드는 voter와 question이 unique해야 한다는 메시지가 노출된다. 의도한 바대로의 응답 메시지다.
그러나 위 이미지에서 또 하나의 문제점이 발견된다. Voter 필드를 읽기 전용으로 설정하지 않았기 때문에, 계정이 따른 계정을 선택해 POST 요청을 진행할 수 있게 된 것이다. voter 필드를 읽기 전용으로 설정하지 않으면서, 수정하지 못하도록 하게 하는 방법은 없을까?
1. HiddenField 이용
class VoteSerializer(serializers.ModelSerializer):
# voter 필드를 자동으로 현재 사용자로 설정
voter = serializers.HiddenField(default=serializers.CurrentUserDefault())
class Meta:
model = Vote
fields = ['id', 'question', 'choice', 'voter']
# is_valid() 메서드가 호출될 때 유효성 검사방법을 정의
validators = [
UniqueTogetherValidator(
queryset=Vote.objects.all(),
fields=['voter', 'question']
)
]
첫 번째 방법은 VoteSerializer 클래스에서 voter 필드를 HiddenField로 설정해준 뒤, default값을 현재 로그인된 유저로 설정하는 것이다. 이렇게 되면 voter 필드를 수정할 수 없으면서 전달되는 voter 값은 현재 로그인된 유저가 될 것이다.
2. perform_update() 메서드 이용
class VoteList(generics.ListCreateAPIView):
serializer_class = VoteSerializer
# IsAuthenticated 클래스는 인증된 사용자만 투표 가능하고 볼 수 있음
permission_classes = [permissions.IsAuthenticated]
# 투표 목록을 가져올 때 투표자가 인증된 현재 사용자인 경우만 가져옴
def get_queryset(self):
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)
def perform_create(self, serializer):
# save 메서드는 주어진 필드를 모두 사용하므로 readonly와 관계없이 지정 가능
serializer.save(voter=self.request.user)
class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Vote.objects.all()
serializer_class = VoteSerializer
permission_classes = [permissions.IsAuthenticated, IsVoteOwner]
def perform_update(self, serializer):
# save 메서드는 주어진 필드를 모두 사용하므로 readonly와 관계없이 지정 가능
serializer.save(voter=self.request.user)
VoteList에서는 투표 레코드를 생성할 경우 perform_create() 메서드에 의해 현재 요청한 User 정보가 강제로 지정되므로 Voter 필드를 수정하는 의미가 없다.
하지만 VoteDetail에서는 해당 메서드가 없었기 때문에, 실제로도 수정이 가능했다. 이를 perform_update 메서드를 상속받아 save할 때 voter를 요청한 계정 정보로 강제 지정해 문제를 해결하는 방법이 있다.
각 질문에 속한 답변이 아닌 다른 질문의 답변도 선택해 저장 가능한 문제 - 해결 방법
드디어 두 번째 문제를 해결할 때가 왔다. 이전에 RestfulAPI에서 계정을 생성할 때 비밀번호 확인을 했던 절차가 기억나는가?(User생성 관련 링크) 그 때 사용했던 validate() 메서드를 다시 상속받아 여기서 이용해 보도록 한다.
# mysite/polls_api/serializers.py
class VoteSerializer(serializers.ModelSerializer):
# voter 필드를 자동으로 현재 사용자로 설정
voter = serializers.HiddenField(default=serializers.CurrentUserDefault())
def validate(self, attrs):
if attrs['choice'].question.id != attrs['question'].id:
raise serializers.ValidationError("선택한 질문과 투표한 질문에 대한 답변이 일치하지 않습니다.")
return attrs
class Meta:
model = Vote
fields = ['id', 'question', 'choice', 'voter']
# is_valid() 메서드가 호출될 때 유효성 검사방법을 정의
validators = [
UniqueTogetherValidator(
queryset=Vote.objects.all(),
fields=['voter', 'question']
)
]
POST 요청한 답변의 question.id와 질문의 id가 일치하지 않으면 에러를 발생시킨다.
이후 서로 질문id가 다른 질문과 답변을 POST했을 때, 위와 같은 에러 메시지와 함께 400 에러코드가 노출되는 것을 확인할 수 있다.
'Minding's Programming > Django' 카테고리의 다른 글
[Django] Testing(Testing Serializer, Testing View) (0) | 2024.10.12 |
---|---|
[Django] RelatedField (0) | 2024.10.11 |
[Django] User 항목 모델에 추가 / User 관리 / User생성 / User 권한 (2) | 2024.10.10 |
[Django] 클래스 기반의 View, Mixin, Generic View (0) | 2024.10.10 |
[Django] GET, POST, PUT, DELETE (1) | 2024.10.10 |