[TIL] 플라스크 #2

Hyeonu_J·2022년 1월 31일
0
post-custom-banner

공부할 책 : Do it! 점프 투 플라스크

플라스크

[TIL] 플라스크 #1 과 이어진다!

답변 등록 기능 만들기

  1. 답변 등록 버튼 만들기
    pybo/templates/question/question_detail.html
<h1>{{question.subject}}</h1>

<div>
    {{question.content}}
</div>

<form action="{{url_for('answer.create',question_id=question.id)}}" method="post">
    <textarea name="content" id="content" rows="15"></textarea>
    <input type="submit" value="답변등록"/>
</form>

질문 상세 조회 페이지를 요청하면 answer.create에 해당하는 URL을 찾을 수 없다는 오류가 나타난다.

  1. 답변 블루프린트 만들기
    답변 모델 answer를 관리하는 블루프린트를 views디렉터리에 answer_views.py 파일을 만들고 다음처럼 코드를 작성한다.
    pybo/views/answer_views.py
from datetime import datetime

from flask import Blueprint, url_for, request
from werkzeug.utils import redirect

from pybo import db

from pybo.models import Question, Answer

# answer_views.py 파일이 answer 라는 이름의 블루프린트 파일임을 나타냄
bp = Blueprint('answer', '__name__', url_prefix='/answer')

# methods 속성은 답변 저장 템플릿에 있는 form 엘리먼트의 method값과 일치해야 함
@bp.route('/create/<int:question_id>', methods=('POST',))
def create(question_id):
    question = Question.query.get_or_404(question_id)
    content = request.form['content'] # name 속성이 'content'인 값
    answer = Answer(content=content, create_date=datetime.now())
    question.answer_set.append(answer) # 질문에 달린 답변들
    db.session.commit()
    return redirect(url_for('question.detail', question_id=question_id))

답변 저장 템플릿의 form 엘리먼트는 POST 방식이었으므로 @bp.route의 methods 속성에도 같은 값을 지정한다.
form 엘리먼트를 통해 전달된 데이터들은 create 함수에서 request 객체로 얻을 수 있다. request.form['content'] 코드는 POST 폼 방식으로 전송된 데이터 항목 중 name 속성이 'content' 인 값을 의미한다.
답변 생성 후 화면을 이동하도록 redirect 함수를 사용했다.
request 객체는 플라스크에서 생성 과정 없이 사용할 수 있는 기본 객체이다. 플라스크는 브라우저의 요청부터 응답까지 처리 구간에서 request 객체를 생성하여 사용할 수 있게 해준다. 이 객체를 이용해 브라우저에서 요청한 정보를 확인할 수 있다.

  1. 답변 블루프린트 적용하기
    생성한 블루프린트 객체 answer_views.bppybo/__init__.py 파일에 등록한다.
    pybo/__init__.py
(...생략...)
    #블루프린트
    from .views import main_views,question_views,answer_views
    app.register_blueprint(main_views.bp)
    app.register_blueprint(question_views.bp)
    app.register_blueprint(answer_views.bp)
    
    return app


4. 질문 상세 페이지에 답변 표시하기
질문 상세 조회 템플릿 파일에 다음 코드를 추가한다.
pybo/templates/question/question_detail.html

<h1>{{question.subject}}</h1>

<div>
    {{question.content}}
</div>

# length는 템플릿 필터인데, | 문자 뒤에 추가해서 사용한다.
<h5>{{question.answer_set|length}}개의 답변이 있습니다.</h5>
<div>
    <ul>
        {% for answer in question.answer_set %}
        <li>{{answer.content}}</li>
        {% endfor %}
    </ul>
</div>


(...생략...)


이제 답변을 저장하고 볼 수 있다.

화면 예쁘게 꾸미기

CSS를 파이보에 적용하려면 CSS파일이 pybo/static 디렉터리에 있어야 한다. 이때 CSS 파일은 플라스크에서 정적파일로 분류된다. 정적 파일은 주로 이미지나 .js, .css 같은 파일을 의미한다.

  1. static 디렉터리 만들고 스타일시트 작성하기
    pybo 디렉터리 안에 static 디렉터리를 생성하자. static 디렉터리를 만들었으면 그곳에 style.css 파일을 만들고 다음과 같은 코드를 작성하자.
    pybo/static/style.css
