본문 바로가기

Flask-Study

Ep06 : 블로그 웹 애플리케이션 개발(3) - 댓글 CRUD, 게시물 삭제 처리, 간단한 contact form 구현하기

Superuser 생성하기

이제는 우리가 회원가입을 하면 기본적으로 is_staff의 기본값이 False로 저장이 된다. 이를 해결하기 위해서 스태프 권한을 가진 유저를 쉽게 만들 수 있도록 작업을 할 것이다. 우선 __init__.py에 아래의 코드를 추가해 준다.

 

 

그리고 터미널에서 flask create_superuser를 입력하면 스태프 권한이 있는 사용자를 만들 수 있다.

 

 

댓글 작성을 위한 HTML Form 작성하기

우선 post_detail.html에 아래 코드를 추가해 준다.

 

<!--comment -->
    <section class="mb-5">
        <div class="card bg-light">
            <div class="card-body" id="comment-wrapper">
                <!-- Comment form-->
                <form class="mb-4" method="post" action="{{ url_for("views.create_comment", id=post.id) }}">
                    {{ form.csrf_token }}
                    <textarea class="form-control mb-3" rows="3" name="content"
                              placeholder="Join the discussion and leave a comment!"></textarea>
                    <div style="text-align: right">
                        <button class="btn btn-info" id="submitButton"
                                style="width: 150px;height: 45px; font-size: 12px;" type="submit">
                            Comment
                        </button>
                    </div>
                </form>
                {% for comment in comments %}
                    <!-- Single comment-->
                    <div class="d-flex">
                        <div class="flex-shrink-0"><img class="rounded-circle"
                                                        src="https://dummyimage.com/50x50/ced4da/6c757d.jpg"
                                                        alt="...">
                        </div>
                        <div class="ms-3">
                            <div class="fw-bold">{{ comment.user }}</div>
                            {{ comment.content }}
                        </div>
                    </div>
                    {% if current_user.username==comment.user.username %}
                        <button class="btn btn-secondary"><a href="">Edit comment</a></button>
                    {% endif %}
                {% endfor %}
            </div>
        </div>
    </section>

 

댓글 모델 작성하기

models.py에 아래 코드를 추가하여 Comment 모델을 만들어 준다.

 

 

댓글 폼 검증을 위한 forms.py 작성하기

Flask-WTForms를 이요하여 댓글의 유효을 검증할 것이므로 아래 코드를 forms.py에 작성해 준다.

 

 

테스트 코드 작성

댓글을 다는 과정을 확인하기 위해서 새로운 테스트 코드를 tests.py에 작성해 준다.

 

