[오늘의 배움] 030 플라스크

59063 단어 FlaskFlask

1. 로그인/로그아웃 구현

1-1. 로그인 폼

class UserLoginForm(FlaskForm):
    username = StringField('사용자이름', validators=[DataRequired(), Length(min=3, max=25)])
    password = PasswordField('비밀번호', validators=[DataRequired()])

1-2. 로그인 라우트 함수

@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)

POST 방식 요청에는 로그인을 수행하고, GET 방식 요청에는 로그인 템플릿을 렌더링한다. 로그인 과정은 폼 입력으로 받은 username이 DB에 있는지 확인하고, 없다면 오류를 발생시킨다. 존재한다면 폼 입력으로 받은 password와 check_password_hash 함수를 사용해 DB에 있는 암호화된 비밀번호와 일치하는지 비교한다. username이 존재하고 password도 맞다면 플라스크 session에 키와 키값을 저장한다 (session['user_id'] = user.id).

세션

  • 서버 측에서 관리, 쿠키 사용
  • 클라이언트 구분을 위해 세션 ID를 부여하고 클라이언트 종료시까지 인증상태 유지
  • 시간제한이 있어 일정 시간 미접속 시 자동으로 삭제
  • 쿠키보다 보안이 좋지만 사용자가 많아질수록 서버 메모리 차지
    클라이언트 서버 접속 시 세션 ID 발급, 클라이언트는 세션 ID를 쿠키를 사용해 저장. 클라이언트 서버 요청 시 세션 ID를 전달. 서버 세션 ID로 세션에 있는 클라이언트 정보 가져옴. 클라이언트 정보를 가지고 서버 요청을 처리하여 클라이언트에 응답.
    ex) 로그인

쿠키

  • 클라이언트 로컬에 저장되는 키와 값이 들어있는 데이터 파일
  • 유효 시간을 명시할 수 있고, 브라우저가 종료되어도 인증 유지 가능
  • 클라이언트 상태 정보를 로컬에 저장했다가 참조
    클라이언트 요청시 서버는 쿠키를 생성해 전송, 클라이언트는 쿠키를 저장. 이후 서버에 요청할 때 쿠키를 같이 전송, 서버가 전송 받은 쿠키와 이전에 전송한 쿠키를 비교, 클라이언트를 구분.
    ex) 로그인 페이지 아이디 비밀번호 저장, 팝업 오늘 더 이상 이 창을 보지 않음 체크

쿠키와 세션은 HTTP의 특징인 connectionless, stateless로 인한 클라이언트 구분 문제를 해결하기 위한 방법으로 비슷한 역할을 한다. 둘의 가장 큰 차이점은 정보 저장되는 위치이다.

1-3. 로그인 템플릿

{% 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 %}

로그인 버튼을 누르면 form 엘리먼트가 /auth/login/ URL을 통해 POST 방식으로 요청

1-4. 로그인 사용자 정보 조회 함수

@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 애너테이션이 적용된 함수는 라우트 함수보다 먼저 실행된다. g는 플라스크가 제공하는 컨텍스트 변수로 request와 마찬가지로 요청->응답 과정에서 유효하다.

1-5. 로그아웃 구현

@bp.route('/logout/')
def logout():
    session.clear()
    return redirect(url_for('main.index'))

session.clear()는 세션의 모든 값을 삭제한다. 여기선 user_id가 세션에서 삭제되고, user_id를 읽을 수 없어 g.user도 None이 된다.


2. 수정 삭제

2-1. 수정 버튼

{% 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 %}

세션 유저와 글쓴이가 같은 경우에만 수정 버튼을 표시한다

2-2. 수정 라우트 함수

@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:
        form = QuestionForm(obj=question)
    return render_template('question/question_form.html', form=form)

GET 방식으로 요청시 QuestionForm(obj=question)으로 글 제목과 내용을 폼에 담아 렌더링한다. POST 방식으로 요청시 폼 검증 후 form.populate_obj(question)로 데이터를 question 객체에 적용하고 저장한다. db.session.commit()에서 session은 플라스크 세션이 아닌 SQLALCHEMY의 세션으로 트랜젝션 비슷한 것이다.

2-3. 삭제 버튼

   <a href="#" class="delete btn btn-sm btn-outline-secondary"
        data-uri="{{ url_for('question.delete', question_id=question.id) }}">삭제</a>
<script type='text/javascript'>
$(document).ready(function(){
    $(".delete").on('click', function() {  // 클래스값이 delete인 엘리먼트가 눌리면?
        if(confirm("정말로 삭제하시겠습니까?")) {  // 확인 창이 열림
            location.href = $(this).data('uri');  // data-uri 속성값으로 URL 호출
        }
    });
});
</script>

삭제 버튼 클릭 시 확인 창을 띄우기 위해 jQuery를 사용한다

{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
    $(".delete").on('click', function() {    //클래스값이 delete인 엘리먼트가 눌리면
        if(confirm("정말로 삭제하시겠습니까?")) {    // 확인 창이 열림
            location.href = $(this).data('uri');    //data-uri 속성값으로 URL 호출
        }
    });
});
</script>
{% endblock %}

$(document).ready 함수는 화면이 표시된 이후 자동으로 호출되는 jQuery 함수이다.
{% block content %}{% endblock %}으로 상속받은 템플릿이 블록을 구현한것 처럼,
{% block script %}{% endblock %}로 스크립트 블록을 구현할 수 있다.

2-4. 삭제 라우트 함수

@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'))

3. 댓글

3-1. 댓글 모델 추가

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'))

3-2. 템플릿 댓글 영역 추가

 {% 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>

div class="comment"는 별도로 구현해야할 css 클래스이다.

.comment {
    border-top:dotted 1px #ddd;
    font-size:0.7em;
}

3-3. 댓글 폼

class CommentForm(FlaskForm):
    content = TextAreaField('내용', validators=[DataRequired()])

3-4. 댓글 등록 라우트 함수

@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)

question에 달린 댓글이기 때문에 Comment 모델 인스턴스를 생성할 때 question 필드에 값을 설정한다.

3-5. 댓글 작성 템플릿

{% extends 'base.html' %}
{% block content %}
<div class="container my-3">
    <h5 class="border-bottom pb-2">댓글등록하기</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 %}

3-6. 댓글 수정 라우트 함수

@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 방식으로 요청 시 폼에서 받은 내용으로 업데이트한다.

3-7. 댓글 삭제 라우트 함수

@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))

좋은 웹페이지 즐겨찾기