textarea{
    width:100%;
}

input[type=submit]{
    margin-top:10px;
}
  1. 질문 상세 페이지에 스타일시트 적용하기
    question_detail.html 파일을 열고 맨 위에 코드를 추가한다.
<link rel="stylesheet" href="{{url_for('static',filename='style.css')}}">
<h1>{{question.subject}}</h1>
(... 생략 ...)


질문 상세 조회 화면이 변경된 걸 확인할 수 있다.

부트스트랩으로 더 쉽게 화면 꾸미기

부트스트랩은 개발자 혼자서도 화면을 괜찮은 수준으로 만들 수 있게 해주는 도구다. 부트스트랩은 트위터를 개발하면서 만들어졌고 아직도 발전하고 관리되고 있는 오픈소스 프로젝트이다.

파이보에 부트스트랩 적용하기

  1. 부트스트랩 설치
    getbootstrap.com 에 접속한 다음 부트스트랩 설치 파일을 내려받자. 압축을 해제하면 많은 파일이 보이는데, 이 중에서 bootstrap.min.css 파일만 복사해서 pybo/static 디렉터리에 저장한다.

  2. 부트스트랩 적용하기
    질문 목록 조회 템플릿에 부트스트랩을 적용해 보자. bootstrap.min.css 파일을 연결하는 것을 시작으로 전체를 수정해야 한다.
    pybo/templates/question/question_list.html

<link rel="stylesheet" href="{{url_for('static',filename='bootstrap.min.css')}}">
<div class="container my-3">
    <table class="table">
        <thead>
            <tr class="thead-dark">
                <th>번호</th>
                <th>제목</th>
                <th>작성일시</th>
            </tr>
        </thead>
        <tbody>
            {% if question_list %}
            {% for question in question_list %}
            <tr>
                <td>{{loop.index}}</td>
                <td>
                    <a href="{{url_for('question.detail',question_id=question.id)}}">
                        {{question.subject}}
                    </a>
                </td>
            </tr>
            {% endfor %}
            {% else %}
            <tr>
                <td colspan="3">질문이 없습니다.</td>
            </tr>
            {% endif %}
        </tbody>
    </table>
</div>

  1. 질문 상세 조회 템플릿에 부트스트랩 적용하기
    pybo/templates/question/question_detail.html
<link rel="stylesheet" href="{{url_for('static', filename='bootstrap.min.css')}}">
<div class="container my-3">
    <h2 class="border-bottom py-2">
        {{question.subject}}
    </h2>
    <div class="card my-3">
        <div class="card-body">
            <div class="card-text" style="white-space:pre-line;">{{question.content}}</div>
            <div class="d-flex justify-content-end">
                <div class="badge-light p-2">
                    {{question.create_date}}
                </div>
            </div>
        </div>
    </div>
    <h5 class="border-bottom my-3 py-2">{{question.answer_set|length}}개의 답변이 있습니다.</h5>
    {% for answer in question.answer_set %}
    <div class="card my-3">
        <div class="card-body">
            <div class="card-text" style="white-space: pre-line;">{{answer.content}}</div>
            <div class="d-flex justify-content-end">
                <div class="p-2">
                    {{answer.create_date}}
                </div>
            </div>
        </div>
    </div>
    {% endfor %}
    <form action="{{url_for('answer.create', question_id=question.id)}}" method="post" class="my-3">
        <div class="form-group">
            <textarea name="content" id="content" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="답변등록" class="btn btn-primary">
    </form>
</div>

표준 HTML과 템플릿 상속 사용하기