class TestComment(unittest.TestCase):
    '''
    댓글은 폼에서의 POST 요청을 보냄으로서 이루어집니다.
    "comment" 버튼을 누르면, 폼에 있는 내용이 서버로 전송되고, 그를 받아서 데이터베이스에 저장해야 합니다.
    댓글을 저장하고 나면 댓글을 작성한 해당 게시물로 자동 이동해야 합니다.
    댓글을 저장하고 나면 댓글이 해당 게시물에 잘 달려 있는 것을 확인해야 합니다.
    댓글을 저장하고 나면 작성자가 제대로 표시되어야 합니다.
    로그인을 한 사람만 댓글을 수정할 수 있습니다.
    '''

    # 테스트를 위한 사전 준비
    def setUp(self):
        self.ctx = app.app_context()
        self.ctx.push()
        self.client = app.test_client()
        # 테스트를 위한 db 설정
        self.db_uri = 'sqlite:///' + os.path.join(basedir, 'test.db')
        app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
        app.config['TESTING'] = True
        app.config['WTF_CSRF_ENABLED'] = False
        app.config['SQLALCHEMY_DATABASE_URI'] = self.db_uri
        if not path.exists("tests/" + "test_db"):  # DB 경로가 존재하지 않는다면,
            db.create_all(app=app)  # DB를 하나 만들어낸다.

        # 2명의 유저 생성하기
        self.james = get_user_model()(
            email="james@example.com",
            username="james",
            password="12345",
            is_staff=False,
        )
        db.session.add(self.james)
        db.session.commit()
        self.nakamura = get_user_model()(
            email="nk222f@example.com",
            username="nakamura",
            password="12345",
            is_staff=False,
        )
        db.session.add(self.nakamura)
        db.session.commit()

        # 댓글을 작성할 게시물 하나 생성하기
        self.example_post = get_post_model()(
            title="댓글 작성을 위한 게시물을 추가합니다.",
            content="부디 테스트가 잘 통과하길 바랍니다.",
            category_id="1",
            author_id=1 # 작성자는 james
        )
        db.session.add(self.example_post)
        db.session.commit()
        self.assertEqual(get_post_model().query.count(), 1)

    # 테스트가 끝나고 나서 수행할 것, 테스트를 위한 데이터베이스의 내용들을 모두 삭제한다.
    def tearDown(self):
        os.remove('tests/test.db')
        self.ctx.pop()

    def test_add_comment(self):
        app.test_client_class = FlaskLoginClient
        with app.test_client(user=self.james) as james:
            response = james.post('/create-comment/1', data=dict(content="만나서 반갑습니다!"))
            self.assertEqual(302, response.status_code) # 댓글을 작성하면 해당 페이지로 자동 리디렉션되어야 한다.
            self.assertEqual(get_comment_model().query.count(), 1) # 작성한 댓글이 데이터베이스에 잘 추가되어 있는가?
            response = james.get('/posts/1')
            soup = BeautifulSoup(response.data, 'html.parser')
            comment_wrapper = soup.find(id="comment-wrapper")
            self.assertIn("만나서 반갑습니다!", comment_wrapper.text) # 작성한 댓글의 내용이 게시물의 상세 페이지에 잘 표시되는가?
            self.assertIn("james", comment_wrapper.text) # 작성자의 이름이 잘 표시되는가?
            self.assertIn("Edit comment", comment_wrapper.text) # 작성자로 로그인되어 있을 경우 수정 버튼이 잘 표시되는가?
            james.get('/auth/logout') # james에서 로그아웃
        with app.test_client(user=self.nakamura) as nakamura:
            response = james.get('/posts/1')
            soup = BeautifulSoup(response.data, 'html.parser')
            comment_wrapper = soup.find(id="comment-wrapper")
            self.assertNotIn("Edit comment", comment_wrapper.text) # 작성자로 로그인되어 있지 않을 경우 수정 버튼이 보이지 않는가?   
            
        db.session.close()

 

그리고 댓글 폼에 대한 정보를 템플릿에 넘겨주기 위해 views.py에서 post_detail 부분을 아래와 같이 수정해 준다.

 

 

댓글 생성, 조회 구현하기

이제 테스트를 돌려보면 아래와 같은 오류가 발생한다.

 

 

이는 create-comment 에 대한 라우팅이 존재하지 않아 발생한 문제이다. 이를 해결하기 위해서 views.py에서 라우팅을 추가해 준다.

 

 

그리고 post_detail.html에서 comment.user 부분을 아래와 같이 수정해 준다.

 

 

그리고 테스트를 돌려보면 아래와 같은 오류가 뜬다.

 

 

댓글 수정하기 버튼이 없어서 생기는 오류이다. 이를 해결하기 위해서 아래 코드를 post_detail.html에 추가해 준다.

 

 

이제 웹 페이지에 들어가서 동작을 해보면 아래와 같은 결과가 나온다!

 

 

댓글 수정 구현하기

일단 새로운 테스트 코드를 작성해 준다.

 

