폼으로 댓글 기능 구현하기[3]

Posted on 2021-12-23 by GKSRUDTN99
Django로 웹사이트 만들기 Django Comment

폼으로 댓글 기능 구현하기[3]

댓글 작성 폼 구현하기

1. 댓글 작성 폼을 위한 테스트 코드 작성하기

  • 로그인 하지 않은 경우에는 댓글을 남기지 못하게 할 것이다.
    • 로그인하지 않은 경우, 댓글을 입력하는 폼이 보이지 않고, Login 버튼이 보이도록 한다.
  • 위 내용을 만족하는 테스트 코드를 작성한다.
# /blog/tests.py
# (...생략...)
def test_comment_form(self):
    # setUp() 함수에서 생성한 Comment가 1개인 것을 확인한다.
    self.assertEqual(Comment.objects.count(), 1)
    # 이 댓글은 self.post_001에 달린 댓글이므로 post_001의 댓글 개수도 1개인 것을 확인한다.
    self.assertEqual(self.post_001.comment_set.count(), 1)

    # 로그인하지 않은 상태 테스트
    response = self.client.get(self.post_001.get_absolute_url())
    self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')

    # id가 'comment-area'인 div 요소를 찾아 comment-area에 저장한다.
    comment_area = soup.find('div', id='comment-area')
    # 로그인하지 않은 상태이므로 'Log in and leave a comment'라는 문구가 보여야 한다.
    self.assertIn('Log in and leave a comment', comment_area.text)
    # 로그인하지 않은 상태이므로 id가 comment-form인 요소는 존재하지 않아야 한다.
    self.assertFalse(comment_area.find('form', id='comment-form'))
  • 로그인이 되어 있는 경우에는 댓글 폼이 보여야 한다.
    • 댓글 폼은 텍스트만 받을 수 있는 input만 있으면 된다.
    • 작성자와 작성시간은 로그인한 사용자 정보를 이용해 자동으로 채울 것이다.
    • 댓글 작성자가 댓글을 남기고 submit버튼을 클릭하면 댓글이 생성되고, 그 위치로 브라우저가 이동한다.
  • 위 내용을 만족하는 테스트 코드를 작성한다.
# /blog/test.py
# (...생략...)
def test_comment_form(self):
    # (...생략...)
    # 로그인한 상태 테스트
    self.client.login(username='obama', password='somepassword')
    response = self.client.get(self.post_001.get_absolute_url())
    self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')

    comment_area = soup.find('div', id='comment-area')
    # 로그인한 상태이므로 로그인을 요구하는 문구는 보이지 않는다.
    self.assertNotIn('Log in and leave a comment', comment_area.text)

    # 로그인한 상태이므로 댓글 폼이 보이고, 그 안에 textare도 있다.
    comment_form = soup.find('form', id='comment-form')
    self.assertTrue(comment_form.find('textarea',id='id_content'))
    # POST 방식으로 댓글 내용을 서버에 보내고, 결과를 response에 담는다.
    # follow=True 옵션은 POST로 요청을 보내는 경우 서버에서 처리한 후 리다이렉트되는데, 이에 따라가도록 설정하는 역할을 한다.
    response = self.client.post(
        self.post_001.get_absolute_url() + 'new_comment/',
        {
            'content': "오바마의 댓글입니다.",
        },
        follow=True
    )

    self.assertEqual(response.status_code, 200)

    # 댓글이 하나 추가되었으므로 전체 댓글 개수는 2개임을 확인한다.
    self.assertEqual(Comment.objects.count(), 2)
    # 추가된 댓글이 self.post_001의 것이므로 해당 post의 댓글 개수도 2개임을 확인한다.
    self.assertEqual(self.post_001.comment_set.count(), 2)

    # 마지막으로 생성된 comment를 가져온다.
    new_comment = Comment.objects.last()

    # POST요청을 보낼 때 follow=True로 설정했기 때문에 서버에서 처리된 뒤 comment가 달린 포스트의 상세 페이지로 리다이렉트된다.
    # 그래서 웹 브라우저의 타이틀로 새로 만든 comment가 달린 포스트의 타이틀이 나타난다.
    soup = BeautifulSoup(response.content, 'html.parser')
    self.assertIn(new_comment.post.title, soup.title.text)

    # 새로 만든 댓글의 내용과 작성자가 표시되는 것을 확인한다.
    comment_area = soup.find('div', id='comment-area')
    new_comment_div = comment_area.find('div', id=f'comment-{new_comment.pk}')
    self.assertIn('obama', new_comment_div.text)
    self.assertIn('오바마의 댓글입니다.', new_comment_div.text)