위의 템플릿 파일들은 표준 HTML 구조가 아니다. 어떤 운영체제나 브라우저를 사용하더라도 웹 페이지가 동일하게 보이고 정상적으로 동작하게하려면 반드시 웹 표준을 지키는 HTML 문서를 작성해야 한다.
앞에서 작성한 템플릿 파일을 표준 HTML 구조로 수정해 보자. 그런데 모든 템플릿 파일을 열어서 표준 HTML 구조로 변경하면 body 앨리먼트 바깥 부분은 모두 같은 내용이 중복될 것이다. 그리고 CSS파일 이름이 변경되거나 새로운 CSS 파일이 추가되면 head 앨리먼트의 내용을 수정하려고 템플릿 파일을 일일히 찾아다녀야 하는 불편함도 발생한다. 플라스크는 이런 불편함을 해소하기 위한 템플릿 상속 기능을 제공한다.

  1. 템플릿 파일의 기본 틀 작성하기
    템플릿 기본 틀인 base.html 템플릿을 작성한다.

pybo/templates/base.html

<!DOCTYPE html>
<html lang="ko">
<head>
    <!--Required meta tags-->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <!--Bootstrap CSS-->
    <link rel="stylesheet" href="{{url_for('static', filename='bootstrap.min.css')}}">
    <!--pybo CSS-->
    <link rel="stylesheet" href="{{url_for('static',filename='style.css')}}">
    <title>Hello, pybo!</title>
</head>
<body>
    <!--기본 템플릿에 삽입할 내용 Start-->
    {% block content %}
    {% endblock %}
    <!--기본 템플릿에 삽입할 내용 End-->
</body>
</html>

body 앨리먼트에 {% block content %} 와 {% endblock %} 템플릿 태그가 base.html 템플릿 파일을 상속한 파일에서 구현할 영역이다.

  1. 질문 목록 조회 템플릿 파일 수정하기

pybo/templates/question/question_list.html

