[Django][The Polls][Chap 2] vote 기능 구현 및 generic view 적용

Info Notice:
안녕하세요. HwanSeok입니다. The Polls 프로젝트는 docs.djangoproject.com를 참조하여 진행됩니다. 본 포스팅은 전체적인 개발 흐름과 The Polls 프로젝트를 효과적으로 이해하기 위한 설명이 포함되어 있습니다.

결과

/polls (index view)

chapter-2-1

/polls/1 (detail view)

chapter-2-2

/polls/1/vote (vote view)

선택을 하지 않고 Vote 버튼을 누른 경우 또는 url을 직접 입력하고 연결된 경우

chapter-2-3

만약 적절히 값을 선택했다면 HttpResponseRedirect에 의해서 /polls/1/vote 경로가 아닌 /polls/1/results로 이동됩니다.

/polls/1/results (results view)

detail view에서 선택한 list의 번호가 choice 값으로 POST data로 전달됩니다. 화면에 보이는 값은 DB에 저장된 값이 객체로 넘어와서 표시된 결과입니다.

chapter-2-4

chapter-2-5

진행사항

detail.view 개선

아래와 같은 view를 구성하기 위해서 아래와 같이 수정합니다.

chapter-2-0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>The Polls(/polls/)</title>
</head>
<body>
    <h1>{{ question.question_text }}</h1>

    {% if error_message %}
        <p><strong>{{ error_message }}</strong></p>
    {% endif %}

    <form action="{% url 'ns_polls:vote' question.id %}" method="post">
        {% csrf_token %}
        {% for choice in question.choice_set.all %}
            <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
            <label for="choice{{ forloop.counter }}">
                {{ choice.choice_text }}
            </label>
            <br>
        {% endfor %}
        <input type="submit" value="Vote">
    </form>
</body>
</html>

template 문서를 수정하는 것은 인자로 전달받은 데이터를 어떻게 보여주는지만 달라질 뿐 그 외의 내용은 영향이 없습니다.

다음 절에서 view를 변경하는 부분이 있는데, view가 어떻게 바뀌어도 template 문서로 전달되는 값이 동일하다면 같은 화면을 구성합니다.

vote view 개선

vote method에서 request는 HttpRsponse 객체입니다. vote view는 detail.html에서 post 형식으로 보내는 choice 값을 request를 통해서 수신할 수 있습니다. 위에서 수정한 form tag에서 post형식으로 ns_polls:vote로 값을 보내는 것을 볼 수 있습니다. 이렇게 수신한 데이터는 dictinary형태의 request.POST에서 찾을 수 있습니다.

항목을 선택하지 않고 제출을 했다면 except에서 error_message를 담아서 detail.html을 다시 render합니다.

값을 제대로 선택했다면 Question 객체 내부의 Choice 객체의 vote Field의 값을 +1 합니다.

Choice 객체의 Field는 polls/models.py에서 설정한 그대로를 사용합니다.

1
2
3
4
5
6
7
class Choice(models.Model):
    def __str__(self):
        return self.choice_text

    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)

post 형식의 데이터를 성공적으로 처리한 뒤에는 HttpResponse가 아닌 HttpResponseRedirect를 return 하는 것이 더 바람직합니다. reverse()는 view 이름과 args를 전달받아서 다른 view로 redirect를 해줍니다. 아래의 코드에서는 reverse()가 /polls/3/results/를 return 합니다. 그리고 자연스럽게 result view가 처리하는 로직이 수행됩니다.

1
2
3
# polls/urls.py 일부
# ex: /polls/5/results/
    path('<int:question_id>/results/', views.results, name='results'),

수정된 전체 views.py는 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# polls/views.py
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse

from .models import Choice, Question
# ...
def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        # Redisplay the question voting form.
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
    else:
        selected_choice.votes += 1
        selected_choice.save()
        # Always return an HttpResponseRedirect after successfully dealing
        # with POST data. This prevents data from being posted twice if a
        # user hits the Back button.
        return HttpResponseRedirect(reverse('ns_polls:results', args=(question.id,)))

Avoiding race conditions using F()

바로 위의 Field 값을 추가하는 코드를 아래와 같이 수정하는 것이 좋습니다.

1
2
selected_choice.votes = F('votes') + 1
# selected_choice.votes += 1

그 이유는 여기에서 별도의 포스트로 정리해두었습니다.

results.html 개선

