캡스톤 프로젝트 4 (블로그에 사용자 추가)

JOOYEUN SEO·2024년 12월 3일

100 Days of Python

목록 보기
69/76
post-thumbnail

🗂️ Day69 프로젝트: 블로그에 사용자 추가

🗂️ Day67 프로젝트: 블로그 RESTful 라우팅에서 사용자 인증을 업데이트하여 블로그 프로젝트 완성

1. 신규 사용자 등록

🔍 유의 사항

  • 사용자가 /register 경로로 이동해서 블로그 웹 사이트에 등록할 수 있도록 만들기
  • forms.py에 RegisterForm이라는 WTForm 생성 후,
    플라스크 부트스트랩을 사용하여 wtf quick_form을 렌더링
  • 사용자가 입력한 데이터가 User 테이블의 blog.db에 새 테이블로 추가되어야 한다
    (이메일, 이름, 해싱된 비밀번호)

2. 등록된 사용자 로그인

🔍 유의 사항

  • 등록을 마친 사용자는 로그인 페이지에서 로그인 가능하도록 설정하기
    • 등록을 성공적으로 완료된 사용자를 홈페이지로 리디렉션
    • 등록/로그인 전과 후의 네비게이션 바의 항목이 다르게 표시되어야 함
  • 플래시 메시지로 피드백
    • 데이터베이스에 이미 있는 이메일로 등록을 시도하는 경우
    • 데이터베이스에 없는 이메일로 로그인을 시도하거나 비밀번호가 일치하지 않는 경우
  • 로그아웃 버튼을 클릭하면 로그아웃 후 홈페이지로 이동하도록 /logout 경로 수정

3. 경로 보호

🔍 유의 사항

  • 최초 등록한 사용자가 관리자가 되어 새 게시물을 생성하거나 게시물 수정 및 삭제 가능
    • 최초 사용자의 id는 1
    • 관리자에게만 새 게시물 생성, 게시물 수정, 게시물 삭제 버튼이 보이도록 변경하기
  • 다른 사용자가 수동으로 경로에 접근하지 못하도록 @admin_only로 해당 라우트 보호

4. 관계형 데이터베이스 만들기

🔍 유의 사항

  • 사용자가 생성한 게시물을 해당 사용자와 데이터베이스에서 연결시키기
    • user 테이블과 blog_post 테이블 간의 관계 생성
    • 한 사용자(부모)가 여러 게시물 객체(자식)를 생성할 수 있는 일대다 관계
  • ForeignKey, relationship() 메서드 사용
  • 테이블 연결 후 BlogPost에 새 열이 추가됐기 때문에 기존 blog.db 파일을 삭제해야 함
  • 블로그 포스트에 사라진 작성자 이름이 나타나도록 수정

5. 모든 사용자가 블로그 게시물에 댓글을 추가할 수 있도록 하기

🔍 유의 사항

  • 사용자들이 게시물에 댓글을 작성할 수 있도록 CommentForm 생성
  • 어느 사용자든 게시물에 댓글을 달 수 있도록 새로운 테이블 생성
    • 이름이 comments인 Comment 테이블
    • id와 text 속성으로 CKEditor에 기본 키와 텍스트가 들어가게 하기
  • 관계 설정
    • User 객체(부모)와 Comment 객체(자식) 간에 일대다 관계 설정
    • BlogPost 객체(부모)와 Comment 객체(자식) 간에 일대다 관계 설정
  • 로그인된 사용자만 댓글을 남길 수 있도록 /post/<int:post_id> 라우트 업데이트
  • post.html 파일을 수정해서 게시물에 달린 모든 댓글을 출력하기
  • 댓글을 단 사용자의 프로필 이미지를 Gravatar로 추가
    (Flask 구버전에서만 동작하므로 2.x 버전으로 다운그레이드 해야 함)

⌨️ main.py

from datetime import date
from flask import Flask, abort, render_template, redirect, url_for, flash
from flask_bootstrap import Bootstrap5
from flask_ckeditor import CKEditor
from flask_gravatar import Gravatar
from flask_login import UserMixin, login_user, LoginManager, current_user, logout_user
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import relationship, DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String, Text
from functools import wraps
from werkzeug.security import generate_password_hash, check_password_hash
# forms.py에서 만든 form을 임포트
from forms import CreatePostForm, RegisterForm, LoginForm, CommentForm

app = Flask(__name__)
app.config['SECRET_KEY'] = '8BYkEfBA6O6donzWlSihBXox7C0sKR6b'
ckeditor = CKEditor(app)
Bootstrap5(app)

# Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))