def test_update_comment(self):
        '''
        임의의 유저로 댓글을 작성하고,
        띄워진 모달 창에서 수정 작업을 거친 후 수정 버튼을 누르면 예전의 댓글 내용이 수정한 내용으로 잘 바뀌어 있어야 한다.
        '''
        app.test_client_class = FlaskLoginClient
        with app.test_client(user=self.james) as james:
            response = james.post('/create-comment/1', data=dict(content="만나서 반갑습니다!")) # james 로 댓글을 하나 작성한 다음,
            self.assertEqual(response.status_code, 302) # 작성이 된 후 정상적으로 리디렉션되어야 한다.
            response = james.post('/edit-comment/1/1', data=dict(content="댓글 내용을 수정합니다!")) # 댓글을 수정해 주고,
            self.assertEqual(302, response.status_code)  # 수정이 완료된 후 정상적으로 리디렉션되어야 한다.
            response = james.get('/posts/1')
            soup = BeautifulSoup(response.data, 'html.parser')
            comment_wrapper = soup.find(id='comment-wrapper')
            self.assertNotIn("만나서 반갑습니다!", comment_wrapper.text) # 기존의 댓글 내용은 있으면 안 되고
            self.assertIn("댓글 내용을 수정합니다!", comment_wrapper.text) # 수정한 댓글의 내용이 표시되어야 한다.
            james.get('/auth/logout') # james에서 로그아웃
        
        db.session.close()

 

테스트 코드를 작성하고 테스트를 실행해 보면 아래와 같은 오류가 뜬다.

 

 

아까 봤던 오류와 같은 오류이다. 이를 해결하기 위해서 views.py에 아래 코드를 추가해 준다.

 

그리고 테스트를 다시 실행해 보면 OK가 뜨는 것을 볼 수 있다.

 

 

다음 기능을 구현하기 전에 post_detail.html 폼에서 부트스트랩을 사용할 수 있도록 아래처럼 수정을 해준다.

 

{% block content %}
    <!-- Post Content-->
    <article class="mb-4">
        <div class="container px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 mb-5 col-xl-7" id="content-wrapper">
                    {{ post.content }}
                </div>
            </div>
            <hr/>
            <!--comment -->
            <section class="mb-5">
                <div class="card bg-light">
                    <div class="card-body" id="comment-wrapper">
                        <!-- Comment form-->
                        <form class="mb-4" method="post" action="{{ url_for("views.create_comment", id=post.id) }}">
                            {{ form.csrf_token }}
                            <textarea class="form-control mb-3" rows="3" name="content"
                                      placeholder="Join the discussion and leave a comment!"></textarea>
                            <div style="text-align: right">
                                <button class="btn btn-info" id="submitButton"
                                        style="width: 150px;height: 45px; font-size: 12px;" type="submit">
                                    Comment
                                </button>
                            </div>
                        </form>
                        {% for comment in comments %}
                            <!-- Single comment-->
                            <div class="d-flex">
                                <div class="flex-shrink-0"><img class="rounded-circle"
                                                                src="https://dummyimage.com/50x50/ced4da/6c757d.jpg"
                                                                alt="...">
                                </div>
                                <div class="ms-3">
                                    <div class="fw-bold">{{ comment.user.username }}</div>
                                    {{ comment.content }}
                                </div>
                            </div>
                            {% if current_user.username==comment.user.username %}
                                <!-- 수정 버튼 -->
                                <button type="button" class="btn btn-primary" data-bs-toggle="modal"
                                        data-bs-target="#editCommentModal{{ comment.id }}">
                                    Edit comment
                                </button>
                                <!-- Modal -->
                                <div class="modal fade" id="editCommentModal{{ comment.id }}" tabindex="-1"
                                     aria-labelledby="exampleModalLabel" aria-hidden="true">
                                    <div class="modal-dialog">
                                        <div class="modal-content">
                                            <div class="modal-header">
                                                <h5 class="modal-title" id="exampleModalLabel">Comment id
                                                    : {{ comment.id }} Edit</h5>
                                                <button type="button" class="btn-close" data-bs-dismiss="modal"
                                                        aria-label="Close"></button>
                                            </div>
                                            <form method="post" class="form-control"
                                                  action="{{ url_for("views.edit_comment", post_id=post.id, comment_id=comment.id) }}">
                                                {{ form.csrf_token }}
                                                <div class="modal-body">
                                                    <input type="text" class="form-control" name="content"
                                                           value="{{ comment.content }}"/>
                                                </div>
                                                <div class="modal-footer">
                                                    <button type="button" class="btn btn-secondary"
                                                            data-bs-dismiss="modal">
                                                        Close
                                                    </button>
                                                    <button type="submit" class="btn btn-primary">Edit comment</button>
                                                </div>
                                            </form>
                                        </div>
                                    </div>
                                </div>

                            {% endif %}
                        {% endfor %}
                    </div>
                </div>
            </section>
        </div>
    </article>
{% endblock %}

 