2. 로그인 상태에 따라 댓글 입력란 또는 로그인 버튼 나타내기

  • post_detail.html 파일에서 댓글 폼에 해당하는 곳을 수정한다.
    • if 문을 사용해서 로그인했을 때만 폼이 보이도록 수정한다.
    • 로그인하지 않은 경우에는 로그인 모달을 나타낼 수 있는 버튼을 만든다.
<!-- /blog/templates/blog/post_detail.html-->
<!-- ...생략... -->
<!-- Comments section-->
<div class="mb-5" id="comment-area">
    <div class="card bg-light">
        <div class="card-body">
            <!-- Comment form-->
            {% if user.is_authenticated %}
                <form class="mb-4" id="comment-form" method="POST" action="{{ post.get_absolute_url }}new_comment/">
                    <textarea class="form-control" rows="3" placeholder="Join the discussion and leave a comment!"></textarea>
                </form>
            {% else %}
                <a role="button" class="btn btn-outline-dark btn btn-sm col-12" href="#" data-toggle="modal" data-target="#loginModal">
                Log in and leave a comment
                </a>
            {% endif %}
<!-- ...생략... -->
  • 이렇게 구현한 뒤 로그아웃한 상태에서 포스트 상세 페이지를 열어보면 댓글 영역이 버튼으로 대체된다.
  • 터미널에서 python manage.py test로 테스트를 수행해보면 id가 'id_content'인 textarea가 없다고 한다.

3. CommentForm 구현하기

  • forms.py 만들고 필드 추가하기
    • blog 앱 폴더 아래 forms.py 파일을 만들고 다음과 같이 작성한다.
    • models.py에서 Comment 모델을 불러오고, django의 forms도 임포트한다.
    • 폼 이름은 CommentForm이고, Comment모델을 사용한다.
    • Comment 모델에는 여러 개의 필드가 있지만 여기서는 content 필드만 입력받을 것이므로 fields = ('content', )와 같이 작성한다.
#/blog/forms.py
from .models import Comment
from django import forms

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ('content', )
        # fields(포함시키기) 대신 exclude(제외시키기)를 사용할 수도 있다 
  • views.py 수정하고 CommentForm 적용하기
    • 이렇게 만든 폼을 PostDetail 클래스에 넘겨주기만 하면 된다.]
# /blog/views.py
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.shortcuts import render, redirect
from django.utils.text import slugify
from django.views.generic import ListView, DetailView, CreateView, UpdateView

# CommentForm을 임포트한다.
from .forms import CommentForm
from .models import Post, Category, Tag

# (... 생략 ...)
# PostDetail 클래스의 get_context_data() 함수에서 CommentForm을 comment_form이라는 이름으로 넘긴다.
class PostDetail(DetailView):
    model = Post

    def get_context_data(self, **kwargs):
        context = super(PostDetail, self).get_context_data()
        context['categories'] = Category.objects.all()
        context['no_category_post_count'] = Post.objects.filter(category=None).count()
        context['comment_form'] = CommentForm

        return context
# (... 생략 ...)
  • comment-form을 사용할 수 있도록 post_detail.html을 수정한다.
    • 폼이 예쁘게 보이도록 post_form에서 적용했듯이 crispy를 적용한다.
<!-- /blog/templates/post_detail.html-->
{% extends 'blog/base.html' %}
{% load crispy_forms_tags %}
<!-- (...생략...) -->
<!-- Comment form-->
    {% if user.is_authenticated %}
        <form class="mb-4" id="comment-form" method="POST" action="{{ post.get_absolute_url }}new_comment/">
            {{ comment_form | crispy }}
            <button type="submit" class="btn btn-primary">Submit</button>
        </form>
  • 이제 웹 브라우저에서 로그인하면 댓글을 작성할 수 있는 창이 나타난다.
  • 테스트를 수행해보면 textarea를 찾을 수 없다는 오류는 발생하지 않지만, POST의 Response Status Code가 200이 아닌 404가 되어 실패한다.