# 관리자만 접근 가능한 데코레이터 생성
def admin_only(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        # id가 1이 아니면 403 에러 반환
        if current_user.id != 1:
            return abort(403)
        # 관리자가 맞을 경우 라우트 계속 사용 가능
        return f(*args, **kwargs)
    return decorated_function


# CREATE DATABASE
class Base(DeclarativeBase):
    pass
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///blog.db'
db = SQLAlchemy(model_class=Base)
db.init_app(app)

# CONFIGURE TABLES
class User(UserMixin, db.Model):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
    name: Mapped[str] = mapped_column(String(100), nullable=False)
    password: Mapped[str] = mapped_column(String(100), nullable=False)

    # User 객체와 BlogPost 객체에서 부모 관계
    posts: Mapped[list["BlogPost"]] = relationship(back_populates="author")

    # User 객체와 Comment 객체에서 부모 관계
    comments: Mapped[list["Comment"]] = relationship(back_populates="comment_author")

class BlogPost(db.Model):
    __tablename__ = "blog_posts"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)

    # User 객체와 BlogPost 객체에서 자식 관계
    author_id: Mapped[int] = mapped_column(db.ForeignKey("users.id"))
    author: Mapped["User"] = relationship(back_populates="posts")

    title: Mapped[str] = mapped_column(String(250), unique=True, nullable=False)
    subtitle: Mapped[str] = mapped_column(String(250), nullable=False)
    date: Mapped[str] = mapped_column(String(250), nullable=False)
    body: Mapped[str] = mapped_column(Text, nullable=False)
    img_url: Mapped[str] = mapped_column(String(250), nullable=False)

    # BlogPost 객체와 Comment 객체에서 부모 관계
    comments: Mapped[list["Comment"]] = relationship(back_populates="parent_post")

class Comment(db.Model):
    __tablename__ = "comments"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)

    # User 객체와 Comment 객체에서 자식 관계
    author_id: Mapped[int] = mapped_column(db.ForeignKey("users.id"))
    comment_author: Mapped["User"] = relationship(back_populates="comments")

    # BlogPost 객체와 Comment 객체에서 자식 관계
    post_id: Mapped[int] = mapped_column(db.ForeignKey("blog_posts.id"))
    parent_post: Mapped["BlogPost"] = relationship(back_populates="comments")

    text: Mapped[str] = mapped_column(Text, nullable=False)

with app.app_context():
    db.create_all()


# 댓글 사용자의 프로필 이미지 추가
gravatar = Gravatar(
    app, size=100, rating='g', default='retro', force_default=False, force_lower=False, use_ssl=False, base_url=None
)

# Use Werkzeug to hash the user's password when creating a new user
@app.route('/register', methods=["GET", "POST"])
def register():
    form = RegisterForm()
    if form.validate_on_submit():

        if User.query.filter_by(email=form.email.data).first():
            flash("You've already signed up with that email, log in instead!")
            return redirect(url_for('login'))

        hash_and_salted_password = generate_password_hash(
            form.password.data,
            method='pbkdf2:sha256',
            salt_length=8
        )

        new_user = User(
            email=form.email.data,
            name=form.name.data,
            password=hash_and_salted_password
        )
        db.session.add(new_user)
        db.session.commit()

        # 해당 유저를 Flask-Login으로 인증
        login_user(new_user)
        return redirect(url_for("get_all_posts"))
    return render_template("register.html", form=form)


# Retrieve a user from the database based on their email.
@app.route('/login', methods=["GET", "POST"])
def login():
    form = LoginForm()
    if form.validate_on_submit():
        email = form.email.data
        password = form.password.data

        user = User.query.filter_by(email=email).first()

        # 입력한 이메일이 존재하지 않음
        if not user:
            flash("That email does not exist, please try again.")
            return redirect(url_for('login'))
        # 비밀번호가 틀림
        elif not check_password_hash(user.password, password):
            flash('Password incorrect, please try again.')
            return redirect(url_for('login'))
        else:
            login_user(user)
            return redirect(url_for('get_all_posts'))
    return render_template("login.html", form=form)


@app.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('get_all_posts'))


@app.route('/')
def get_all_posts():
    result = db.session.execute(db.select(BlogPost))
    posts = result.scalars().all()
    return render_template("index.html", all_posts=posts)


@app.route("/post/<int:post_id>", methods=["GET", "POST"])
def show_post(post_id):
    comment_form = CommentForm()
    requested_post = db.get_or_404(BlogPost, post_id)

    if comment_form.validate_on_submit():
        if not current_user.is_authenticated:
            flash("You need to login or register to comment.")
            return redirect(url_for("login"))

        new_comment = Comment(
            text=comment_form.comment_text.data,
            comment_author=current_user,
            parent_post=requested_post
        )
        db.session.add(new_comment)
        db.session.commit()

    return render_template("post.html", post=requested_post, form=comment_form)