웹 페이지에서 실행을 해 보면 디자인이 아래처럼 바뀐 것을 볼 수 있다.

 

 

 

게시물, 댓글 삭제 처리하기

게시물과 댓글의 삭제는 "/delete-post/post의 id"나 "/delete-comment/post의 id"로 요청이 들어오면 데이터 베이스에서 해당 게시물과 댓글을 삭제하는 식으로 구현할 것이다. views.py에 아래 코드를 추가해 주면 된다.

 

 

다음으로 게시물이나 댓글을 작성한 사람만 수정, 삭제가 보이도록 수정할 것이다. post_detail.html 에서 게시물과 댓글 부분에 아래 코드를 추가해서 기능을 구현해 보자

 

{% extends 'base.html' %}

{% block title %}{{ post.title }}{% endblock %}


{% block header %}
    <!-- Page Header-->
    <header class="masthead" style="background-image: url('assets/img/post-bg.jpg')">
        <div class="container position-relative px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-7">
                    <div class="post-heading">
                        <div id="title-wrapper"><h1>{{ post.title }}</h1></div>
                        <span class="meta">
                            <div id="author-wrapper"><p>Posted by : <a href="#!">{{post.user.username}}</a></p></div>
                            <p>created_at : {{ post.created_at }}</p>
                            {% if post.user.username == current_user.username %}
                            <!-- 게시물 생성 버튼 -->
                            <button class="btn btn-info" id="edit-button">
                                <a href="{{ url_for("views.edit_post", id=post.id) }}">Edit</a>
                            <!-- 게시물 수정 버튼 -->
                            <button class="btn btn-info" style="padding: 5px; margin: 10px;" id="edit-button">
                                <a href="{{ url_for("views.edit_post", id=post.id) }}">Edit</a>
                            </button>
                            <!-- 게시물 삭제 버튼 -->
                            <button class="btn btn-danger" style="padding: 5px; margin: 10px;" id="edit-button">
                            <a href="{{ url_for("views.delete_post", id=post.id) }}">Delete</a>
                            </button>
                            {% endif %}
                            </span>
                    </div>
                </div>
            </div>
        </div>
    </header>
{% endblock %}

