공부할 책 : Do it! 점프 투 플라스크
[TIL] 플라스크 #2 과 이어진다!
지금까지 만든 파이보의 기능(질문 등록 및 조회, 답변 등록 및 조회)을 사용해 봤다면 편의 기능이 없어서 이런저런 불편함을 느꼈을 것이다. 불편함을 해소할 수 있는 기능을 추가하기 위해 내비게이션 바를 만들어 보자.
<body>
태그 바로 아래에 추가하자. 내비게이션 바에는 메인 페이지로 이동해 주는 'Pybo' 로고를 가장 왼쪽에 배치하고, 오른쪽에는 '계정생성'과 '로그인' 링크를 추가하자.pybo/templates/base.html
(...생략...)
<body>
<!--내비게이션 바-->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<a class="navbar-brand" href="{{url_for('main.index')}}">Pybo</a>
<button
class="navbar-toggler ml-auto"
type="button"
data-toggle="collapse"
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse flex-grow-0" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="#">계정생성</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">로그인</a>
</li>
</ul>
</div>
</nav>
<!--기본 템플릿에 삽입할 내용 Start-->
{% block content %}
{% endblock %}
<!--기본 템플릿에 삽입할 내용 End-->
</body>
</html>
질문 목록 조회 화면에서 상단 내비게이션 바 확인하기
부트스트랩이 제공하는 햄버거 메뉴 버튼 확인하기
웹 브라우저의 너비를 줄여보자. 그러면 어느순간 햄버거 메뉴 버튼이 생긴다.
그런데 버튼을 클릭해도 아무 변화가 없다. 그 이유는 부트스트랩 자바스크립트 파일이 base.html에 포함되지 않았기 때문이다. 또한 부트스트랩 자바스크립트 파일은 제이쿼리를 기반으로 해서 만들어졌다. 버튼을 제대로 사용하려면 부트스트랩 자바스크립트 파일과 제이쿼리 파일이 필요하다.
부트스트랩 자바스크립트 파일
bootstrap-5.1.3-dist\bootstrap-5.1.3-dist\js\bootstrap.min.js
파일을 복사해서 C:\projects\myproject\pybo\static
에 붙여넣는다.
제이쿼리
jquery.com/download 에 접속해 'Download the compressed, production jQuery 3.4.1' 링크를 마우스 오른쪽 버튼으로 눌러 '다른 이름으로 링크 저장' 후 C:\projects\myproject\pybo\static
에 붙여 넣는다.
templates/base.html 에 파일 추가하기
pybo/templates/base.html
<!--기본 템플릿에 삽입할 내용 Start-->
{% block content %}
{% endblock %}
<!--기본 템플릿에 삽입할 내용 End-->
<!-- jQuery JS -->
<script src="{{url_for('static',filename='jquery-3.6.0.min.js')}}"></script>
<!-- Bootstarp JS -->
<script src="{{url_for('static',filename='bootstrap.min.js')}}"></script>
</body>
</html>
pybo/templates/navbar.html
<!--내비게이션 바-->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<a class="navbar-brand" href="{{url_for('main.index')}}">Pybo</a>
<button
class="navbar-toggler ml-auto"
type="button"
data-toggle="collapse"
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse flex-grow-0" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="#">계정생성</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">로그인</a>
</li>
</ul>
</div>
</nav>
/pybo/templates/base.html
(...생략...)
</head>
<body>
{% include "navbar.html" %}
<!--기본 템플릿에 삽입할 내용 Start-->
{% block content %}
{% endblock %}
<!--기본 템플릿에 삽입할 내용 End-->
<!-- jQuery JS -->
<script src="{{url_for('static',filename='jquery-3.6.0.min.js')}}"></script>
<!-- Bootstarp JS -->
<script src="{{url_for('static',filename='bootstrap.min.js')}}"></script>
</body>
</html>
이렇게 include 기능은 템플릿의 특정 영역을 중복, 반복해서 사용할 경우 유용하다. 즉 중복, 반복하는 템플릿의 특정 영역을 따로 템플릿 파일로 만들고, include 기능으로 그 템플릿을 포함한다. navbar.html 파일은 base.html 파일에서 1번만 사용되지만 따로 파일로 관리해야 이후 유지/보수 하는 데 유리하므로 분리했다.
지금까지 만든 파이보의 질문 목록 조회는 페이징 기능이 없었다. 페이징 기능이 없으면 어떻게 될까? 만약 게시물이 300개 작성되면 질문 목록 조회 화면에 게시물이 300개 그대로 표시될 것이다. 이런 경우 한 화면에 표시할 게시물이 많아져서 스크롤 바를 내려야 하는 등의 불편함이 생기므로 페이징 기능은 필수다. 페이징 기능을 추가하는 방법을 알아보자.
플라스크 셸을 이용해 테스트 데이터를 300개 생성한다.
from pybo import db
from pybo.models import Question
from datetime import datetime
>>> for i in range(300):
... q = Question(subject='테스트 데이터입니다:[%03d]' % i, content='내용무', create_date=datetime.now())
... db.session.add(q)
>>> db.session.commit()
게시물이 한없이 이어지는 문제가 있다. 페이징이 필요한 이유이다.
pybo/views/quesiton_views.py
@bp.route('/list/')
def _list():
page = request.args.get('page', type=int, defalut=1) # 페이지
question_list = Question.query.order_by(Question.create_date.desc())
question_list = question_list.paginate(page,per_page=10)
return render_template('question/question_list.html', question_list=question_list)
여기서 page=request.args.get('page', type=int, default=1)를 살펴보자.
type=int 는 page 매개변수의 자료형이 정수임을 의미한다.
다음과 같은 GET방식으로 요청한 URL 에서 page값 5를 가져올 때 사용한다. localhost:5000/question/list/?page=5
만약 다음과 같이 URL에 page값이 없으면 default=1 을 자동으로 적용해 기본값 1이 설정된다.
localhost:5000/question/list
이어서 question_list = question_list.paginate(page, per_page=10)는 조회한 데이터 question_list에 paginate 함수로 페이징을 적용해 준다. 이 함수의 1번째 인자로 전달된 page는 현재 조회할 페이지의 번호를 의미하고, 2번째 인자 per_page로 전달된 10은 페이지마다 보여 줄 게시물이 10건임을 의미한다. 만약 URL이 ?page=6 으로 끝나면 질문 목록 6번째 페이지부터 한 페이지에 10건씩 게시물을 보여 줄 것이다.
paginate 함수는 조회한 데이터를 감싸 Pagination 객체로 반환한다. 위 코드에서 question_list는 paginate 함수를 사용해 Pagination 객체가 되었으므로 다음과 같은 속성을 사용할 수 있다. 즉, paginate 함수로 만든 Pagination 객체는 페이징 처리를 아주 쉽게 만들어 준다.
Pagination 객체의 속성을 이용해 템플릿에서 페이징을 적용해 보자.
객체에 적용할 수 있는 속성은 다음과 같다.
items
: 현재 페이지에 해당하는 게시물 리스트
total
: 게시물 전체 개수
per_page
: 페이지당 보여 줄 게시물 개수
page
: 현재 페이지 번호
iter_pages
페이지 범위 ([1,2,3,4,5,None,30,31)]
prev_num / next_num
: 이전 페이지 번호 / 다음 페이지 번호
has_prev / has_next
: 이전 페이지 존재여부 / 다음 페이지 존재여부
{% for question in question_list %}
를 {% for question in question_list.items %}
와 같이 .items를 추가하는 방식으로 수정하자. '현재 조회된 질문 목록 데이터'를 가져오려면 items 함수를 호출해야 한다.pybo/templates/question/question_list.html
(...생략...)
<tbody>
{% if question_list %}
{% for question in question_list.items %} <--! ← 수정된 부분 -->
<tr>
<td>{{loop.index}}</td>
<td>
<a href="{{url_for('question.detail',question_id=question.id)}}">
{{question.subject}}
</a>
(...생략...)
</table>
바로 아래에 다음과 같이 코드를 작성한다./pybo/templates/question/question_list.html
(...생략...)
</table>
<!-- 페이징 처리 시작 -->
<ul class="pagination justify-content-center">
<!--이전 페이지-->
{% if question_list.has_prev %}
<li class="page-item">
<a class="page-link" href="?page={{question_list.prev_num}}">이전</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" tabindex="-1" aria-disabled="true" href="#">이전</a>
</li>
{% endif %}
{% for page_num in question_list.iter_pages() %}
{% if page_num %}
{% if page_num != question_list.page %}
<li class="page-item">
<a class="page-link" href="?page={{page_num}}">{{page_num}}</a>
</li>
{% else %}
<li class="page-item active" aria-current="page">
<a class="page-link" href="#">{{page_num}}</a>
</li>
{% endif %}
{% else %}
<li class="disabled">
<a class="page-link" href="#">...</a>
</li>
{% endif %}
{% endfor %}
<!--다음 페이지-->
{% if question_list.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{question_list.next_num}}">다음</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" tabindex="-1" aria-disabled="true" href="#">다음</a>
</li>
{% endif %}
</ul>
<!-- 페이징 처리 끝 -->
<a href="{{ url_for('question.create') }}" class="btn btn-primary">질문 등록하기</a>
</div>
{% endblock %}
페이징 기능이 아래에 완성된 걸 확인할 수 있다.
페이징은 사실 구현하기 무척어려운 기술이다. 플라스크의 paginate 함수가 없었다면 쉽게 해내기는 힘들었을 것이다.
참고로 질문 제목의 숫자가 뒤섞여 있는 경우는 데이터가 아주 빠른 속도로 저장되어 같은 시간이 입력되어 뒤죽박죽으로 섞여 보이는 것이다.
질문 목록 페이지에서 작성일시 확인하기
datetime 객체를 보기 편한 문자열로 만드는 템플릿 필터 만들기
작성일시를 보기 편한 문자열로 만들 수 있는 템플릿 필터를 만들어 보자. pybo/filter.py 파일을 만들고 format_datetime 함수를 추가한다.
pybo/filter.py
def format_datetime(value, fmt='%Y년 %m월 %d일 %H:%M'):
return value.strftime(fmt)
pybo/__init__.py
수정하여 필터 적용하기(...생략...)
def create_app():
(...생략...)
#블루프린트
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)
#필터
from .filter import format_datetime
app.jinja_env.filters['datetime'] = format_datetime
return app
format.datetime 함수를 임포트한 다음 app.jinja_env.filters['datetime']과 같이 datetime이라는 이름으로 필터를 등록해 두었다.
(...생략...)
<tbody>
{% if question_list %}
{% for question in question_list.items %}
<tr>
<td>{{loop.index}}</td>
<td>
<a href="{{url_for('question.detail',question_id=question.id)}}">
{{question.subject}}
</a>
</td>
<td>{{question.create_date | datetime}}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="3">질문이 없습니다.</td>
</tr>
{% endif %}
</tbody>
(...생략...)
{{question.create_date | datetime}}
와 같이 datetime 필터를 적용했다.
pybo/templates/question/question_detail.html
(...생략...)
<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 | datetime}}
</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 | datetime}}
</div>
</div>
</div>
</div>
{% endfor %}
(...생략...)
필터가 적용된 걸 볼 수 있다.
게시물 번호 문제 살펴보기
페이지마다 게시물 번호가 항상 1부터 시작된다.
게시물 번호 공식 만들기
질문 게시물의 번호르 역순으로 정렬하려면 이 공식을 적용하자.
번호 = 전체 게시물 개수 - (현재 페이지-1)*페이지당 게시물 개수 - 나열 인덱스
게시물 번호 공식을 질문 목록 조회 템플릿에 적용하기
<td>
엘리먼트에 있던 {{loop.index}}
를 아래처럼 바꿔준다.
pybo/templates/question/question_list.html
{% extends 'base.html' %}
{% block content %}
<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.items %}
<tr>
<td>{{question_list.total - ((question_list.page-1)*question_list.per_page)-loop.index0}}</td>
<td>
<a href="{{url_for('question.detail',question_id=question.id)}}">
{{question.subject}}
</a>
</td>
<td>{{question.create_date | datetime}}</td>
</tr>
(...생략...)
pybo/templates/question/question_list.html
(...생략...)
<td>
<a href="{{url_for('question.detail',question_id=question.id)}}">
{{question.subject}}
</a>
{% if question.answer_set | length > 0 %}
<span class="text-danger small ml-2">
{{question.answer_set | length}}
</span>
{% endif %}
</td>
(...생략...)
지금까지 질문, 답변 관련 모델만 사용했다면 이제 회원정보 모델이 필요하다.
pybo/models.py
파일에 Uswer 모델 작성하기pybo/models.py
(...생략...)
class User(db.Model):
id=db.Column(db.Integer,primary_key=True)
username=db.Column(db.String(150), unique=True, nullable=False)
password=db.Column(db.String(200), nullable=False)
email=db.Column(db.String(120), unique=True, nullable=False)
unique=True 옵션은 '같은 값을 지정할 수 없다' 를 뜻한다. 이렇게 하면 username과 email이 중복되어 저장되지 않는다.
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, PasswordField, EmailField
from wtforms.validators import DataRequired, Length, EqualTo, Email
(...생략...)
class UserCreateForm(FlaskForm):
username = StringField('사용자이름', validators=[DataRequired(), Length(min=3, max=25)])
password1 = PasswordField('비밀번호', validators=[DataRequired(), EqualTo('password2', '비밀번호가 일치하지 않습니다.')])
password2 = PasswordField('비밀번호확인', validators=[DataRequired()])
email = EmailField('이메일', [DataRequired(), Email()])
PasswordField 로 만든 필드는 이후 폼을 이용해 템플릿 코드를 자동으로 생성할 때 <input type="password">
가 된다.
같은 맥락에서 EmailFiled로 만든 email 필드는 <input type="email">
이 된다.
pybo/views/auth_views.py
from flask import Blueprint, url_for, render_template, flash, request
from werkzeug.security import generate_password_hash
from werkzeug.utils import redirect
from pybo import db
from pybo.forms import UserCreateForm
from pybo.models import User
bp = Blueprint('auth', __name__, url_prefix='/auth')
@bp.route('/signup/',methods=('GET','POST'))
def signup():
form = UserCreateForm()
if request.method == 'POST' and form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if not user:
user = User(username=form.username.data,
password=generate_password_hash(form.password1.data),
email=form.email.data)
db.session.add(user)
db.session.commit()
return redirect(url_for('main.index'))
else:
flash('이미 존재하는 사용자입니다.')
return render_template('auth/signup.html',form=form)
/auth/ 라는 URL 접두어로 시작하는 URL이 호출되면 auth_views.py 파일의 함수들이 호출될 수 있도록 블루프린트 auth를 추가했다. 그런 다음 /signup/ URL과 연결된 signup 함수를 생성했다. signup 함수는 POST 방식 요청에는 계정 등록을, GET 방식 요청에는 계정 등록을 하는 템플릿을 렌더링하도록 구현했다.
pybo/__init__.py
파일에 블루프린트 등록하기pybo/__init__.py
(...생략...)
#블루프린트
from .views import main_views,question_views,answer_views,auth_views
app.register_blueprint(main_views.bp)
app.register_blueprint(question_views.bp)
app.register_blueprint(answer_views.bp)
app.register_blueprint(auth_views.bp)
(...생략...)
pybo/templates/auth/signup.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<form method="post" class="post-form">
{{form.csrf_token}}
{% include "form_errors.html" %}
<div class="form-group">
<label for="username">사용자 이름</label>
<input type="text" class="form-control" name="username" id="username" value="{{form.username.data or ''}}">
</div>
<div class="form-group">
<label for="password1">비밀번호</label>
<input type="password" class="form-control" name="password1" id="password1" value="{{form.password1.data or ''}}">
</div>
<div class="form-group">
<label for="password2">비밀번호 확인</label>
<input type="password" class="form-control" name="password2" id="password2" value="{{form.password2.data or ''}}">
</div>
<div class="form-group">
<label for="email">이메일</label>
<input type="text" class="form-control" name="email" id="email" value="{{form.email.data or ''}}">
</div>
<button type="submit" class="btn btn-primary">생성하기</button>
</form>
</div>
{% endblock %}
pybo/templates/form_errors.html
<!--필드 오류-->
{% for field,errors in form.errors.items() %}
<div class="alert alert-danger" role="alert">
<strong>{{ form[field].label }}</strong>: {{ ', '.join(errors) }}
</div>
{% endfor %}
<!--flash 오류-->
{% for message in get_flashed_messages() %}
<div class="alert alert-danger" role="alert">
{{message}}
</div>
{% endfor %}
(...생략...)
<div class="collapse navbar-collapse flex-grow-0" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.signup') }}">계정생성</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">로그인</a>
</li>
</ul>
</div>
(...생략...)
pybo/forms.py
(... 생략 ...)
class UserLoginForm(FlaskForm):
username = StringField('사용자이름', validators=[DataRequired(), Length(min=3, max=25)])
password = PasswordField('비밀번호',validators=[DataRequired()])
pybo/views/auth_views.py
from flask import Blueprint, url_for, render_template, flash, request, session
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import redirect
from pybo import db
from pybo.forms import UserCreateForm, UserLoginForm
(...생략...)
@bp.route('/login/',methods=('GET','POST'))
def login():
form = UserLoginForm()
if request.method == 'POST' and form.validate_on_submit():
error = None
user = User.query.filter_by(username=form.username.data).first()
if not user:
error="존재하지 않는 사용자입니다."
elif not check_password_hash(user.password,form.password.data):
error = "비밀번호가 올바르지 않습니다."
if error is None:
session.clear()
session['user_id'] = user.id
return redirect(url_for('main.index'))
flash(error)
return render_template('auth/login.html',form=form)
라우트 URL인 /login/에 매핑되는 login 함수를 생성했다.
POST 방식 요청으로 로그인 작업을 수행하는 과정을 알아보자. 우선 폼 입력으로 받은 username으로 데이터베이스에 해당 사용자가 있는지를 검사한다. 만약 사용자가 없으면 '존재하지 않는 사용자입니다'라는 오류를 발생시키고, 사용자가 존재한다면 폼 입력으로 받은 password와 check_password_hash 함수를 사용해 데이터베이스의 비밀번호와 일치하는지를 비교한다.
사용자도 존재하고 비밀번호도 올바르다면 플라스크 세션에 키와 키값을 저장한다. 키에는 'user_id'라는 문자열을, 키값은 데이터베이스에서 조회된 사용자의 id값을 저장한다.
세션 개념을 잠시 살펴보자. 세션은 request와 마찬가지로 플라스크가 자동으로 생성하여 제공하는 변수이다. 쉽게 말해 세션은 플라스크 서버를 구동하는 동안에는 영구히 참조할 수 있는 값이다. session 변수에 user의 id값을 저장했으므로 다양한 URL 요청에 이 세션값을 사용할 수 있다. 예를 들어 현재 웹 브라우저를 요청한 주체가 로그인한 사용자인지 아닌지를 판별할 수 있다.
pybo/templates/auth/login.html
{% extends "base.html" %}
{% block content %}
<div class="container my-3">
<form method="post" class="post-form">
{{form.csrf_token}}
{% include "form_errors.html" %}
<div class="form-group">
<label for="username">사용자 이름</label>
<input type="text" class="form-control" name="username" id="username" value="{{form.username.data or ''}}">
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" class="form-control" name="password" id="password" value="{{form.password.data or ''}}">
</div>
<button type="submit" class="btn btn-primary">로그인</button>
</form>
</div>
{% endblock %}
<!--내비게이션 바-->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
(...생략...)
<div class="collapse navbar-collapse flex-grow-0" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.signup') }}">계정생성</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{url_for('auth.login')}}">로그인</a>
</li>
</ul>
</div>
</nav>
username과 password를 제대로 입력하면 로그인을 수행한 다음 메인 화면으로 이동한다. 하지만 로그인 한 후에도 내비게이션 바에 로그인 링크가 남아있다. 이 링크는 로그아웃 링크로 바뀌어야 한다.
사용자의 로그인 여부는 'session에 저장된 값을 조사'하면 알 수 있다. 단순히 session에 저장된 user_id값 여부로 로그인을 확인할 수도 있지만 여기서는 널리 사용할 수 있는 방법을 선택하자.
pybo/views/auth_views.py
from flask import Blueprint, url_for, render_template, flash, request, session, g
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import redirect
(...생략...)
@bp.before_app_request
def load_logged_in_user():
user_id = session.get('user_id')
if user_id is None:
g.user = None
else:
g.user = User.query.get(user_id)
여기서 @bp.before_app_request 애너테이션을 사용했다. 이 애너테이션이 적용된 함수는 라우트 함수들보다 먼저 실행된다. 즉 앞으로 load_logged_in_user 함수는 모든 라우트 함수보다 먼저 실행될 것이다.
load_logged_in_user 함수에서 사용한 g는 플라스크가 제공하는 컨텍스트 변수이다. 이 변수는 request 변수와 마찬가지로 [요청 -> 응답] 과정에서 유효하다. 코드에서 보듯 session 변수에 user_id값이 있으면 데이터베이스에서 이를 조회하여 g.user에 저장한다.
이렇게 하면 이후 사용자 로그인 검사를 할 때 session을 조사할 필요가 없다. g.user에 값이 있는지만 알아내면 된다. g.user에는 User 객체가 저장되어 있으므로 여러 가지 사용자 정보(username,email 등)을 추가로 얻어내는 이점이 있다.
pybo/templates/navbar.html
<!--내비게이션 바-->
<nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
<a class="navbar-brand" href="{{url_for('main.index')}}">Pybo</a>
<button
class="navbar-toggler ml-auto"
type="button"
data-toggle="collapse"
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse flex-grow-0" id="navbarNav">
{% if g.user %}
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="#">{{g.user.username}} (로그아웃)</a>
</li>
</ul>
{% else %}
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.signup') }}">계정생성</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{url_for('auth.login')}}">로그인</a>
</li>
</ul>
{% endif %}
</div>
</nav>
g.user는 이전 단계에서 구현한 load_logged_in_user 함수로 생성한 사용자 정보값이다. 로그인되어 있다면 g.user가 만들어진 상태이므로 username의 값과 '로그아웃'링크를 보여 줄 것이다. 로그인되어 있지 않다면 '로그인'과 '계정생성' 링크를 보여 줄 것이다.
pybo/views/auth_views.py
(...생략...)
@bp.route('/logout/')
def logout():
session.clear()
return redirect(url_for('main.index'))
(...생략...)
{% if g.user %}
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{url_for('auth.logout')}}">{{g.user.username}} (로그아웃)</a>
</li>
</ul>
{% else %}
(...생략...)
Question, Answer 모델을 수정하여 '글쓴이' 에 해당하는 user 필드를 추가해 보자.
SQLite 데이터베이스는 ORM을 사용할 때 몇 가지 문제점이 있다. 이것은 SQLite 데이터베이스에만 해당하고 다른 데이터베이스에는 상관없는 내용이다.
pybo/__init__.py
파일 수정하기pybo/__init__.py
파일을 열어 설정을 다음과 같이 수정하자.
from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
import config
naming_convention = {
"ix" : "ix_%(column_0_label)s",
"uq" : "uq_%(table_name)s_%(column_0_name)s",
"ck" : "ck_%(table_name)s_%(column_0_name)s",
"fk" : "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk" : "pk_%(table_name)s"
}
db=SQLAlchemy(metadata=MetaData(naming_convention=naming_convention))
migrate=Migrate()
def create_app():
app=Flask(__name__)
#config.py 파일에 작성한 항목을 app.config 환경 변수로 부르기 위한 코드
app.config.from_object(config)
#ORM
db.init_app(app)
if app.config['SQLALCHEMY_DATABASE_URI'].startswitch("sqlite"):
migrate.init_app(app,db,render_as_batch=True)
else:
migrate.init_app(app,db)
migrate.init_app(app,db)
# migrate 객체가 models.py 파일 참조하게 함
from . import models
(...생략...)
파일을 수정한 이유?
SQLite 데이터베이스에서 사용하는 인덱스 등의 제약 조건 이름은 MetaData 클래스를 사용하여 규칙을 정의해야 한다. 만약 이름을 정의하지 않으면 SQLite 데이터베이스는 다음과 같은 제약 조건에 이름이 없다는 오류를 발생시킨다.
ValueError: Constraint must have a name
또 SQLite 데이터베이스는 migrate.init_app(app, db, render_as_batch=True)에서 지정한 것처럼 render_as_batch 속성을 True로 지정해야 한다. 만약 이 속성이 False라면 '제약 조건의 변경을 지원하지 않는다'는 오류를 발생시킨다.
pybo/__init__.py
파일에서 수정한 내용은 SQLite 데이터베이스를 플라스크 ORM에서 정상으로 사용하기 위한 것이라고 이해하면 된다.
pybo/models.py
(...생략...)
class Question(db.Model):
id=db.Column(db.Integer, primary_key=True)
subject=db.Column(db.String(200), nullable=False)
content=db.Column(db.Text(), nullable=False)
create_date=db.Column(db.DateTime(), nullable=False)
user_id=db.Column(db.Integer, db.ForeignKey('user.id',),nullable=False)
user=db.relationship('User',backref=db.backref('question_set'))
(...생략...)
user_id 필드는 User 모델 데이터의 id값을 Question 모델에 포함시키기 위한 것이다. user 필드는 Question 모델에서 User 모델을 참조하기 위한 필드이다. 이를 위해 db.relationship 함수로 필드를 추가한다. db.relationship 함수의 backref 매개변수는 User 모델 데이터를 통해 Question 모델 데이터를 참조하려고 설정한 것이다.
user_id 필드에서 사용된 db.ForeignKey를 자세히 살펴보자. 1번째 인수 user_id는 User 모델의 id값을 의미한다. 알다시피 db.ForeginKey는 다른 모델과 연결하는 것을 의미하므로 2번째 인수 ondelete='CASCADE'는 이 질문과 연결되어 있는 User 모델 데이터가 데이터베이스 명령으로 삭제되면 Question 모델 데이터도 함께 삭제될 수 있게 해주는 설정이다.
flask db migrate 명령으로 리비전 파일 생성하기
모델을 수정했으므로 flask db migate 명령을 실행해서 리비전 파일을 생성하자.
flask db upgrade 명령으로 리비전 파일 적용하기
(myproject) c:\projects\myproject>flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
...
sqlalchemy.exc.IntegrityError: (sqlite3.IntegrityError) NOT NULL constraint failed: _alembic_tmp_question.user_id
[SQL: INSERT INTO _alembic_tmp_question (id, subject, content, create_date) SELECT question.id, question.subject, question.content, question.create_date
FROM question]
(Background on this error at: https://sqlalche.me/e/14/gkpj)
그런데 오류가 난다. 이는 'user_id 필드가 Null값을 허용하지 않기 때문' 이다. 앞서 실습을 진행하며 Question 모델 데이터를 여러 건 저장했던 데이터에 user_id 필드의 값이 없었다. 이 변경된 모델은 이를 허용하지 않으므로 오류가 발생한다.
이 문제를 해결하려면 다음과 같은 과정을 거쳐야 한다. 이런 과정은 어떤 모델에 데이터가 있는데 nullable 설정이 False인 필드를 추가할 때 어쩔 수 없이 거쳐야 한다.
순서 1. user_id의 nullable 설정을 False 대신 True로 바꾸기
순서 2. user_id를 임의의 값으로 설정하기(여기서는 1로 설정)
순서 3. flask db migrate 명령, flask db upgrade 명령 다시 실행하기
순서 4. user_id의 nullable 설정을 다시 False로 변경하기
순서 5. flask db migrate 명령, flask db upgrade 명령 다시 실행하기
pybo/models.py
(...생략...)
class Question(db.Model):
id = db.Column(db.Integer, primary_key=True)
subject = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text(), nullable=False)
create_date = db.Column(db.DateTime(), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=True, server_default='1')
user = db.relationship('User', backref=db.backref('question_set'))
(...생략...)
필드의 기본값은 default, server_default 를 사용해 설정할 수 있다. server_default 를 사용하면 flask db upgrade 명령을 수행할 때 필드를 갖고 있지 않던 기존 데이터에도 기본값이 저장된다. 하지만 default는 새로 생성되는 데이터에만 기본값을 생성해 준다.
(myproject) c:\projects\myproject>flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
ERROR [flask_migrate] Error: Target database is not up to date.
오류가 난다. 왜냐하면 이전의 migrate 명령은 제대로 수행되었지만 upgrade 를 실패해 정상으로 종료되지 않았기 때문이다. 문제를 해결해 보자.
(myproject) c:\projects\myproject>flask db heads
7f6ea668965d (head)
(myproject) c:\projects\myproject>flask db current
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
65f87f44fc8c
2개의 리비전이 다른 이유는 migrate 이후 upgrade를 실패했기 때문이다. 이 때문에 migrate 명령을 수행할 수 없는 것이다. 이 둘이 일치해야 migrate 작업을 진행할 수 있다.
(myproject) c:\projects\myproject>flask db stamp heads
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running stamp_revision 65f87f44fc8c -> 7f6ea668965d
(myproject) c:\projects\myproject>flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected removed table '_alembic_tmp_question'
INFO [alembic.autogenerate.compare] Detected added column 'question.user_id'
INFO [alembic.autogenerate.compare] Detected added foreign key (user_id)(id) on table question
Generating c:\projects\myproject\migrations\versions\8ee9ea023fbf_.py ... done
(myproject) c:\projects\myproject>flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 2bb98ef5137c -> 8ee9ea023fbf, empty message
(...생략...)
class Question(db.Model):
id=db.Column(db.Integer, primary_key=True)
subject=db.Column(db.String(200), nullable=False)
content=db.Column(db.Text(), nullable=False)
create_date=db.Column(db.DateTime(), nullable=False)
user_id=db.Column(db.Integer, db.ForeignKey('user.id',),nullable=False)
user=db.relationship('User',backref=db.backref('question_set'))
(...생략...)
pybo/models.py
class Answer(db.Model):
id=db.Column(db.Integer, primary_key=True)
question_id=db.Column(db.Integer, db.ForeignKey('question.id',ondelete='CASCADE'))
question=db.relationship('Question', backref=db.backref('answer_set',))
content=db.Column(db.Text(),nullable=False)
create_date=db.Column(db.DateTime(),nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id',), nullable=True, server_default='1')
user = db.relationship('User', backref=db.backref('answer_set'))
(myproject) c:\projects\myproject>flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added column 'answer.user_id'
INFO [alembic.autogenerate.compare] Detected added foreign key (user_id)(id) on table answer
Generating c:\projects\myproject\migrations\versions\492afe287e0e_.py ... done
(myproject) c:\projects\myproject>flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade eed5c06a16c0 -> 492afe287e0e, empty message
pybo/models.py
class Answer(db.Model):
id=db.Column(db.Integer, primary_key=True)
question_id=db.Column(db.Integer, db.ForeignKey('question.id',ondelete='CASCADE'))
question=db.relationship('Question', backref=db.backref('answer_set',))
content=db.Column(db.Text(),nullable=False)
create_date=db.Column(db.DateTime(),nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id',), nullable=False)
user = db.relationship('User', backref=db.backref('answer_set'))
이어서 migrate, upgrade 명령을 순서대로 수행한다.
(myproject) c:\projects\myproject>flask db migrate
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected NOT NULL on column 'answer.user_id'
Generating c:\projects\myproject\migrations\versions\1d25833fe03d_.py ... done
(myproject) c:\projects\myproject>flask db upgrade
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade 492afe287e0e -> 1d25833fe03d, empty message
from datetime import datetime
from flask import Blueprint, url_for, request, render_template, g
(...생략...)
@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(), user=g.user)
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)
from datetime import datetime
from flask import Blueprint, render_template, request, url_for, g
(... 생략...)
@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(), user=g.user)
db.session.add(question)
db.session.commit()
return redirect(url_for('main.index'))
return render_template('question/question_form.html', form=form)
로그아웃 상태에서 질문, 답변 등록해 보기 - 오류 발생
오류가 발생한 이유는 로그아웃했으므로 g.user의 값이 None이기 때문이다. 이 문제를 해결하려면 로그아웃 상태에서 질문 또는 답변을 등록할 때 사용자를 로그인 페이지로 리다이렉트해야 한다. 그렇게 하려면 모든 질문, 답변 등록 함수의 시작 부분에 리다이렉트를 처리하기 위한 코드를 추가해야 한다. 그리고 이 방식은 같은 코드가 중복되므로 무척 비효율적이다. 다행히도 이런 경우에는 '파이썬 데코레이터'를 사용하면 문제를 손쉽게 해결할 수 있다.
데코레이터 함수 생성해 보기
auth_views.py 파일에 login_required 라는 이름의 데코레이터 함수를 생성하자.
pybo/views/auth_views.py
import fuctools
(...생략...)
def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if g.user is None:
return redirect(url_for('auth.login'))
return view(**kwargs)
return wrapped_view
코드에서 보듯 데코레이터 함수는 기존함수를 감싸는 방법으로 간단히 만들 수 있다. 이제 다른 함수에 @login_required 애너테이션을 지정하면 login_required 데코레이터 함수가 먼저 실행된다. login_required 함수는 g.user가 있는지를 조사하여 없으면 로그인 URL로 리다이렉트 하고 g.user가 있으면 원래 함수를 그대로 실행한다.
pybo/views/question_views.py
(...생략...)
from pybo.views.auth_views import login_required
(...생략...)
@bp.route('/create/', methods=('GET', 'POST'))
@login_required
def create():
(...생략...)
pybo/views/answer_views.py
(...생략...)
from .auth_views import login_required
(...생략...)
@bp.route('/create/<int:question_id>', methods=('POST',))
@login_required
def create(question_id):
(...생략...)
이제 로그아웃 상태에서 질문, 답변 등록을 시도하면 로그인 화면으로 리다이렉트된다.
로그아웃 상태에서 답변 등록을 할 수 있는 것처럼 되어 있는 문제를 해결하자.
pybo/templates/question/question_detail.html
(...생략...)
<div class="form-group">
<textarea {% if not g.user %} disabled {% endif %}
name="content" id="content" class="form-control" rows="10"></textarea>
</div>
<input type="submit" value="답변등록" class="btn btn-primary">
</form>
</div>
{% endblock %}
Question 모델과 Answer 모델에 user 필드를 추가했다. 게시판의 게시물에는 '글쓴이'를 표시하는 것이 일반적이다. 글쓴이를 표시해 보자.
pybo/templates/question/question_list.html
(...생략...)
<tr class="text-center thead-dark">
<th>번호</th>
<th style="width:50%">제목</th>
<th>글쓴이</th>
<th>작성일시</th>
</tr>
(...생략...)
(...생략...)
{% for question in question_list.items %}
<tr class="text-center">
<td>{{question_list.total - ((question_list.page-1)*question_list.per_page)-loop.index0}}</td>
<td class="text-left">
<a href="{{url_for('question.detail',question_id=question.id)}}">
{{question.subject}}
</a>
{% if question.answer_set | length > 0 %}
<span class="text-danger small ml-2">
{{question.answer_set | length}}
</span>
{% endif %}
</td>
<td>{{question.user.username}}</td> <!--글쓴이 추가-->
<td>{{question.create_date | datetime}}</td>
</tr>
{% endfor %}
(...생략...)
질문 상세 조회 템플릿에 글쓴이 표시하기
(...생략...)
<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 text-left">
<div class="mb-2">{{question.user.username}}</div>
<div>{{question.create_date | datetime}}</div>
</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 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
</div>
</div>
</div>
(...생략...)
from pybo import db
class Question(db.Model):
(...생략...)
modify_date = db.Column(db.DateTime(),nullable=True)
class Answer(db.Model):
(...생략...)
modify_date = db.Column(db.DateTime(),nullable=True)
pybo/templates/question/question_detail.html
(...생략...)
<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 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
</div>
{% if g.user == question.user %}
<div class="my-3">
<a href="{{url_for('question.modify', question_id=question.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
</div>
{% endif %}
</div>
</div>
(...생략...)
pybo/views/question_views.py
(...생략...)
from flask import Blueprint, render_template, request, url_for, g,flash
(...생략...)
@bp.route('/modify/<int:question_id>', methods=('GET','POST'))
@login_required
def modify(question_id):
question = Question.query.get_or_404(question_id)
if g.user != question.user :
flash('수정권한이 없습니다')
return redirect(url_for('question.detail',question_id=question_id))
if request.method == 'POST':
form = QuestionForm()
if form.validate_on_submit():
form.populate_obj(question)
question.modify_date = datetime.now() # 수정일시 저장
db.session.commit()
return redirect(url_for('question.detail',question_id=question_id))
else: # if request.method == 'Post' 에 대응하는 else문
form = QuestionForm(obj=question)
return render_template('question/question_form.html', form=form)
질문 수정은 로그인이 필요하므로 @login_required 애너테이션을 추가했다. 만약 로그인한 사용자와 질문의 작성자가 다르면 수정할 수 없도록 flash 오류를 발생시키는 코드도 추가했다.
modify 함수가 GET 방식으로 요청되는 경우는 <질문수정> 버튼을 눌렀을때이다(question/question_form.html 템플릿 렌더링). 이때 이미 수정할 질문에 해당하는 '제목', '내용' 등의 데이터가 보여야 한다. 데이터베이스에서 조회한 데이터를 템플릿에 적용하는 가장 간단한 방법은 QuestionForm(obj=question)과 같이 조회한 데이터를 obj 매개변수에 전달하여 폼을 생성하는 것이다. 이렇게 하면 QuestionForm의 subject, content 필드에 question 객체의 subject, content의 값이 적용된다.
modify 함수가 POST 방식으로 요청되는 경우는 질문 수정 화면에서 데이터를 수정한 다음 <저장하기> 버튼을 눌렀을 경우이다. 그러면 form.validate_on_submit 함수에서 QuestionForm 을 검증하는데, 아무 이상이 없으면 변경된 데이터를 저장한다. 데이터 변경을 위해 입력한 form.populate_obj(question)는 form 변수에 들어 있는 데이터(화면에 입력되어 있는 데이터)를 question 객체에 적용해 준다. 이어서 question 객체의 modify_date를 현재일시로 저장한다.
pybo/template/question/question_detail.html
(...생략...)
{% if g.user == question.user %}
<div class="my-3">
<a href="{{url_for('question.modify', question_id=question.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="#" class="delete btn btn-sm btn-outline-secondary" data-uri="{{url_for('question.delete',question_id=question.id)}}">삭제</a>
</div>
{% endif %}
(...생략...)
삭제 버튼은 수정 버튼과는 달리 href 속성값을 "#"로 설정했다. 그리고 jQuery에서 $(this).data('uri')로 삭제를 실행하는 URL을 얻으려고 data-uri 속성도 추가했다.
질문 삭제 버튼에 jQuery 사용하기
삭제 기능에서 삭제 버튼을 구현할 때 '정말로 삭제하시겠습니까?' 와 같은 확인 창을 보여 주어야 한다.
jQuery 실행을 위해 templates/base.html 파일 수정하기
jQuery는 jQuery 자바스크립트를 불러온 다음 사용할 수 있다. 알다시피 base.html 파일에 jQuery 로드 관련 코드가 추가되어 있다. 다만 템플릿에서 jQuery를 사용할 수 있도록 base.html 파일을 수정해야 한다.
pybo/templates/base.html
(...생략...)
<body>
{% include "navbar.html" %}
<!--기본 템플릿에 삽입할 내용 Start-->
{% block content %}
{% endblock %}
<!--기본 템플릿에 삽입할 내용 End-->
<!-- jQuery JS -->
<script src="{{url_for('static',filename='jquery-3.6.0.min.js')}}"></script>
<!-- Bootstarp JS -->
<script src="{{url_for('static',filename='bootstrap.min.js')}}"></script>
<!-- 자바스크립트 Start -->
{% block script %}
{% endblock %}
<!-- 자바스크립트 End -->
</body>
</html>
<script src="{{url_for('static', filename='jquery-3.6.0.min.js')}}"></script>
이후에 {% block script %} {% endblock %}
을 추가했다. 이렇게 하면 base.html 파일을 상속받는 템플릿이 이 블록을 구현하여 jQuery를 사용한 코드를 작성할 수 있다.
pybo/templates/question/question_detail.html
(...생략...)
{% endblock %}
{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
$(".delete").on('click', function(){
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = $(this).data('uri');
}
});
});
</script>
{% endblock %}
pybo/views/question_views.py
(...생략...)
@bp.route('/delete/<int:question_id>')
@login_required
def delete(question_id):
question = Question.query.get_or_404(question_id)
if g.user != question.user:
flash('삭제권한이 없습니다')
return redirect(url_for('question.detail', question_id=question_id))
db.session.delete(question)
db.session.commit()
return redirect(url_for('question._list'))
이번에는 답변 수정 & 삭제 기능을 추가하자. 질문 수정 & 삭제 기능과 거의 비슷한 구성으로 실습을 진행한다. 다만 답변 수정은 답변 등록 템플릿이 따로 없으므로 답변 수정에 사용할 템플릿이 추가로 필요하다.
pybo/templates/question/question_detail.html
(...생략...)
{% 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 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
</div>
{% if g.user == answer.user %}
<div class="my-3">
<a href="{{url_for('answer.modify', answer_id=answer.id)}}" class="btn btn-sm btn-ourline-secondary">수정</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
(...생략...)
답변 목록이 출력되는 부분에 답변 수정 버튼을 추가하자.
pybo/views/answer_views.py
(...생략...)
from flask import Blueprint, url_for, request, render_template, g, flash
(...생략...)
@bp.route('/modify/<int:answer_id>',methods=('GET', 'POST'))
@login_required
def modify(answer_id):
answer = Answer.query.get_or_404(answer_id)
if g.user != answer.user:
flash('수정권한이 없습니다')
return redirect(url_for('question.detail', question_id=answer.question.id))
if request.method == "POST":
form = AnswerForm()
if form.validate_on_submit():
form.populate_obj(answer)
answer.modify_date = datetime.now() # 수정일시 저장
db.session.commit()
return redirect(url_for('question.detail',question_id=answer.question.id))
else:
form = AnswerForm(obj=answer)
return render_template('answer/answer_form.html', answer=answer, form=form)
pybo/templates/answer/answer_form.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<form method="post" class="post-form">
{{form.csrf_token}}
{% include "form_errors.html" %}
<div class="form-group">
<label for="content">답변내용</label>
<textarea class="form-control" name="content" id="content" rows="10">
{{form.content.data or ''}}</textarea>
</textarea>
</div>
<button type="submit" class="btn btn-primary">저장하기</button>
</form>
</div>
{% endblock %}
pybo/templates/question/question_detail.html
(...생략...)
{% 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 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
</div>
{% if g.user == answer.user %}
<div class="my-3">
<a href="{{url_for('answer.modify', answer_id=answer.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="#" class="delete btn btn-sm btn-outline-secondary"data-uri="{{url_for('answer.delete',answer_id=answer.id)}}">삭제</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
(...생략...)
pybo/views/answer_views.py
@bp.route('/delete/<int:answer_id>')
@login_required
def delete(answer_id):
answer = Answer.query.get_or_404(answer_id)
question_id = answer.question.id
if g.user != answer.user:
flash('삭제권한이 없습니다')
else:
db.session.delete(answer)
db.session.commit()
return redirect(url_for('question.detail', question_id=question_id))
pybo/templates/question/question_detail.html
{% extends 'base.html' %}
{% block content %}
<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">
{% if question.modify_date %}
<div class="badge-light p-2 text-left mx-3">
<div class="mb-2">modified at</div>
<div>{{question.modify_date | datetime}}</div>
</div>
{% endif %}
<div class="badge-light p-2 text-left">
<div class="mb-2">{{question.user.username}}</div>
<div>{{question.create_date | datetime}}</div>
</div>
</div>
(...생략...)
{% 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">
{% if answer.modify_date %}
<div class="badge-light p-2 text-left mx-3">
<div class="mb-2">modified at</div>
<div>{{answer.modify_date | datetime}}</div>
</div>
{% endif %}
<div class="p-2 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
</div>
{% if g.user == answer.user %}
<div class="my-3">
<a href="{{url_for('answer.modify', answer_id=answer.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="#" class="delete btn btn-sm btn-outline-secondary" data-uri="{{url_for('answer.delete',answer_id=answer.id)}}">삭제</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
pybo/models.py
(...생략...)
class Comment(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'),nullable=False)
user = db.relationship('User',backref=db.backref('comment_set'))
content = db.Column(db.Text(), nullable=False)
create_date = db.Column(db.DateTime(), nullable=False)
modify_date = db.Column(db.DateTime())
question_id = db.Column(db.Integer, db.ForeignKey('question.id',ondelete='CASCADE'),nullable=True)
question = db.relationship('Question', backref=db.backref('comment_set'))
answer_id = db.Column(db.Integer, db.ForeignKey('answer.id',ondelete='CASCADE'),nullable=True)
answer = db.relationship('Answer',backref=db.backref('comment_set'))
pybo/templates/question/question_detail.html
(...생략...)
<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">
{% if answer.modify_date %}
<div class="badge-light p-2 text-left mx-3">
<div class="mb-2">modified at</div>
<div>{{answer.modify_date | datetime}}</div>
</div>
{% endif %}
<!-- 질문 댓글 Start -->
<div class="p-2 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
</div>
{% if g.user == answer.user %}
<div class="my-3">
<a href="{{url_for('answer.modify', answer_id=answer.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="#" class="delete btn btn-sm btn-outline-secondary" data-uri="{{url_for('answer.delete',answer_id=answer.id)}}">삭제</a>
</div>
{% endif %}
{% if question.comment_set|length > 0 %}
<div class="mt-3">
{% for comment in question.comment_set %}
<div class="comment py-2 text-muted">
<span style="white-space: pre-line;">{{ comment.content }}</span>
<span>
- {{ comment.user.username }}, {{ comment.create_date|datetime }}
{% if comment.modify_date %}
(수정:{{ comment.modify_date|datetime }})
{% endif %}
</span>
{% if g.user == comment.user %}
<a href="{{ url_for('comment.modify_question', comment_id=comment.id) }}" class="small">수정</a>,
<a href="#" class="small delete" data-uri="{{ url_for('comment.delete_question', comment_id=comment.id) }}">삭제</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div>
<a href="{{ url_for('comment.create_question', question_id=question.id) }}" class="small"><small>댓글 추가 ..</small></a>
</div>
<!-- 질문 댓글 End -->
</div>
</div>
(...생략...)
pybo/static/style.css
.comment {
border-top:dotted 1px #ddd;
font-size:0.7em;
}
pybo/forms.py
class CommentForm(FlaskForm):
content = TextAreaField('내용', validators=[DataRequired()])
pybo/views/comment_views.py
from datetime import datetime
from flask import Blueprint, url_for, request, render_template, g
from werkzeug.utils import redirect
from pybo import db
from pybo.forms import CommentForm
from pybo.models import Question, Comment
from pybo.views.auth_views import login_required
bp = Blueprint('comment', __name__, url_prefix='/comment')
@bp.route('/create/question/<int:question_id>',methods=('GET','POST'))
@login_required
def create_question(question_id):
form = CommentForm()
question = Question.query.get_or_404(question_id)
if request.method == 'POST' and form.validate_on_submit():
comment = Comment(user=g.user, content=form.content.data, create_date=datetime.now(),question=question)
db.session.add(comment)
db.session.commit()
return redirect(url_for('question.detail',question_id=question_id))
return render_template('comment/comment_form.html',form=form)
질문에 달린 댓글이므로 Comment 모델 객체를 생성할 때 question 필드에 값을 설정한 점에 주의하자.
pybo/__init__.py
(...생략...)
#블루프린트
from .views import main_views,question_views,answer_views,auth_views,comment_views
app.register_blueprint(main_views.bp)
app.register_blueprint(question_views.bp)
app.register_blueprint(answer_views.bp)
app.register_blueprint(auth_views.bp)
app.register_blueprint(comment_views.bp)
(...생략...)
pybo/templates/comment/comment_form.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<h5 class="container my-3">댓글등록하기</h5>
<form method="post" class="post-form my-3">
{{ form.csrf_token }}
{% include "form.errors.html" %}
<div class="form-group">
<label for="content">댓글내용</label>
<textarea class="form-control" name="content" id="content" rows="3">{{form.content.data or ''}}</textarea>
</div>
<button type="submit" class="btn btn-primary">저장하기</button>
</form>
</div>
{% endblock %}
pybo/views/comment_views.py
(...생략...)
from flask import Blueprint, url_for, request, render_template, g, flash
(...생략...)
@bp.route('/modify/question/<int:comment_id>',methods=('GET','POST'))
@login_required
def modify_question(comment_id):
comment=Comment.query.get_or_404(comment_id)
if g.user != comment.user:
flash('수정권한이 없습니다')
return redirect(url_for('question.detail', question_id=comment.question.id))
if request.method == 'POST':
form = CommentForm()
if form.validate_on_submit():
form.populate.obj(comment)
comment.modify_date = datetime.now()
db.session.commit()
return redirect(url_for('question.detail',question_id=comment.question.id))
else:
form = CommentForm(obj=comment)
return render_template('comment/comment_form.html',form=form)
GET 방식으로 요청할 경우 기존 댓글을 조회하여 반환하고,
POST 방식으로 요청할 경우 폼에서 받은 내용으로 댓글을 업데이트한다.
pybo/views/comment_views.py
@bp.route('/delete/question/<int:comment_id>')
@login_required
def delete_question(comment_id):
comment=Comment.query.get_or_404(comment_id)
question_id = comment.question.id
if g.user != comment.user:
flash('삭제권한이 없습니다')
return redirect(url_for('question.detail',question_id=question_id))
db.session.delete(comment)
db.session.commit()
return redirect(url_for('question.detail',question_id=question_id))
답변 댓글 기능 추가는 질문 댓글 기능을 추가하는 과정과 크게 차이나지 않는다.
pybo/templates/question/question_detail.html
(...생략...)
<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">
{% if answer.modify_date %}
<div class="badge-light p-2 text-left mx-3">
<div class="mb-2">modified at</div>
<div>{{answer.modify_date | datetime}}</div>
</div>
{% endif %}
<div class="p-2 text-left">
<div class="mb-2">{{answer.user.username}}</div>
<div>{{answer.create_date | datetime}}</div>
</div>
{% if g.user == answer.user %}
<div class="my-3">
<a href="{{url_for('answer.modify', answer_id=answer.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="#" class="delete btn btn-sm btn-outline-secondary" data-uri="{{url_for('answer.delete',answer_id=answer.id)}}">삭제</a>
</div>
{% endif %}
<!-- 답변 댓글 start -->
{% if answer.comment_set|length > 0 %}
<div class="mt-3">
{% for comment in answer.comment_set %}
<div class="comment py-2 text-muted">
<span style="white-space: pre-line;">{{ comment.content }}</span>
<span>
- {{ comment.user.username }}, {{ comment.create_date|datetime }}
{% if comment.modify_date %}
(수정:{{ comment.modify_date|datetime }})
{% endif %}
</span>
{% if g.user == comment.user %}
<a href="{{ url_for('comment.modify_answer',comment_id=comment.id) }}" class="small">수정</a>,
<a href="#" class="small delete" data-uri="{{ url_for('comment.delete_answer', comment_id=comment.id) }}">삭제</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div>
<a href="{{ url_for('comment.create_answer', answer_id=answer.id) }}" class="small"><small>댓글 추가..</small></a>
</div>
<!-- 답변 댓글 End -->
</div>
</div>
</div>
{% endfor %}
(...생략...)
pybo/views/comment_views.py
(...생략...)
from pybo.models import Question, Comment, Answer
(...생략...)
@bp.route('/create/answer/<int:answer_id>', methods=('GET','POST'))
@login_required
def create_answer(answer_id):
form = CommentForm()
answer = Answer.query.get_or_404(answer_id)
if request.method == 'POST' and form.validate_on_submit():
comment = Comment(user=g.user, content=form.content.data, create_date=datetime.now(),answer=answer)
db.session.add(comment)
db.session.commit()
return redirect(url_for('question.detail',question_id=answer.question.id))
return render_template('comment/comment_form.html',form=form)
@bp.route('/modify/answer/<int:comment_id>',methods=('GET','POST'))
@login_required
def modify_answer(comment_id):
comment = Comment.query.get_or_404(comment_id)
if g.user != comment.user:
flash('수정권한이 없습니다')
return redirect(url_for('question.detail',question_id=comment.answer.id))
if request.method == 'POST':
form = CommentForm()
if form.validate_on_submit():
form.populate_obj(comment)
comment.modify_date = datetime.now()
db.session.commit()
return redirect(url_for('question.detail', question_id=comment.answer.question.id))
else:
form = CommentForm(obj=comment)
return render_template('comment/comment_form.html', form=form)
@bp.route('/delete/answer/<int:comment_id>')
@login_required
def delete_answer(comment_id):
comment = Comment.query.get_or_404(comment_id)
question_id = comment.answer.question.id
if g.user != comment.user:
flash('삭제권한이 없습니다')
return redirect(url_for('question.detail', question_id=question_id))
db.session.delete(comment)
db.sesstion.commit()
return redirect(url_for('question.detail',question_id=question_id))
질문/답변 칸의 '댓글추가..' , '삭제', '추가' 가 정상으로 작동한다.
'추천'은 질문이나 답변에 적용해야 하는 요소이다. 그러려면 Question, Answer 모델에 '추천인'이라는 필드를 추가해야 한다. 게시판 서비스를 사용해 봤다면 글 1개에 여러 명이 추천할 수 있고, 반대로 1명이 여러 개의 글을 추천할 수 있다는 것을 쉽게 알 수 있다. 그리고 이런 경우에는 모델의 다대다 관계를 사용해야 한다.
pybo/models.py
from pybo import db
question_voter = db.Table(
'question_voter',
db.Column('user_id',db.Integer,db.ForeignKey(
'user.id', ondelete='CASCADE'),primary_key=True),
db.Column('question_id',db.Integer,db.ForeignKey(
'question.id', ondelete='CASCADE'),primary_key=True)
)
여기서 테이블 객체란 다대다 관계를 정의하려고 db.Table 클래스로 정의되는 객체를 말한다. question_voter는 user_id와 question_id 모두 기본 키이므로 다대다 관계가 성립되는 테이블이다.
이 코드는 다음과 같이 구성된 question_voter 테이블을 만든다.
(...생략...)
class Question(db.Model):
id=db.Column(db.Integer, primary_key=True)
subject=db.Column(db.String(200), nullable=False)
content=db.Column(db.Text(), nullable=False)
create_date=db.Column(db.DateTime(), nullable=False)
user_id=db.Column(db.Integer, db.ForeignKey('user.id',),nullable=False)
user=db.relationship('User',backref=db.backref('question_set'))
modify_date = db.Column(db.DateTime(),nullable=True)
voter = db.relationship('User', secondary=question_voter, backref=db.backref('question_voter_set'))
(...생략...)
voter 필드는 user 필드와 똑같이 User 모델의 relationship으로 만든다. 다만 secondary 설정을 했다는 차이점이 있다. secondary 설정은 'voter가 다대다 관계며, question_voter 테이블을 참조한다'는 사실을 알려 준다. 또 backref를 question_voter_set로 설정했다. 이 설정은 만약 어떤 계정이 a_user라는 객체로 참조되면 a_user.question_voter_set으로 해당 계정이 추천한 질문 리스트를 구할 수 있게 만들어 준다.
한 가지 주의할 점은 relationship의 backref 설정에 사용하는 이름은 중복되면 안 된다는 점이다. 예를 들어 Question 모델에는 이미 user필드의 backref 설정에 question_set 이라는 이름을 사용했으므로 voter 필드의 backref 설정에는 question_set을 사용할 수 없다.
pybo/models.py
(...생략...)
answer_voter = db.Table(
'answer_voter',
db.Column('user_id', db.Integer, db.ForeignKey(
'user.id', ondelete='CASCADE'), primary_key=True),
db.Column('answer_id',db.Integer, db.ForignKey(
'answer.id',ondelete='CASCADE'),primary_key=True)
)
(...생략...)
class Answer(db.Model):
id=db.Column(db.Integer, primary_key=True)
question_id=db.Column(db.Integer, db.ForeignKey('question.id',ondelete='CASCADE'))
question=db.relationship('Question', backref=db.backref('answer_set',))
content=db.Column(db.Text(),nullable=False)
create_date=db.Column(db.DateTime(),nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id',), nullable=False)
user = db.relationship('User', backref=db.backref('answer_set'))
modify_date = db.Column(db.DateTime(),nullable=True)
voter = db.relationship('User', secondary=answer_voter, backref=db.backref('answer_voter_set'))
(...생략...)
pybo/templates/question/question_detail.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<h2 class="border-bottom py-2">
{{question.subject}}
</h2>
<div class="row my-3">
<div class="col-1"> <!-- 추천영역 -->
<div class="bg-light text-center p-3 border font-weight-bolder mb-1">{{question.voter|length}}</div>
<a href="#" data-uri="{{url_for('vote.question', question_id=question.id)}}" class="recommend btn btn-sm btn-secondary btn-block my-1">추천</a>
</div>
<div class="col-11"> <!-- 질문영역 -->
<!-- 기존내용 -->
<div class="card">
<div class="card-body">
<div class="card-text" style="white-space:pre-line;">{{question.content}}</div>
<div class="d-flex justify-content-end">
{% if question.modify_date %}
<div class="badge-light p-2 text-left mx-3">
<div class="mb-2">modified at</div>
<div>{{question.modify_date | datetime}}</div>
</div>
{% endif %}
<div class="badge-light p-2 text-left">
<div class="mb-2">{{question.user.username}}</div>
<div>{{question.create_date | datetime}}</div>
</div>
</div>
{% if g.user == question.user %}
<div class="my-3">
<a href="{{url_for('question.modify', question_id=question.id)}}" class="btn btn-sm btn-outline-secondary">수정</a>
<a href="#" class="delete btn btn-sm btn-outline-secondary" data-uri="{{url_for('question.delete',question_id=question.id)}}">삭제</a>
</div>
{% endif %}
<!-- 질문 댓글 start -->
{% if question.comment_set|length > 0 %}
<div class="mt-3">
{% for comment in question.comment_set %}
<div class="comment py-2 text-muted">
<span style="white-space: pre-line;">{{ comment.content }}</span>
<span>
- {{ comment.user.username }}, {{ comment.create_date|datetime }}
{% if comment.modify_date %}
(수정:{{ comment.modify_date|datetime }})
{% endif %}
</span>
{% if g.user == comment.user %}
<a href="{{ url_for('comment.modify_question', comment_id=comment.id) }}" class="small">수정</a>,
<a href="#" class="small delete" data-uri="{{ url_for('comment.delete_question', comment_id=comment.id) }}">삭제</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div>
<a href="{{ url_for('comment.create_question', question_id=question.id) }}" class="small"><small>댓글 추가 ..</small></a>
</div>
<!--질문 댓글 End -->
</div>
</div>
</div>
</div>
<h5 class="border-bottom my-3 py-2">{{question.answer_set|length}}개의 답변이 있습니다.</h5>
{% for answer in question.answer_set %}
(...생략...)
{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
$(".delete").on('click', function(){
if(confirm("정말로 삭제하시겠습니까?")) {
location.href = $(this).data('uri');
}
});
$(".recommend").on('click',function(){
if(confirm("정말로 추천하시겠습니까?")){
location.href = $(this).data('uri');
}
});
});
</script>
{% endblock %}
pybo/views/vote_views.py
from flask import Blueprint, url_for, flash, g
from werkzeug.utils import redirect
from pybo import db
from pybo.models import Question
from pybo.views.auth_views import login_required
bp = Blueprint('vote', __name__, url_prefix='/vote')
@bp.route('/question/<int:question_id>/')
@login_required
def question(question_id):
question = Question.query.get_or_404(question_id)
if g.user == question.user:
flash('본인이 작성한 글은 추천할 수 없습니다')
else:
question.voter.append(g.user)
db.session.commit()
return redirect(url_for('question.detail',question_id=question.id))
Question 모델의 voter는 여러 사람을 추가할 수 있는 다대다 관계이므로 question.voter.append(g.user)와 같이 append 함수로 추천인을 추가해야 한다.
pybo/__init__.py
(...생략...)
#블루프린트
from .views import main_views,question_views,answer_views,auth_views,comment_views,vote_views
app.register_blueprint(main_views.bp)
app.register_blueprint(question_views.bp)
app.register_blueprint(answer_views.bp)
app.register_blueprint(auth_views.bp)
app.register_blueprint(comment_views.bp)
app.register_blueprint(vote_views.bp)
(...생략...)
pybo/templates/question/question_detail.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
{% for message in get_flashed_messages() %}
<div class="alert alert-danger" role="alert">
{{ message }}
</div>
{% endfor %}
<h2 class="border-bottom py-2">
(...생략...)
pybo/templates/question/question_detail.html
(...생략...)
<h5 class="border-bottom my-3 py-2">{{question.answer_set|length}}개의 답변이 있습니다.</h5>
{% for answer in question.answer_set %}
<div class="row my-3">
<div class="col-1"> <!-- 추천영역 -->
<div class="bg-lgiht text-center p-3 border font-weight-bolder mb-1">{{ answer.voter | length }}</div>
<a href="#" data-uri="{{url_for('vote.answer',answer_id=answer.id)}}" class="recommend btn btn-sm btn-secondary btn-block my-1">추천</a>
</div>
<div class="col-11"> <!-- 답변영역 -->
<!-- 기존내용 -->
<div class="card">
<div class="card-body">
(...생략...)
</div>
</div>
</div>
</div>
{% endfor %}
(...생략...)
pybo/views/vote_views.py
(...생략...)
from pybo.models import Question, Answer
(...생략...)
@bp.route('/answer/<int:answer_id>/')
@login_required
def answer(answer_id):
answer = Answer.query.get_or_404(answer_id)
if g.user == answer.user:
flash('본인이 작성한 글은 추천할 수 없습니다.')
else:
answer.voter.append(g.user)
db.session.commit()
return redirect(url_for('question.detail',question_id=answer.question.id))
pybo/templates/question/question_list.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<table class="table">
<thead>
<tr class="text-center thead-dark">
<th>번호</th>
<th>추천</th>
<th style="width:50%">제목</th>
<th>글쓴이</th>
<th>작성일시</th>
</tr>
</thead>
<tbody>
{% if question_list %}
{% for question in question_list.items %}
<tr class="text-center">
<td>{{question_list.total - ((question_list.page-1)*question_list.per_page)-loop.index0}}</td>
<td>
{% if question.voter|length > 0 %}
<span class="badge badge-warning px-2 py-1">{{question.voter|length}}</span>
{% endif %}
</td>
(...생략...)
파이보에 더 많은 기능을 추가하기 전에 '스크롤 초기화'문제점을 해결해 보자.
답글 작성 또는 수정 후 스크롤이 항상 페이지 상단으로 이동하는 문제를 경험할 것이다. 코드 오류는 아니지만 서비스답지 못한 현상이다. 하지만 일반 사용자라면 답변을 작성한 다음에 자신이 작성한 답변 위치에 스크롤이 있어야 자연스럽게 느낀다. 이 문제를 해결해 보자.
HTML에는 URL을 호출하면 원하는 위치로 스크롤을 이동시키는 앵커 엘리먼트(a)가 있다. 예를 들어 HTML 중간에 <a name="flask></a>
와 같이 앵커 엘리먼트를 위치시키고, 해당 HTML을 호출하는 URL 뒤에 #flask 를 붙여주면 바로 해당 앵커 엘리먼트 위치로 스크롤이 이동한다. 이 원리로 '스크롤 초기화'문제를 해결할 것이다.
(...생략...)
<h5 class="border-bottom my-3 py-2">{{question.answer_set|length}}개의 답변이 있습니다.</h5>
{% for answer in question.answer_set %}
<a name="answer_{{answer.id}}"></a>
(...생략...)
답변이 반복되어 표시되는 for문 바로 다음에 앵커 엘리먼트를 추가했다. name 속성은 유일해야 하므로 answer_{{answer.id}} 와 같이 답변 id를 사용했다.
기존 코드
return redirect(urlfor('question.detail', question_id=question_id))
앵커 엘리먼트 포함 코드
return redirect('{}#answer{}'.format(url_for('question.detail', question_id=question_id), answer.id))
pybo/views/answer_views.py
(...생략...)
@bp.route('/create/<int:question_id>', methods=('POST',))
@login_required
def create(question_id):
(...생략...)
if form.validate_on_submit():
(...생략...)
return redirect('{}#answer_{}'.format(url_for('question.detail',question_id=question_id),answer.id))
(...생략...)
@bp.route('/modify/<int:answer_id>',methods=('GET', 'POST'))
@login_required
def modify(answer_id):
(...생략...)
if request.method == "POST":
(...생략...)
if form.validate_on_submit():
(...생략...)
return redirect('{}#answer_{}'.format(url_for('question.detail',question_id=answer.question.id),answer.id))
(...생략...)
pybo/templates/question/question_detail.html
(...생략...)
<!-- 질문 댓글 start -->
{% if question.comment_set|length > 0 %}
<div class="mt-3">
{% for comment in question.comment_set %}
<a name="comment_{{comment.id}}"></a>
(...생략...)
<!-- 답변 댓글 start -->
{% if answer.comment_set|length > 0 %}
<div class="mt-3">
{% for comment in answer.comment_set %}
<a name="comment_{{comment.id}}"></a>
(...생략...)
pybo/views/comment_views.py
from datetime import datetime
from flask import Blueprint, url_for, request, render_template, g, flash
from werkzeug.utils import redirect
from pybo import db
from pybo.forms import CommentForm
from pybo.models import Question, Comment, Answer
from pybo.views.auth_views import login_required
bp = Blueprint('comment', __name__, url_prefix='/comment')
@bp.route('/create/question/<int:question_id>',methods=('GET','POST'))
@login_required
def create_question(question_id):
form = CommentForm()
question = Question.query.get_or_404(question_id)
if request.method == 'POST' and form.validate_on_submit():
comment = Comment(user=g.user, content=form.content.data, create_date=datetime.now(),question=question)
db.session.add(comment)
db.session.commit()
return redirect('{}#comment_{}'.format(url_for('question.detail',question_id=question_id),comment.id))
return render_template('comment/comment_form.html',form=form)
@bp.route('/modify/question/<int:comment_id>',methods=('GET','POST'))
@login_required
def modify_question(comment_id):
comment=Comment.query.get_or_404(comment_id)
if g.user != comment.user:
flash('수정권한이 없습니다')
return redirect(url_for('question.detail', question_id=comment.question.id))
if request.method == 'POST':
form = CommentForm()
if form.validate_on_submit():
form.populate.obj(comment)
comment.modify_date = datetime.now()
db.session.commit()
return redirect('{}#comment_{}'.format(url_for('question.detail',question_id=comment.question.id),comment.id))
else:
form = CommentForm(obj=comment)
return render_template('comment/comment_form.html',form=form)
@bp.route('/delete/question/<int:comment_id>')
@login_required
def delete_question(comment_id):
comment=Comment.query.get_or_404(comment_id)
question_id = comment.question.id
if g.user != comment.user:
flash('삭제권한이 없습니다')
return redirect(url_for('question.detail',question_id=question_id))
db.session.delete(comment)
db.session.commit()
return redirect(url_for('question.detail',question_id=question_id))
@bp.route('/create/answer/<int:answer_id>', methods=('GET','POST'))
@login_required
def create_answer(answer_id):
form = CommentForm()
answer = Answer.query.get_or_404(answer_id)
if request.method == 'POST' and form.validate_on_submit():
comment = Comment(user=g.user, content=form.content.data, create_date=datetime.now(),answer=answer)
db.session.add(comment)
db.session.commit()
return redirect('{}#comment_{}'.format(url_for('question.detail',question_id=answer.question.id),comment.id))
return render_template('comment/comment_form.html',form=form)
(...생략...)
파이보는 게시판 서비스이므로 질문 또는 답변을 작성할 때 일반 텍스트 형식으로 글을 작성하면 매력이 떨어진다. 예를 들어 글자를 진하게 표시하거나 링크를 추가하고 싶을 수도 있다. 이런 경우 사용하면 좋은 도구가 바로 '마크다운'이다. 마크다운을 이용하면 간단한 문법으로 문서에 여러 형태로 표시할 수 있다. 여기서는 마크다운 문법을 간단히 설명하고, 파이보에 마크다운 기능을 적용하는 방법까지 알아보겠다.
Flask-Markdown 설치하기
pip install Flask-Markdown 명령을 실행하여 Flask-Markdown 을 설치한다.
마크다운 기능 등록하기
__init__.py
파일에서 app에 등록하자.
pybo/__init__.py
(...생략...)
from flaskext.markdown import Markdown
(...생략...)
def create_app():
(...생략...)
# markdown
Markdown(app, extensions=['nl2br', 'fenced_code'])
return app
마크다운에는 몇 가지 확장 기능이 있는데 여기서는 마크다운 문법을 편하게 사용할 수 있도록 만들어 주는 nl2br와 fenced_code를 사용했다. nl2br는 줄바꿈 문자를 <br>
로 바꿔준다. 만약 이 확장 기능을 사용하지 않으면 원래 마크다운 문법인 스페이스를 2개 연속으로 입력해야 줄바꿈을 할 수 있다. fenced_code는 코드 표시 기능을 위해 추가했다.
pybo/templates/question/question_detail.html
(...생략...)
<div class="card-text">{{question.content|markdown}}</div>
(...생략...)
pybo/templates/question/question_detail.html
(...생략...)
<div class="card-text">{{answer.content|markdown}}</div>
(...생략...)
여기서는 파이보에 검색 기능과 정렬 기능을 추가할 것이다. 파이보는 질문, 답변 데이터가 계속 쌓이는 게시판 서비스이므로 검색 기능은 필수다. 검색 대상은 '제목', '질문 내용', '답변 내용', '질문 작성자', '답변 작성자'로 정한다. 예를 들어 '파이썬'이라고 검색하면 '파이썬' 이라는 문자열이 '제목', '질문 내용', '답변 내용', '질문 작성자', '답변 작성자' 에 있는지 검사하고, 검사 결과를 화면에 보여 준다.
검색 기능을 완성한 다음에는 최신순, 추천순, 인기순과 같은 정렬 기능도 만들어 볼 것이다. 우선 검색 기능을 위해 잠시 데이터베이스 지식을 공부할 필요가 있다. 여기서는 그중에서 조인, 아우터조인, 서브쿼리를 공부하려고 한다. 참고로 검색과 정렬은 이 책에서 다루는 가장 어려운 개념이다. 차분한 마음으로 공부해보자.
조인은 같은 데이터로 연결된 두 모델을 함께 조회할 때 사용한다. 작성자 이름이 '홍길동'인 질문을 검색하는 상황을 예로 들어 조인을 구체적으로 설명하려고 한다. 보통 다음과 같은 절차가 떠오를 것이다.
절차1. User 모델에서 username이 '홍길동'인 데이터의 id 조사하기
절차2. 절차1에서 조사한 id와 Question 모델의 user_id가 같은 데이터인지 조사하기
절차대로 코드를 작성하면 다음과 같다.
user=user.query.filter(User.username=='홍길동').first()
Question.query.filter(Question.user_id==user.id)
하지만 조인을 사용하면 다음과 같이 간편하게 검색할 수 있다.
Question.query.join(User).filter(User.username=='홍길동')
>>> from pybo.models import Question, Answer
>>> Question.query.count()
304
>>> Answer.query.count()
8
>>> Question.query.join(Answer).count()
8
아우터조인을 설명하기 전에 잠시 생각해야 할 내용이 있다. 만약 "파이썬" 이라는 문자열을 포함한 질문 내용 또는 답변 내용을 찾아 질문 목록을 조회하고 싶다면, 앞에서 배운 조인을 사용하면 안 된다. 왜냐면 답변이 없는 질문은 모두 제외하기 때문이다. 비록 답변이 없는 질문이라도 질문 내용에 "파이썬"이 포함되어 있으면 검색 결과에 포함되어야 상식적이다. 그러면 조인을 사용하면 안 될까? 아니다. 왜냐하면 질문에는 "파이썬"이 없지만 답변에는 "파이썬"이 있는 질문 목록을 조회하려면 반드시 조인을 사용해야 하기 때문이다. 이런 모순을 해결할 수 있는 방법이 없을까? 아니다. 바로 아우터조인으로 이 문제를 해결할 수 있다!
>>> print(Question.query.outerjoin(Answer).count())
308
흥미로운 결과가 나타났다. 질문 개수인 306보다 더 큰 숫자인 308이 나타났기 때문이다. 2가 증가한 값이 나타난 이유는 '질문'에 등록된 답변이 1개 이상인 경우가 있기 때문이다. 총 질문 개수인 306에 답변 개수가 1 이상인 데이터 2개가 더해져 총 308이 나왔다.
>>> print(Question.query.outerjoin(Answer).distinct().count())
304
distinct 함수를 사용하면 중복 데이터가 제거되므로 데이터는 306개 검색된다. 이처럼 아우터조인은 조인 대상인 모델(Answer)이 없어도 기준 모델(Question)의 데이터가 제외되지 않는다.
>>> print(Question.query.outerjoin(Answer).filter(
... Question.content.ilike('%마크다운%') |
... Answer.content.ilike('%마크다운%')).distinct().count())
1
아우터조인을 하면 조인 대상인 Answer 모델로 검색할 수 있는 효과가 있다.
Question 모델과 연결된 다른 모델을 검색하려면 Answer, User 모델을 조인해야 한다. 하지만 이렇게 모델 자체를 사용하기보다는 필요한 데이터만 모아서 조회하는 서브쿼리를 만들어 조인해야 할 필요도 있다. 왜 서브쿼리를 사용해야 하는지는 차근차근 알아보자.
답변 작성자를 검색 조건에 포함하려면?
파이보의 검색 기능을 구현하려면 답변 내용뿐 아니라 답변 작성자도 검색 조건에 포함해야 한다. 답변 내용 검색은 아우터조인에서 본 것처럼 Answer 모델을 아우터조인하면 쉽게 해결할 수 있지만, 답변 작성자를 조건에 추가하기는 쉽지 않다. 왜냐하면 답변 작성자는 이미 아우터조인한 Answer 모델과 User 모델을 다시 한번 더 조인해야 하기 때문이다. 이런 복잡한 경우는 다음과 같이 서브쿼리를 사용하는 것이 가독성과 성능 면에서 유리하다. 서브쿼리는 db.session.query로 만든 쿼리에 subquery 함수를 실행하여 만든다.
sub_query = db.session.query(Answer.question_id, Answer.content, User.username).join(User, Answer.user.id == User.id).subquery()
이 서브쿼리는 답변 모델과 사용자 모델을 조인하여 만든 것으로, 검색 조건에 사용할 답변 내용 Answer.content과 답변 작성자 User.username가 쿼리 조회 항목으로 추가되었다. 그리고 이 서브쿼리와 질문 모델을 연결할 수 있는 질문 id에 해당하는 Answer.question_id도 조회 항목에 추가되었다. 이처럼 서브쿼리를 생성하면 다음과 같이 Question 모델과 서브쿼리를 아우터조인할 수 있다.
Question.query.outerjoin(sub_query.c.question_id == Question.id).distinct()
sub_query.c.question_id에 사용한 c는 서브쿼리의 조회 항목을 의미한다. 즉 sub_query.c.question_id는 서브쿼리의 조회 항목 중 question_id를 의미한다. 이제 sub_query를 아우터조인했으므로 sub_query의 조회 항목을 filter 함수에 조건으로 추가할 수 있다.
Question.query.outerjoin(sub_query.c.question_id == Question.id).filter(sub_query.c.content.ilike('%파이썬%') | sub_query.c.username.ilike('%파이썬%')).distinct()
이 코드로 답변 내용 또는 답변 작성자 이름에서 '파이썬' 문자열을 포함하는 질문 목록을 조회할 수 있다. 다음과 같이 지금까지 살펴본 내용을 적용한 코드를 눈으로만 살펴보자.
kw = request.args.get('kw', type=str, default='') #검색어
# 검색
search = '%%{}%%'.format(kw)
sub_query = db.session.query(Answer.question_id, Answer.content, User.username).join(User, Answer.user_id == User.id).subquery()
question_list = Question.query.join(User).outerjoin(sub_query,sub_query.c.question_id == Question.id)
.filter(Question.subject.ilike(search) | #질문 제목
Question.content.ilike(search) | #질문 내용
Question.content.ilike(search) | #질문 작성자
User.username.ilike(search) | #질문 작성자
sub_query.c.content.ilike(search) | #답변 내용
sub_query.c.username.ilike(search) | #답변 작성자
).distinct()
POST 방식으로 검색, 페이징 기능을 만들면 웹 브라우저에서 '새로고침'또는 '뒤로가기'를 했을 때 '만료된 페이지' 오류를 종종 만난다. POST방식은 같은 POST 요청이 발생하면 중복을 방지하기 때문이다. 예를 들어 2페이지에서 3페이지로 갔다가 '뒤로가기'를 하면 2페이지로 갈 때 '만료된 페이지' 오류를 만난다 이러한 이유로 게시판을 조회하는 목록 함수는 GET 방식을 사용해야 한다. 정렬 기능 역시 GET방식으로 구현한다.
pybo/templates/question/question_list/html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<div class="row justify-content-end my-3">
<div class="col-4 input-group">
<input type="text" class="form-control kw" value="{{kw or ''}}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
</div>
</div>
</div>
<table class="table">
(...생략...)
pybo/templates/question/question_list.html
(...생략...)
<!-- 페이징 처리 끝 -->
<a href="{{ url_for('question.create') }}" class="btn btn-primary">질문 등록하기</a>
</div>
<form id="searchForm" method="get" action="{{url_for('question._list')}}">
<input type="hidden" id="kw" name="kw" value="{{ kw or ''}}">
<input type="hidden" id="page" name="page" value="{{page}}">
</form>
{% endblock %}
(기존 코드)
<a class="page-link" href="?page={{question_list.prev_num}}">이전</a>
(수정한 코드)
<a class="page-link" data-page="{{question_list.prev_num}}" href="#">이전</a>
pybo/templates/question/question_list.html
(...생략...)
<!-- 페이징 처리 시작 -->
<ul class="pagination justify-content-center">
<!--이전 페이지-->
{% if question_list.has_prev %}
<li class="page-item">
<a class="page-link" data-page="{{question_list.prev_num}}" href="#">이전</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" tabindex="-1" aria-disabled="true" href="#">이전</a>
</li>
{% endif %}
{% for page_num in question_list.iter_pages() %}
{% if page_num %}
{% if page_num != question_list.page %}
<li class="page-item">
<a class="page-link" data-page="{{page_num}}" href="#">{{page_num}}</a>
</li>
{% else %}
<li class="page-item active" aria-current="page">
<a class="page-link" href="#">{{page_num}}</a>
</li>
{% endif %}
{% else %}
<li class="disabled">
<a class="page-link" href="#">...</a>
</li>
{% endif %}
{% endfor %}
<!--다음 페이지-->
{% if question_list.has_next %}
<li class="page-item">
<a class="page-link" data-page="{{question_list.next_num}}" href="#">다음</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" tabindex="-1" aria-disabled="true" href="#">다음</a>
</li>
{% endif %}
</ul>
<!-- 페이징 처리 끝 -->
(...생략...)
pybo/templates/question/question_list.html
{% endblock %}
{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
$(".page-link").on('click',function(){
$("#page").val($(this).data("page"));
$("#searchForm").submit();
});
$("#btn_search").on('click',function(){
$("#kw").val($(".kw").val());
$("#page").val(1);
$("#searchForm").submit();
});
});
</script>
{% endblock %}
class 속성이 "page-link"인 링크를 누르면 이 링크의 data-page 속성값을 읽어 searchForm의 page 필드에 그 값을 설정하여 폼을 요청하도록 했다. 또한 <검색> 버튼을 누르면 검색 창에 입력된 값을 searchForm의 kw필드에 설정하여 폼을 요청하도록 했다. 이때 검색 버튼을 누르는 경우는 검색 요청에 해당하므로 searchForm의 page필드에 항상 1을 설정하여 폼을 요청하도록 했다.
from ..models import Question, Answer, User
(...생략...)
@bp.route('/list/')
def _list():
# 입력 파라미터
page = request.args.get('page', type=int, default=1)
kw = request.args.get('kw', type=str, default='')
#조회
question_list = Question.query.order_by(Question.create_date.desc())
if kw:
search = '%%{}%%'.format(kw)
sub_query = db.session.query(
Answer.question_id, Answer.content, User.username).join(
User, Answer.user_id == User.id).subquery()
question_list = question_list.join(User).outerjoin(sub_query, sub_query.c.question_id == Question.id).filter(
Question.subject.ilike(search) | #질문 제목
Question.content.ilike(search) | #질문 내용
User.username.ilike(search) | #질문 작성자
sub_query.c.content.ilike(search) | #답변 내용
sub_query.c.username.ilike(search) #답변 작성자
).distinct()
#페이징
question_list = question_list.paginate(page, per_page=10)
return render_template('question/question_list.html', question_list=question_list, page=page, kw=kw)
(...생략...)
pybo/templates/question/question_list.html
{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
<div class="row justify-content-between my-3">
<div class="col-2">
<select class="form-control so">
<option value="recent" {% if so == 'recent' %}selected{% endif %}>최신순</option>
<option value="recommend" {% if so == 'recommend' %}selected{% endif %}>추천순</option>
<option value="popular" {% if so == 'popular' %}selected{% endif %}>인기순</option>
</select>
</div>
<div class="col-4 input-group">
<input type="text" class="form-control kw" value="{{kw or ''}}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
</div>
</div>
pybo/templates/question/question_list.html
(...생략...)
<form id="searchForm" method="get" action="{{url_for('question._list')}}">
<input type="hidden" id="kw" name="kw" value="{{ kw or ''}}">
<input type="hidden" id="page" name="page" value="{{page}}">
<input type="hidden" id="so" name="so" value="{{so}}">
</form>
(...생략...)
pybo/templates/question/question_list.html
{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
(...생략...)
$(".so").on('change',function(){
$("#so").val($(this).val());
$("#page").val(1);
$("#searchForm").submit();
})
});
</script>
{% endblock %}
pybo/views/question_views.py
from datetime import datetime
from flask import Blueprint, render_template, request, url_for, g,flash
from werkzeug.utils import redirect
from .. import db
from ..models import Question, Answer, User, question_voter
from ..forms import QuestionForm,AnswerForm
from pybo.views.auth_views import login_required
from sqlalchemy import func
bp = Blueprint('question',__name__,url_prefix='/question')
@bp.route('/list/')
def _list():
# 입력 파라미터
page = request.args.get('page', type=int, default=1)
kw = request.args.get('kw', type=str, default='')
so = request.args.get('so', type=str, default='recent')
#정렬
if so == 'recommend':
sub_query = db.session.query(
question_voter.c.question_id, func.count('*').label('num_voter')).group_by(question_voter.c.question_id).subquery()
question_list = Question.query.outerjoin(
sub_query, Question.id == sub_query.c.question_id).order_by(sub_query.c.num_voter.desc(),Question.create_date.desc())
elif so == 'popular':
sub_query = db.session.query(
Answer.question_id, func.count('*').label('num_answer')).group_by(Answer.question_id).subquery()
question_list = Question.query.outerjoin(sub_query, Question.id == sub_query.c.question_id).order_by(sub_query.c.num_answer.desc(),Question.create_dat.desc())
else: #최근 질문
question_list = Question.query.order_by(Question.create_date.desc())
#조회
if kw:
search = '%%{}%%'.format(kw)
sub_query = db.session.query(
(...생략...)