3. urls.py 수정해 new_comment 경로 추가하기

  • 현재는 POST로 보낸 comment_form의 내용을 받아들일 준비가 되어있지 않아 새로운 Comment를 만들지 못하고 있다.
  • blog의 urls.py에 new_comment 경로를 추가한다.
    • url에 있는 pk로 포스트를 찾는다.
    • 이번에는 FBV 스타일로 만들 것이므로 views 뒤에 소문자로 new_comment라고 작성한다.
# /blog/urls.py
from django.contrib import admin
from django.urls import path
from . import views

urlpatterns = [
    path('update_post/<int:pk>/', views.PostUpdate.as_view()),
    path('create_post/', views.PostCreate.as_view()),
    path('tag/<str:slug>/', views.tag_page),
    path('category/<str:slug>/', views.category_page),
    path('<int:pk>/new_comment/', views.new_comment),
    path('<int:pk>/', views.PostDetail.as_view()),
    path('', views.PostList.as_view()),
]
  • new_comment 함수를 views.py에 구현한다.
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied
# get_object_or_404를 임포트한다.
from django.shortcuts import render, redirect, get_object_or_404
from django.utils.text import slugify
from django.views.generic import ListView, DetailView, CreateView, UpdateView

from .forms import CommentForm
from .models import Post, Category, Tag

# (... 생략 ...)

def new_comment(request, pk):
    # 로그인하지 않은 상태로 댓글을 작성하려 하면 PermissionDenied를 발생시킨다.
    if request.user.is_authenticated:
        # url에서 인자로 받은 pk로 댓글을 추가할 post를 가져오는데, 실패할 시 404에러를 발생시키도록 get_object_or_404를 사용한다.
        post = get_object_or_404(Post, pk=pk)

        # POST방식이 아닌 다른 method로 접근했을 경우 그냥 해당 포스트의 상세 페이지로 리다이렉트 되도록 한다.
        if request.method == 'POST':
            # POST방식으로 들어온 정보를 CommentForm의 형태로 가져온다.
            comment_form = CommentForm(request.POST)
            # 폼이 유효하게 작성되었다면 해당 내용으로 새로운 레코드를 만들어 DB에 저장한다.
            # 이때 commit=False 옵션으로 바로 저장하는 기능을 잠시 미루고 comment_form에 담긴 정보로 Comment 인스턴스만 가져온다.
            # 이때 CommentForm은 content 필드의 내용만 담고 있으므로 필요한 나머지 정보들을 모두 채운뒤 save()를 호출하여 저장한다.
            if comment_form.is_valid():
                comment = comment_form.save(commit=False)
                comment.post = post
                comment.author = request.user
                comment.save()
                # 처리가 끝나면 comment의 URL로 리다이렉트한다.
                return redirect(comment.get_absolute_url())
        else:
            return redirect(post.get_absolute_url())
    else:
        raise PermissionDenied
  • 이제 테스트를 수행해보면 OK가 나오지만, 웹 브라우저에서 직접 댓글을 작성하려 하면 403 오류가 발생한다.
    • form 안에 CSRF Token이 없어 발생하는 오류로, post_detail의 댓글작성 폼 안에 {% csrf_token %}이 있는지 확인한다.
    • 장고에서는 form을 사용할 때 보안을 위해 form 안에 무작위의 토큰을 input 값으로 부여하고, 나중에 서버에 POST로 들어온 값을 확인할 때 그 토큰 값이 맞는지 확인하는 과정을 거친다.
    • 따라서 특별한 경우가 아니라면 form에 항상 CSRF 토큰을 추가해주자.
<!-- /blog/templates/post_detail.html-->
<!-- Comment form-->
{% if user.is_authenticated %}
    <form class="mb-4" id="comment-form" method="POST" action="{{ post.get_absolute_url }}new_comment/">
        {% csrf_token %}
        {{ comment_form | crispy }}
  • 이제 웹 브라우저에서도 정상적으로 댓글을 작성할 수 있다.