{% block content %}
    <!-- Post Content-->
    <article class="mb-4">
        <div class="container px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 mb-5 col-xl-7" id="content-wrapper">
                    {{ post.content }}
                </div>
            </div>
            <hr/>
            <!--comment -->
            <section class="mb-5">
                <div class="card bg-light">
                    <div class="card-body" id="comment-wrapper">
                        <!-- Comment form-->
                        <form class="mb-4" method="post" action="{{ url_for("views.create_comment", id=post.id) }}">
                            {{ form.csrf_token }}
                            <textarea class="form-control mb-3" rows="3" name="content"
                                      placeholder="Join the discussion and leave a comment!"></textarea>
                            <div style="text-align: right">
                                <button class="btn btn-info" id="submitButton"
                                        style="width: 150px;height: 45px; font-size: 12px;" type="submit">
                                    Comment
                                </button>
                            </div>
                        </form>
                        {% for comment in comments %}
                            <!-- Single comment-->
                            <div class="d-flex">
                                <div class="flex-shrink-0"><img class="rounded-circle"
                                                                src="https://dummyimage.com/50x50/ced4da/6c757d.jpg"
                                                                alt="...">
                                </div>
                                <div class="ms-3">
                                    <div class="fw-bold">{{ comment.user.username }}</div>
                                    {{ comment.content }}
                                </div>
                            </div>
                            {% if current_user.username==comment.user.username %}
                                <!-- 수정 버튼 -->
                                <button type="button" class="btn btn-primary" data-bs-toggle="modal"
                                        data-bs-target="#editCommentModal{{ comment.id }}">
                                    Edit comment
                                </button>
                                 <!-- 댓글 삭제 버튼 -->
                                <button class="btn btn-danger" style="padding: 5px; margin: 10px;" id="edit-button">
                                <a href="{{ url_for("views.delete_comment", id=post.id) }}">Delete</a>
                                </button>
                                <!-- Modal -->
                                <div class="modal fade" id="editCommentModal{{ comment.id }}" tabindex="-1"
                                     aria-labelledby="exampleModalLabel" aria-hidden="true">
                                    <div class="modal-dialog">
                                        <div class="modal-content">
                                            <div class="modal-header">
                                                <h5 class="modal-title" id="exampleModalLabel">Comment id
                                                    : {{ comment.id }} Edit</h5>
                                                <button type="button" class="btn-close" data-bs-dismiss="modal"
                                                        aria-label="Close"></button>
                                            </div>
                                            <form method="post" class="form-control"
                                                  action="{{ url_for("views.edit_comment", post_id=post.id, comment_id=comment.id) }}">
                                                {{ form.csrf_token }}
                                                <div class="modal-body">
                                                    <input type="text" class="form-control" name="content"
                                                           value="{{ comment.content }}"/>
                                                </div>
                                                <div class="modal-footer">
                                                    <button type="button" class="btn btn-secondary"
                                                            data-bs-dismiss="modal">
                                                        Close
                                                    </button>
                                                    <button type="submit" class="btn btn-primary">Edit comment</button>
                                                </div>
                                            </form>
                                        </div>
                                    </div>
                                </div>

                            {% endif %}
                        {% endfor %}
                    </div>
                </div>
            </section>
        </div>
    </article>
{% endblock %}

 

Contact Form을 이용해서 메일 전송 구현하기

Contact Form을 이요하여 사용자가 관리자 계정으로 이메일을 보낼 수 있도로 구현할 것이다. 우선 pip install flask-email을 터미널에 입력하여 설치해 준다. 이메일을 보낼 때 문의하는 사람의 이메일과 이름은 그대로, 핸드폰 번호와 메시지만 입력하게끔 할 것이다. 다음으로 models.py에서 새로운 모델과 forms.py에서 폼을 작성해 준다.

 

 

다음으로 views.py에서 아래 코드를 추가하여 로그인 여부를 체크하고, GET 방식으로 요청하면 폼을 띄워주고, .POST 방식으로 요청을 받으면 데이터를 처리하는 기능을 추가해 준다.

 

 

다음으로 contact.html에 아래 코드를 추가해 준다.

 

{% extends 'base.html' %}

{% block title %}Contact{% endblock %}

{% block header %}
<!-- Page Header-->
<header class="masthead" style="background-image: url('{{ url_for('static', filename='assets/img/contact-bg.jpg') }}')">
    <div class="container position-relative px-4 px-lg-5">
        <div class="row gx-4 gx-lg-5 justify-content-center">
            <div class="col-md-10 col-lg-8 col-xl-7">
                <div class="page-heading">
                    <h1>Contact Me</h1>
                    <span class="subheading">Have questions? I have answers.</span>
                </div>
            </div>
        </div>
    </div>
</header>
{% endblock %}