@app.route("/new-post", methods=["GET", "POST"])
@admin_only
def add_new_post():
    form = CreatePostForm()
    if form.validate_on_submit():
        new_post = BlogPost(
            title=form.title.data,
            subtitle=form.subtitle.data,
            body=form.body.data,
            img_url=form.img_url.data,
            author=current_user,
            date=date.today().strftime("%B %d, %Y")
        )
        db.session.add(new_post)
        db.session.commit()
        return redirect(url_for("get_all_posts"))
    return render_template("make-post.html", form=form)


@app.route("/edit-post/<int:post_id>", methods=["GET", "POST"])
@admin_only
def edit_post(post_id):
    post = db.get_or_404(BlogPost, post_id)
    edit_form = CreatePostForm(
        title=post.title,
        subtitle=post.subtitle,
        img_url=post.img_url,
        author=post.author,
        body=post.body
    )
    if edit_form.validate_on_submit():
        post.title = edit_form.title.data
        post.subtitle = edit_form.subtitle.data
        post.img_url = edit_form.img_url.data
        post.author = current_user
        post.body = edit_form.body.data
        db.session.commit()
        return redirect(url_for("show_post", post_id=post.id))
    return render_template("make-post.html", form=edit_form, is_edit=True)


@app.route("/delete/<int:post_id>")
@admin_only
def delete_post(post_id):
    post_to_delete = db.get_or_404(BlogPost, post_id)
    db.session.delete(post_to_delete)
    db.session.commit()
    return redirect(url_for('get_all_posts'))


@app.route("/about")
def about():
    return render_template("about.html")


@app.route("/contact")
def contact():
    return render_template("contact.html")


if __name__ == "__main__":
    app.run(debug=True)

🏗️ header.html

<!DOCTYPE html>
<html lang="en">
  <head 💬 >
  <body>
    <!-- Navigation-->
    <nav class="navbar navbar-expand-lg navbar-light" id="mainNav">
      <div class="container px-4 px-lg-5">
        <a class="navbar-brand" href="/">Start Bootstrap</a>
        <button
          class="navbar-toggler"
          type="button"
          data-bs-toggle="collapse"
          data-bs-target="#navbarResponsive"
          aria-controls="navbarResponsive"
          aria-expanded="false"
          aria-label="Toggle navigation"
        >
          Menu
          <i class="fas fa-bars"></i>
        </button>
        <div class="collapse navbar-collapse" id="navbarResponsive">
          <ul class="navbar-nav ms-auto py-4 py-lg-0">
            <li class="nav-item">
              <a
                class="nav-link px-lg-3 py-3 py-lg-4"
                href="{{ url_for('get_all_posts') }}"
                >Home</a
              >
            </li>

            {% if not current_user.is_authenticated: %}
              <li class="nav-item">
                <a
                  class="nav-link px-lg-3 py-3 py-lg-4"
                  href="{{ url_for('login') }}"
                  >Login</a
                >
              </li>
              <li class="nav-item">
                <a
                  class="nav-link px-lg-3 py-3 py-lg-4"
                  href="{{ url_for('register') }}"
                  >Register</a
                >
              </li>
            {% else %}
              <li class="nav-item">
                <a
                  class="nav-link px-lg-3 py-3 py-lg-4"
                  href="{{ url_for('logout') }}"
                  >Log Out</a
                >
              </li>
            {% endif %}

            <li class="nav-item">
              <a
                class="nav-link px-lg-3 py-3 py-lg-4"
                href="{{ url_for('about') }}"
                >About</a
              >
            </li>
            <li class="nav-item">
              <a
                class="nav-link px-lg-3 py-3 py-lg-4"
                href="{{ url_for('contact') }}"
                >Contact</a
              >
            </li>
          </ul>
        </div>
      </div>
    </nav>
  </body>
</html>

🏗️ footer.html

        <!-- Footer-->
        <footer class="border-top" 💬 >
      <!-- Bootstrap core JS-->
      <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
      <!-- Core theme JS-->
      <script src="{{ url_for('static', filename='js/scripts.js') }}"></script>
  </body>
</html>

🏗️ index.html

{% include "header.html" %}

<!-- Page Header-->
<header 💬 >
<!-- Main Content-->
<div class="container px-4 px-lg-5">
  <div class="row gx-4 gx-lg-5 justify-content-center">
    <div class="col-md-10 col-lg-8 col-xl-7">
      <!-- Post preview-->
      {% for post in all_posts %}
      <div class="post-preview">
        <a href="{{ url_for('show_post', post_id=post.id) }}">
          <h2 class="post-title">{{ post.title }}</h2>
          <h3 class="post-subtitle">{{ post.subtitle }}</h3>
        </a>
        <p class="post-meta">
          Posted by
          <!-- BlogPost의 author는 User 객체가 됩 -->
          {{post.author.name}}
          on {{post.date}}

          <!-- 관리자만 게시물 삭제 버튼에 접근 가능 -->
          {% if current_user.id == 1: %}
            <a href="{{url_for('delete_post', post_id=post.id) }}"></a>
          {% endif %}
        </p>
      </div>
      <!-- Divider-->
      <hr class="my-4" />
      {% endfor %}

      <!-- New Post -->
      <!-- 관리자만 새 게시물 생성 버튼에 접근 가능 -->
      {% if current_user.id == 1: %}
        <div class="d-flex justify-content-end mb-4">
          <a
            class="btn btn-primary float-right"
            href="{{url_for('add_new_post')}}"
            >Create New Post</a
          >
        </div>
      {% endif %}

      <!-- Pager-->
      <div class="d-flex justify-content-end mb-4" 💬 >
    </div>
  </div>