{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
    <table class="table">
	(...생략...)
    </table>
</div>
{% endblock %}

base.html 템플릿 파일을 상속받고자 {% extends 'base.html' %} 템플릿 태그를 사용했다. 그리고 {% blcok content %} 와 {% endblock %} 사이에 question_list.html 에서만 사용할 내용을 작성했다. 이제 question_list.html은 base.html을 상속받았으므로 표준 HTML 구조를 갖추게 된다.

  1. 질문 상세 조회 템플릿 수정하기

pybo/templates/question/question_detail.html

{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
    <h2 class="border-bottom py-2">
	(...생략...)
    </form>
</div>
{% endblock %}

폼 모듈로 데이터 검증 더 쉽게 하기

웹 프로그램에서 폼은 사용자에게 입력 양식을 편리하게 제공하기 위해 사용한다. 여기서는 폼 모듈을 어떻게 사용하는지 알아보자. 폼 모듈을 사용하면 폼으로 전송되는 데이터의 필수 여부, 길이, 형식 등을 더 쉽게 검증할 수 있다.

  1. 플라스크 폼 모듈 설치하기
    플라스크에서 폼을 사용하려면 Flask-WTF 라는 라이브러리를 설치한다. 가상환경에서 pip install Flask-WTF 명령으로 설치해주자.
    Flask-WTF를 사용하려면 플라스크 환경 변수 SECRET_KEY 가 필요하다. SECRET_KEY는 CSRF라는 웹 사이트 공격 기법인데 SECRET_KEY를 기반으로 해서 생성되는 CSRF 토큰은 폼으로 전송된 데이터가 실제 웹 페이지에서 작성된 데이터인지를 판단해 주는 가늠자 역할을 한다.
    먼저 프로젝트 루트에 있는 config.py 설정 파일을 열고 마지막 줄에 SECRET_KEY 환경 변수를 추가한다.

config.py

(...생략...)
SECRET_KEY = "dev"

(실제 서비스를 운영할 때 "dev"처럼 유추하기 쉬운 문자열을 입력하면 안 된다! 현재는 개발 환경이므로 괜찮다.)

질문 등록 기능 만들기

파이보에 질문을 등록하는 기능을 만들어 보자.

  1. 질문 등록 버튼 만들기

pybo/templates/question/question_list.html

(...생략...)
	</tbody>
    </table>
    <a href="{{ url_for('question.create') }}" class="btn btn-primary">질문 등록하기</a>
</div>
{% endblock %}
  1. 질문 등록 라우트 함수 추가하기
    URL을 추가했으므로 question_views.py 파일에 라우트 함수 create를 추가하자.

pybo/views/question_views.py

from ..forms import QuestionForm
(...생략...)
@bp.route('/create/')
def create():
    form = QuestionForm()
    return render.template('question/question_form.html', form=form)

create 함수에서 QuestionForm 클래스의 객체 form을 생성하고 return 문에서 render_template 함수가 템플릿을 렌더링할 때 form 객체를 전달한다. QuestionForm 클래스는 질문 등록을 할 때 사용할 플라스크의 폼으로 바로 다음 단계에서 작성할 것이다. 이후 form 객체는 템플릿에서 라벨이나 입력 폼 등을 만들 때 사용한다.

  1. 질문 등록 폼 클래스 작성하기
    pybo 디렉터리에 forms.py 파일을 새로 만든 다음 질문 등록을 할 때 사용할 QuestionForm 클래스를 작성하자.

pybo/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField
from wtforms.validators import DataRequired

class QuestionForm(FlaskForm):
    subject = StringField('제목', validators=[DataRequired()])
    content = TextAreaField('내용', validators=[DataRequired()])

QuestionForm 클래스는 Flask-WTF 모듈의 FlaskForm 클래스를 상속받으며 subject, content 속성을 포함한다.
StringField, TextAreaField 는 플라스크 폼의 속성 또는 필드라고 한다. 첫 번째 인자는 폼 라벨로, 두 번째 인자 validators는 필드값을 검증할 때 사용한다.
자세한 내용은 공식문서를 참조하자.
https://wtforms.readthedocs.io/en/3.0.x/

  1. 질문 등록 템플릿 작성하기

/pybo/templates/question/question_form.html

{% extends 'base.html' %}
{% block content %}
<div class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form method="post" class="post-form my-3">
        {{form.subject.label}}
        {{form.subject()}}

        {{form.content.label}}
        {{form.content()}}

        <button type="submit" class="btn btn-primary">저장하기</button>
    </form>
</div>
{% endblock %}
  1. 질문 등록 기능 사용해 보기

이와 같은 오류 화면이 나타난 이유는 현재 폼이 POST 방식으로 데이터를 전송하기 때문이다. <form method="post" class="post-form my-3"> 에서 폼에 입력한 데이터는 모두 POST 방식으로 전송된다. 그런데 현재 create 함수에는 별도의 method 속성을 지정하지 않았으므로 기본 처리 방식인 GET 방식만 처리할 수 있다. 즉 POST 방식으로 데이터를 처리하게 하려면 질문 등록 라우트를 변경해야 한다.

  1. 질문 전송 방식 수정
    질문 등록 라우트에 GET과 POST 방식을 포함하는 method 속성을 추가하자.

pybo/views/question_views.py

(... 생략 ...)
@bp.route('/create/', methods=('GET','POST'))
def create():
    form = QuestionForm()
    return render_template('question/question_form.html', form=form)

다시 저장하기 버튼을 눌러보면 오류 화면은 보이지 않고 질문 등록 화면만 보인다. create 함수에 데이터를 저장하는 코드를 작성하지 않았기 때문이다.

  1. 폼 데이터를 저장하는 코드 작성하기
    create 함수에 POST 방식으로 요청된 폼 데이터를 데이터베이스에 저장하는 코드를 추가하자.

pybo/views/question_views.py

from datetime import datetime
from flask import Blueprint, render_template, request, url_for
from werkzeug.utils import redirect
from .. import db
from ..models import Question
from ..forms import QuestionForm

(... 생략 ...)

@bp.route('/create/', methods=('GET','POST'))
def create():
    form = QuestionForm()
    if request.method == 'POST' and form.validate_on_submit():
        question = Question(subject=form.subject.data, content=form.content.data,create_date=datetime.now())
        db.session.add(question)
        db.session.commit()
        return redirect(url_for('main.index'))
    return render_template('question/question_form.html', form=form)

if 문의 request.method는 create 함수로 요청된 전송 방식을 의미한다. 여기서 POST 방식 요청을 걸러낸다. form.validate_on_submit 함수는 POST 방식으로 전송된 폼 데이터의 정합성을 점검한다. 즉, 폼을 생성할 때 각 필드에 지정한 DataRequired() 같은 점검 항목에 이상이 없는지 확인한다. 마지막으로 데이터 저장이 완료되면 main.index 페이지로 이동하도록 했다.
방금 추가한 코드의 핵심은 데이터 전송 방식이 POST인지 GET인지에 따라서 달리 처리하는 부분이다. 질문 목록에서 <질문 등록하기> 버튼을 누르거나 질문 등록 화면에서 <저장하기> 버튼을 누르면 똑같이 localhost:5000/question/create/ 페이지를 요청하므로 create 라우트가 이 요청을 받는다. 다만 create 라우트가 if문에서 request.method == 'POST' 코드로 요청 방식을 구분해서 렌더링한다. 즉 <질문 등록하기>는 GET방식 요청이므로 그대로 질문 등록 화면을 보여주고, <저장하기> 는 POST 방식이므로 데이터베이스에 폼 데이터를 저장한 다음 질문 목록으로 이동한다.

  1. 폼에 부트스트랩 적용하기

pybo/templates/question/question_form.html

(...생략...)
        {{form.subject.label}}
        {{form.subject(class="form-control")}}

        {{form.content.label}}
        {{form.content(class="form-control")}}
(...생략...)
  1. 수작업으로 폼 작성하기
    앞에서 {{form.subject()}} 처럼 HTML 코드를 자동으로 생성하는건 폼을 빠르게 만드는 데에 도움이 되지만 내가 원하는 디자인을 적용하기도 엘리먼트나 속성을 추가하기도 어렵다. 다음처럼 수정해보자.

pybo/templates/question/question_form.html

{% extends 'base.html' %}
{% block content %}
<div class="container">
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form method="post" class="post-form my-3">
        <div class="form-group">
            <label for="subject">제목</label>
            <input type="text" class="form-control" name="subject" id="subject">
        </div>
        <div class="form-group">
            <label for="content">내용</label>
            <textarea class="form-control" name="content" id="content" rows="5"></textarea>
        </div>
        <button type="submit" class="btn btn-primary">저장하기</button>
    </form>
</div>
{% endblock %}


질문 등록 기능 완성하기

<저장하기> 버튼을 눌러도 화면에 아무런 변화가 없다. 그 이유를 알아보고 보완해서 질문 등록 기능을 완성해 보자.

pybo/templates/question/question_form.html

(... 생략 ...)
    <form method="post" class="post-form my-3">
        <!-- 오류표시 Start -->
        {% for field, errors in form.errors.items() %}
        <div class="alert alert-danger" role="alert">
            <strong>{{form[field].label}}</strong>: {{', '.join(errors)}}
        </div>
        {% endfor %}
        <!-- 오류표시 End -->
        <div class="form-group">
(... 생략 ...)

아무 값도 입력하지 않고 <저장하기> 버튼을 누르면 아래와 같은 화면이 나타난다.

제목과 내용 필드에 발생한 오류 메시지 외에 CSRF Token 오류가 발생한다. CSRF는 보안 관련 항목으로, form 엘리먼트를 통해 전송된 데이터가 실제 웹 사이트에서 만들어진 데이터인지 검증하는 CSRF 토큰이 빠졌다는 의미다.

  1. CSRF 토큰 오류 처리하기
    form 엘리먼트 바로 밑에 {{ form.csrf_token }} 코드를 삽입함으로써 CSRF 토큰 오류에서 탈출해 보자.
(...생략...)
    <h5 class="my-3 border-bottom pb-2">질문등록</h5>
    <form method="post" class="post-form my-3">
        {{ form.csrf_token }}
(...생략...)


입력한 값 유지하고 오류 메시지 한글로 바꾸기

  1. 입력한 값 유지하기
    폼을 전송했을 때 오류가 있더라도 이미 입력한 값을 유지하도록 질문 폼 템플릿을 다음처럼 수정한다.

pybo/templates/question/question_form.html

        <div class="form-group">
            <label for="subject">제목</label>
            <input type="text" class="form-control" name="subject" id="subject" value="{{form.subject.data or ''}}">
        </div>
        <div class="form-group">
            <label for="content">내용</label>
            <textarea class="form-control" name="content" id="content" rows="10">{{form.content.data or ''}}</textarea>
        </div>
        <button type="submit" class="btn btn-primary">저장하기</button>
    </form>

value 값으로 {{form.subject.data or ''}} 를 입력하면 이미 전송한 데이터가 다시 설정된다. 여기서 or '' 은 현재 템플릿이 GET 방식으로 요청되는 경우 기존 입력값이 없으므로(None으로 출력) 이를 방지하기 위해서 사용했다. 즉 이렇게 하면 form.subject.data에 값이 없을 때 None이 아니라 ''이 출력된다.

  1. 오류 메시지 한글로 바꾸기
    forms.py 파일을 열어 DataRequired에 한글 메시지를 설정하자.

pybo/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField
from wtforms.validators import DataRequired

class QuestionForm(FlaskForm):
    subject = StringField('제목', validators=[DataRequired('제목은 필수 입력 항목입니다.')])
    content = TextAreaField('내용', validators=[DataRequired('내용은 필수 입력 항목입니다.')])


답변 등록 기능 수정하기

  1. 답변 등록 폼 추가하기

pybo/forms.py

(... 생략 ...)
class AnswerForm(FlaskForm):
    content = TextAreaField('내용', validators=[DataRequired('내용은 필수 입력 항목입니다.')])
  1. 답변 등록 라우트 함수 수정하기

pybo/templates/question/question_detail.html

from datetime import datetime
from flask import Blueprint, url_for, request, render_template
from werkzeug.utils import redirect
from pybo import db
from pybo.forms import AnswerForm
from pybo.models import Question, Answer

# answer_views.py 파일이 answer 라는 이름의 블루프린트 파일임을 나타냄
bp = Blueprint('answer', '__name__', url_prefix='/answer')

# methods 속성은 답변 저장 템플릿에 있는 form 엘리먼트의 method값과 일치해야 함
@bp.route('/create/<int:question_id>', methods=('POST',))
def create(question_id):
    form = AnswerForm()
    question = Question.query.get_or_404(question_id)
    if form.validate_on_submit():
        content = request.form['content']
        answer = Answer(content=content, create_date=datetime.now())
        question.answer_set.append(answer)
        db.session.commit()
        return redirect(url_for('question.detail',question_id=question_id))
    return render_template('question/question_detail.html',question=question, form=form)
  1. CSRF 코드와 오류 표시 기능 추가하기

pybo/templates/question/question_detail.html

(...생략...)
    <form action="{{url_for('answer.create', question_id=question.id)}}" method="post" class="my-3">
        {{form.csrf_token}}
        <!-- 오류표시 Start -->
        {% for field, errors in form.errors.items() %}
        <div class="alert alert-danger" role="alert">
            <strong>{{form[field].label}}</strong>: {{', '.join(errors)}}
        </div>
        {% endfor %}
        <!-- 오류표시 end -->
        <div class="form-group">
            <textarea name="content" id="content" class="form-control" rows="10"></textarea>
        </div>
        <input type="submit" value="답변등록" class="btn btn-primary">
    </form>
(...생략...)
  1. 상세 보기 라우트에도 폼 적용하기
    question_view.py 파일의 detail 함수도 폼을 사용해야 한다. 다음을 참고해 코드를 수정하자.

pybo/views/question_views.py

(...생략...)
from ..forms import Question, AnswerForm

(...생략...)
@bp.route('/detail/<int:question_id>/')
def detail(question_id):
    form = AnswerForm()
    question = Question.query.get_or_404(question_id)
    return render_template('question/question_detail.html', question=question, form=form)
(...생략...)


후기 :

기록하면서 공부를 하니까 더 잘 되는 것 같다.
실행도 시켜보고, 코드를 보면서 이해하자!

https://github.com/hyeonuJ/myproject

profile
흔한 컴공러 / 3학년
post-custom-banner

0개의 댓글