{% block content %}
<!-- Main Content-->
<main class="mb-4">
    <div class="container px-4 px-lg-5">
        <div class="row gx-4 gx-lg-5 justify-content-center">
            <div class="col-md-10 col-lg-8 col-xl-7">
                <p>궁굼한 점이 있다면 꼭 연락주세요. :)</p>
                <div class="my-5">
                    <form id="contactForm" method="post">
                        {{ form.csrf_token }}
                        <div class="form-floating">
                            <input class="form-control" id="name" name="name" type="text" value="{{ current_user.username }}"
                                   data-sb-validations="required" readonly/>
                            <label for="name">Name</label>
                            <div class="invalid-feedback" data-sb-feedback="name:required">A name is required.</div>
                        </div>
                        <div class="form-floating">
                            <input class="form-control" id="email" name="email" type="email" value="{{ current_user.email }}"
                                   data-sb-validations="required.email" readonly/>
                            <label for="email">Email address</label>
                            <div class="invalid-feedback" data-sb-feedback="email:required">An email is required.</div>
                            <div class="invalid-feedback" data-sb-feedback="email:email">Email is not valid.</div>
                        </div>
                        <div class="form-floating">
                            <input class="form-control" id="phone" type="tel" name="phone" placeholder="Enter your phone number..."
                                   data-sb-validations="required"/>
                            <label for="phone">Phone Number</label>
                            <div class="invalid-feedback" data-sb-feedback="phone:required">A phone number is
                                required.
                            </div>
                        </div>
                        <div class="form-floating">
                            <textarea class="form-control" id="message" name="message" placeholder="Enter your message here..."
                                      style="height: 12rem" data-sb-validations="required"></textarea>
                            <label for="message">Message</label>
                            <div class="invalid-feedback" data-sb-feedback="message:required">A message is required.
                            </div>
                        </div>
                        <br/>
                        <button class="btn btn-primary text-uppercase" id="submitButton" type="submit">Send
                        </button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</main>
{% endblock %}

 

관리자 페이지에서 데이터를 조회할 수 있도록 __init__.py에 ContactMessage 모델을 등록해 준다.

 

 

이제 페이지에서 데이터를 보낸 후 관리자 페이지에서 확인을 해보면 성공적으로 데이터가 보내진 것을 볼 수 있다.

 

 

Home 페이지에 데이터베이스에 있는 가장 최근 게시물 5개 나타내기

가장 최근 게시물을 나타내기 위해서 Post 폼에 있는 작성일자를 기준으로 나열하기로 생각했다. 그래서 views.py에 있는

blog_home 부분을 아래처럼 수정해 주었다.

 

query.order_by를 이용하여 Post 모델에 있는 데이터들을 날짜에 맞춰 내림차순으로 정렬해 주었다.

다음으로는 index.html을 아래처럼 수정해 준다.

 

게시물 리스트는 post_list.html에 있는 코드를 참고해 왔다. 우선 jinja2 템플릿을 이용하여 for문으로 blog_home에서 정렬한 리스트들을 가져온다. 그다음 5개까지 표현하기로 했으니 loop.index <= 5를 if문으로 조건을 걸어서 5개까지만 나열하도록 했다. 그다음 기존에 있는 게시물 리스트는 카테고리 표시가 없었기 때문에 카테고리도 추가로 표시해 주었다.

 

  

그래서 실행을 해 보면 카테고리와 상관 없이 가장 최근에 만든 게시물 순으로 5개가 나열된 결과가 나온다!

관리자 권한이 있는 유저가 로그인한 경우, 관리자 페이지로 이동하는 네비게이션 바 구현하기

우선 관리자 페이지는 주소가 http://127.0.0.1:5000/admin/ 로 고정적으로 설정이 되어 있다. 그래서 templates에 admin.html을 하나 생성하여 아래 코드를 추가해준다.

 

 

만들어준 이유는 네비게이션바를 클릭 했을 시 라우팅을 통해서 템플릿으로 넘어간 후 링크로 이동하기 위해서 이다.

그래서 views.py에 라우터를 하나 만들어 준다.

 

이제 네비게이션 바를 수정할 차례이다. 아래처럼 base.html에서 네비게이션 바 부분을 수정해 준다.

 

 

로그인한 상태에서 유저가 관리자 권한이 있으면 관리자 페이지로 넘어가는 네비게이션 바가 뜨도록 수정한 것이다.

이제 실행을 해 보면 아래와 같은 결과가 나오게 된다.

 

 

관리자 권한이 있는 admin 계정으로 로그인하면 네비게이션 바에 ADMIN이 뜨는 것을 볼 수 있다. ADMIN을 클릭해 보면

 

 

성공적으로 관리자 페이지에 들어가는 것을 볼 수 있다. 와!