</div>

{% include "footer.html" %}

🏗️ register.html

{% from "bootstrap5/form.html" import render_form %}
{% block content %} {% include "header.html" %}

<!-- Page Header -->
<header 💬 >
<main class="mb-4">
  <div class="container">
    <div class="row">
      <div class="col-lg-8 col-md-10 mx-auto">
        <!--Rendering the registration form here-->
        {{ render_form(form, novalidate=True, button_map={"submit": "primary"}) }}
      </div>
    </div>
  </div>
</main>

{% include "footer.html" %} {% endblock %}

🏗️ login.html

{% from "bootstrap5/form.html" import render_form %} {% block content %} {%
include "header.html" %}

<!-- Page Header -->
<header 💬 >
<main class="mb-4">
  <div class="container">
    <div class="row">
      <div class="col-lg-8 col-md-10 mx-auto">
        <div style="color:red">
          {% with messages = get_flashed_messages() %}
            {% if messages %}
              {% for message in messages %}
                <p>{{ message }}</p>
              {% endfor %}
            {% endif %}
          {% endwith %}
        </div>
        <!--Rendering login form here-->
        {{ render_form(form, novalidate=True, button_map={"submit": "primary"}) }}
      </div>
    </div>
  </div>
</main>

{% include "footer.html" %} {% endblock %}

🏗️ make-post.html

{% from "bootstrap5/form.html" import render_form %}
{% block content %} {% include "header.html" %}

<!-- Page Header -->
<header 💬 >

<main class="mb-4">
  <div class="container">
    <div class="row">
      <div class="col-lg-8 col-md-10 mx-auto">
        {{ ckeditor.load() }}
        {{ ckeditor.config(name='body') }}
        {{ render_form(form, novalidate=True, button_map={"submit": "primary"}) }}
      </div>
    </div>
  </div>
</main>
  
{% include "footer.html" %} {% endblock %}

🏗️ post.html

{% from "bootstrap5/form.html" import render_form %}
{% include "header.html" %}

<!-- Page Header-->
<header class="masthead" style="background-image: url('{{post.img_url}}')">
  <div class="container position-relative px-4 px-lg-5">
    <div class="row gx-4 gx-lg-5 justify-content-center">
      <div class="col-md-10 col-lg-8 col-xl-7">
        <div class="post-heading">
          <h1>{{ post.title }}</h1>
          <h2 class="subheading">{{ post.subtitle }}</h2>
          <span class="meta"
            >Posted by
            {{ post.author.name }}
            on {{ post.date }}
          </span>
        </div>
      </div>
    </div>
  </div>
</header>

<!-- Post Content -->
<article>
  <div class="container px-4 px-lg-5">
    <div class="row gx-4 gx-lg-5 justify-content-center">
      <div class="col-md-10 col-lg-8 col-xl-7">
        {{ post.body|safe }}

        <!-- 관리자만 게시물 수정 버튼에 접근 가능 -->
        {% if current_user.id == 1 %}
        <div class="d-flex justify-content-end mb-4">
          <a
            class="btn btn-primary float-right"
            href="{{url_for('edit_post', post_id=post.id)}}"
            >Edit Post</a
          >
        </div>
        {% endif %}

        <!-- Comments Area -->
        <!-- CKEditor -->
        {{ ckeditor.load() }}
        {{ ckeditor.config(name='comment_text') }}
        {{ render_form(form, novalidate=True, button_map={"submit": "primary"}) }}
        <div class="comment">
          {% for comment in post.comments: %}
          <ul class="commentList">
            <li>
              <div class="commenterImage">
                <img src="{{ comment.comment_author.email | gravatar }}" />
              </div>
              <div class="commentText">
                {{ comment.text|safe }}
                <span class="date sub-text">{{ comment.comment_author.name }}</span>
              </div>
            </li>
          </ul>
          {% endfor %}
        </div>
      </div>
    </div>
  </div>
</article>

{% include "footer.html" %}




▷ Angela Yu, [Python 부트캠프 : 100개의 프로젝트로 Python 개발 완전 정복], Udemy, https://www.udemy.com/course/best-100-days-python/?couponCode=ST3MT72524

0개의 댓글