아래와 같이 수정합니다. question 객체를 전달받아서 내부의 choice 객체의 정보를 출력합니다. get_object_or_404에 의해서 DB에서 객체 정보를 조회하고, 이를 model에 담아서 전달하기 때문에 results.html에서 보여줄 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>The Polls(/polls/)</title>
</head>
<body>
    <h1>{{ question.question_text }}</h1>

    <ul>
        {% for choice in question.choice_set.all %}
            <li>{{ choice.choice_text }} -- {{ choice.votes }}
                vote{{ choice.votes|pluralize }}</li>
        {% endfor %}
    </ul>

    <a href="{% url 'ns_polls:detail' question.id %}">Vote again?</a>
</body>
</html>

vote 기능 테스트

여기까지 진행하면 vote 기능을 테스트해볼 수 있습니다.

Danger Notice:
단, vote 버튼을 여러번 클릭하는 경우 HttpResponseRedirectF()를 사용했음에도 불구하고 여러 번 Field의 값이 증가합니다. 이 문제는 아직 해결되지 않았습니다.

Generic View의 적용

지금까지 구현된 기능은 다음과 같은 과정을 거쳤습니다.

  1. DB에서 데이터 retrieve
  2. Load Template
  3. rendering된 template return
1
2
3
4
5
6
7
8
# 간소화 적용 전
def index(request):
    latest_question_list = Question.objects.order_by('-pub_date')[:5]
    template = loader.get_template('polls/index.html')
    context = {
        'latest_question_list': latest_question_list,
    }
    return HttpResponse(template.render(context, request))
1
2
3
4
5
6
7
# render를 통한 간소화
def index_shortcut(request):
    latest_question_list = Question.objects.order_by('-pub_date')[:5]
    context = {
        'latest_question_list': latest_question_list,
    }
    return render(request, 'polls/index.html', context)

위 세 가지 과정은 반복적으로 진행되는 내용이기 때문에 genericView를 대신 사용할 수 있습니다.

template_name, context_object_name, get_queryset라는 예약어를 통해서 tempalte 문서를 가져오고, 전달할 context object를 지정하고, DB에서 조회한 정보를 전달하는 과정을 generic하게 진행할 수 있도록 해줍니다.

모두가 공유하는 generic한 View 덕분에 제3자가 봤을 때 빠르게 review할 수 있을 것입니다.

1
2
3
4
5
6
7
# genericView를 통한 간소화
class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'
    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]

detail.view도 아래와 같이 간소화될 수 있고,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def detail_template(request, question_id):
    try:
        question = Question.objects.get(pk=question_id)
    except Question.DoesNotExist:
        raise Http404("Question does not exist")
    return render(request, 'polls/detail.html', {'question': question})

# get_object_or_404로 간소화
def detail_or_404(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/detail.html', {'question': question})

# genericView로 간소화
class DetailView(generic.DetailView):
    model = Question
    template_name = 'polls/detail.html'

results.view도 간소화 가능합니다.

1
2
3
4
5
6
7
8
9
10
11
12
def results_before(request, question_id):
    response = "You're looking at the results of question %s."
    return HttpResponse(response % question_id)


def results(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/results.html', {'question': question})

class ResultsView(generic.DetailView):
    model = Question
    template_name = 'polls/results.html'

index view는 generic.ListView로 대치되었고, detail과 result view는 generic.DetailView로 대치되었습니다. detail과 result가 동일한 genericView로 대치될 수 있는 것은 detail.html과 result.html이 둘다 model을 전달받아서 내부의 데이터를 보여주는 기능입니다. 쉽게 정리하면 하나의 모델을 전달받을 때와 모델의 List를 전달받을 때 어떤 genericView를 사용할지가 결정된다고 할 수 있습니다.

마지막으로 urlconf를 아래와 같이 수정해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# polls/urls.py
from django.urls import path

from . import views

app_name = 'ns_polls'
# urlpatterns = [
#     # ex: /polls/
#     path('', views.index, name='index'),
#     # ex: /polls/5/
#     path('<int:question_id>/', views.detail_or_404, name='detail'),
#     # ex: /polls/5/results/
#     path('<int:question_id>/results/', views.results, name='results'),
#     # ex: /polls/5/vote/
#     path('<int:question_id>/vote/', views.vote, name='vote'),
# ]

urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('<int:pk>/', views.DetailView.as_view(), name='detail'),
    path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

Success Notice: 위와 같은 과정을 거쳐 처음에 보았던 결과 페이지를 생성하였습니다. 수고하셨습니다. :+1:

개발환경

  • window 10
  • python 3.6.8
  • django 3.1.5

Leave a comment