본문 바로가기

Flask-Study

Ep05 : 블로그 웹 애플리케이션 개발(2) - 테스트 코드 도입 / 관리자 페이지 / 카테고리 / 게시물 / 다루기

스태프 권한을 위한 모델 수정

관리자 페이지를 아무나 들어갈 수 없기 때문에 권한이 있는 사람만 들어갈 수 있도록 아래와 같이 코드를 수정한다.

 

테스트 코드로 지금까지 작성했던 코드 테스트하기

tests폴더를 만들어서 그 안에 tests.py 파일을 만들어 아래와 같이 코드를 작성해 준다.

코드를 보면 setUp()은 테스트를 준비하기 위해서 호출하고 tearDown()은 테스트가 끝난 다음에 호출한다.

setUp()에서는 테스트를 위한 데이터베이스 파일을 만들고 테스트가 끝나면 tearDown()을 호출하여 데이터베이스를 삭제한다.

 

테스트 케이스1 - 우리가 만든 모델이 잘 동작하는가?

 models.py에서 만든 User모델이 잘 작동하는지 확인하기 위해서 유저 모델을 데이터베이스에 넣은 후 잘 저장되어있는지

확인하는 코드이다. assertEqual(User.query.count(), 2)는 유저의 수가 2명인지 체크하는 코드이다. 만약 유저 모델이 잘 동작한다면 테스트를 했을 때 유저가 2명이 맞으므로 OK가 뜨게 된다.

 

테스트 케이스2 - 폼으로 회원가입을 진행해도 데이터베이스에 값이 잘 추가되는가? 

response 뒤의 코드는 실제 웹 페이지에서 회원가입을 하는것과 같다. 그래서 저 요청이 성공적으로 실행되면 auth.py에 있는 signup 부분에서 이메일과 유저네임의 중복을 체크하고 데이터베이스에 넣어지는 것까지 성공적으로 실행되게 된다.

그래서 회원가입이 성공적으로 이뤄지면 assertEqual(User.query.count(), 1)로 체크하게 되어 테스트 결과가 OK가 나오게 된다.

 

테스트 케이스3 - 로그인 전과 로그인 후의 네비게이션 바가 동적으로 바뀌는가?

코드를 보면 로그인 전이면 네이게이션 바에 로그인 버튼, 회원가입 버튼이 있는지 확인한다. 그리고 회원가입과 로그인을 한 다음에 네비게이션 바에 로그인과 회원가입 대신에 로그인한 유저의 이름과 로그아웃이 뜨는지 확인한다. 그래서 테스트를 하면 성공적으로 실행될 경우 OK가 뜨게 된다.

 

flask-admin으로 관리자 페이지 만들기

우선 터미널에 pip install flask-admin을 입력하여 flask-admin을 설치해 준다.

그리고 __init__.py에 아래 내용을 추가해준다.

다음으로 도메인 주소 뒤에 /admin을 붙여서 들어가 보면 관리자 페이지가 만들어진 것을 확인할 수 있다.

 

이제 User 모델을 관리자 페이지에 추가를 할 차례이다. admin.add_view(ModelView(User, db.session)) 코드를 __init__. py에 추가를 해준다. 하지만 그대로 사용하면 순환 참조 에러가 발생하기 때문에 코드를 수정해야 한다.

우선 models.py를 아래와 같이 수정해준다.

 

models.py

from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin   #UserMixin = 로그인에서 수행하는 메소드에 대한 기본 구현을 제공 
from sqlalchemy.sql import func


# init 으로부터 옮김
db = SQLAlchemy()
DB_NAME = "blog_db"


class User(db.Model, UserMixin):
    id = db.Column(db.Integer, primary_key=True)    #유일키
    email = db.Column(db.String(150), unique=True)  #유일한 값 즉, 중복이 없도록    
    username = db.Column(db.String(150), unique = True)
    password = db.Column(db.String(150))
    create_at = db.Column(db.DateTime(timezone=True), default=func.now()) # 생성일자,기본적으로 현재로 저장
    is_staff = db.Column(db.Boolean(), default = False) # 스태프 권한이 있는 유저인지 아닌지를 판별하는 불리언 필드

# User 클래스를 반환하는 함수 정의
def get_user_model():
    return User

우선 init에 있는 코드를 models.py로 옮긴다. 맨 아래에 User 클래스를 반환하는 함수를 정의 했다. 나중에 다른 곳에서 User 클래스를 사용할 일이 있으면 저 함수를 이용하여 가져오면 된다.

__init__.py는 아래와 같이 수정해준다.

 

__init__.py

from flask import Flask
from .models import DB_NAME, db, get_user_model
from flask_sqlalchemy import SQLAlchemy
from os import path
from flask_login import LoginManager    #flask_login = 로그인 기능을 쉽게 구현할 수 있도록 도와주는 라이브러리
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView

from pprint import pprint

def create_app():
    app = Flask(__name__)
    app.config['SECRET_KEY'] = "IFP"
    
    # DB 설정하기
    app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_NAME}'
    # DB 관련 추가할 설정
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    
     # flask-admin
    app.config['FLASK_ADMIN_SWATCH'] = 'Darkly'
    admin = Admin(app, name='blog',
                  template_mode='bootstrap3')
    
    # flask-admin에 model 추가
    admin.add_view(ModelView(get_user_model(), db.session)) 
    # get_user_model 로 유저 클래스를 가져옴
    db.init_app(app)
    
    from .views import views
    app.register_blueprint(views, url_prefix="/")
    from .auth import auth
    app.register_blueprint(auth, url_prefix="/auth")
    
    #db 생성하는 함수 호출
    create_database(app)
    
    login_manager = LoginManager()  #LoginManager() 객체 생성
    login_manager.login_view = "auth.login"
    #login_manager.login_view -> 만약 로그인하지 않고 보기에 엑세스 하려고 하면 "auth.login"으로 리디렉션
    #만약 login_view 가 설정되어 있지 않으면 401오류와 함께 중단
    
    login_manager.init_app(app) #app 에 login_manager 연결
    
    @login_manager.user_loader  #사용자 정보 조회
    def load_user_by_id(id):
        return get_user_model().query.get(int(id))  #유저 id를 받아와서 그 유저의 정보를 반환
      
    return app

def create_database(app):
    if not path.exists("blog/" + DB_NAME):  #DB 경로가 있으면 호출
        db.create_all(app=app)              #없으면 생성

코드를 보면 User 대신에 get_user_model()을 사용하여 User 클래스를 가져온 것을 확인할 수 있다.

다른 코드들도 User 대신 get_user_model()을 호출하는 방식으로 코드를 수정해 준다.

 

auth.py

from flask_login import login_user, logout_user, current_user, login_required
from flask import Blueprint, render_template, redirect, request, flash, url_for
from .models import db, get_user_model 
from blog.forms import SignupForm, LoginForm
from blog.models import User
from werkzeug.security import generate_password_hash, check_password_hash

auth = Blueprint("auth", __name__)

@auth.route('/login', methods = ['GET', 'POST'])
def login():
    form = LoginForm()
    if request.method == "POST" and form.validate_on_submit():
        password = form.password.data
        user = get_user_model().query.filter_by(email=form.email.data).first()
        
        if user:
            if check_password_hash(user.password, password):    
                #check_password_hash : 입력받은 password와 기존 password를 비교하여 참 거짓 반환
                flash("Logged in!", category='success')
                login_user(user, remember=True) #사용자 정보를 session에 저장
                return redirect(url_for('views.blog_home'))
            else:
                flash("Password is incorrect!", category='error')
        else:
            flash("Email does not exist...", category='error')
    return render_template("login.html", form=form, user=current_user)

@auth.route('/logout')
@login_required 
#특정 요청을 실행하기 전 로그인이 필요한 기능에서 요청을한 사용자가 로그인된 사용자인지 확인한다.
def logout():
    logout_user()
    #flask_login의 함수로 session 정보를 삭제한다.
    return redirect(url_for("views.blog_home"))

@auth.route('/sign-up', methods = ['GET', 'POST'])
def signup():
    form = SignupForm()
    if request.method == "POST" and form.validate_on_submit():  #전송된 폼 데이터의 정합성을 점검
        signup_user = get_user_model()(
            email = form.email.data,    #입력받은 데이터를 변수에 저장
            username = form.username.data,
            password = generate_password_hash(form.password1.data),
        )   #폼으로부터 검증된 데이터 받아오기
    
        email_exists = get_user_model().query.filter_by(email=form.email.data).first()
        username_exists = get_user_model().query.filter_by(username=form.username.data).first()
        #폼에서 받아온 데이터가 데이터베이스에 이미 존재하는지 확인
        #filter_by : 함수에서 ()안에 있는 조건에 맞는 데이터를 반환
        #first()인 경우 첫번째에 매칭되는 데이터만 반환/전부 받아오려면 all() 사용   
        
        if email_exists:
            flash('Email is already in use...', category = 'error')
        elif username_exists:
            flash('Username is already in use...', category = 'error')
        #이메일과 유저이름 중복 검사
        
        else:
            db.session.add(signup_user)
            db.session.commit()
            flash("User Created!")
            return redirect(url_for("views.blog_home"))
        #중복이 아니면 유저의 정보를 추가후 저장, 그리고 home으로 리다이렉트
     
    return render_template("signup.html", form=form, user=current_user)    #GET요청을 보내면 회원가입 템플릿으로 넘어감

 

tests.py

import unittest
from os import path

from flask_login import current_user
from bs4 import BeautifulSoup # pip install BeautifulSoup4

from blog import create_app
from blog import db
import os

from blog.models import get_user_model

basedir = os.path.abspath(os.path.dirname(__file__))
app = create_app()
app.testing = True

'''
회원가입, 로그인, 로그아웃 부분을 테스트
1. 2명의 유저를 데이터베이스에 넣어 본 후, 데이터베이스에 들어간 유저의 수가 총 2명이 맞는지를 확인한다.
2. auth/sign-up 에서 폼을 통해서 회원가입 요청을 보낸 후, 데이터베이스에 값이 잘 들어갔는지를 확인한다.
3. 로그인 전에는 네비게이션 바에 "login", "sign up" 이 보여야 하고, 로그인한 유저 이름과 "logout" 이 표시되면 안 된다.
'''


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

    # 테스트가 끝나고 나서 수행할 것, 테스트를 위한 데이터베이스의 내용들을 모두 삭제한다.
    def tearDown(self):
        os.remove('tests/test.db')
        self.ctx.pop()
        
                
    # 1. 2명의 유저를 데이터베이스에 넣어 본 후, 
    # 데이터베이스에 들어간 유저의 수가 총 2명이 맞는지를 확인한다.
    def test_signup_by_database(self):
        self.user_test_1 = get_user_model()(
            email="hello@example.com",
            username="testuserex1",
            password="12345",
            is_staff=True
        )
        db.session.add(self.user_test_1)
        db.session.commit()

        self.user_test_2 = get_user_model()(
            email="hello2@example.com",
            username="testuserex2",
            password="12345",
        )
        db.session.add(self.user_test_2)
        db.session.commit()
        

        # 데이터베이스에 있는 유저의 수가 총 2명인가?
        self.assertEqual(get_user_model().query.count(), 2)
        #assetEqual은 TestCase 클래스가 제공하는 메소드 중 하나로 
        #위 코드는 데이터베이스의 존재하는 유저들의 수가 2와 같은지를 체크하는 것이다.
        
        db.session.close()
        
        
    # 2. auth/sign-up 에서 폼을 통해서 회원가입 요청을 보낸 후, 
    # 데이터베이스에 값이 잘 들어갔는지를 확인한다.
    def test_signup_by_form(self):
        response = self.client.post('/auth/sign-up',data=dict(email="helloworld@naver.com", 
                username="hello", password1="dkdldpvmvl", password2="dkdldpvmvl"))
        self.assertEqual(1, get_user_model().query.count())
        
        db.session.close() 
    
    # 3. 로그인 전에는 네비게이션 바에 "login", "sig up" 이 보여야 하고, 로그인한 유저 이름과 "logout" 이 표시되면 안 된다.
    def test_before_login(self):
        # 로그인 전이므로, 네비게이션 바에는 "login", "sign up" 이 보여야 한다.
        response = self.client.get('/')
        soup = BeautifulSoup(response.data, 'html.parser')
        navbar_before_login = soup.nav  # nav 태그 선택

        self.assertIn("Login", navbar_before_login.text)  # navbar 안에 "Login" 이 들어있는지 테스트
        self.assertIn("Sign Up", navbar_before_login.text, )  # navbar 안에 "Sign Up" 이 들어있는지 테스트
        self.assertNotIn("Logout", navbar_before_login.text, )  # navbar 안에 "Logout" 이 없는지 테스트

        # 로그인을 하기 위해서는 회원가입이 선행되어야 하므로, 폼에서 회원가입을 진행해 준다.
        response = self.client.post('/auth/sign-up',
                                    data=dict(email="helloworld@naver.com", username="hello", password1="dkdldpvmvl",
                                              password2="dkdldpvmvl"))
        # 이후, auth/login 에서 로그인을 진행해 준다.
        with self.client:
            response = self.client.post('/auth/login',
                                        data=dict(email="helloworld@naver.com", username="hello",
                                                  password="dkdldpvmvl"),
                                        follow_redirects=True)
            soup = BeautifulSoup(response.data, 'html.parser')
            navbar_after_login = soup.nav

            # 로그인이 완료된 후, 네비게이션 바에는 로그인한 유저 이름과 "Logout" 이 표시되어야 한다.
            self.assertIn(current_user.username, navbar_after_login.text)
            self.assertIn("Logout", navbar_after_login.text)
            # 로그인이 완료된 후, 네비게이션 바에는 "Login" 과 "Sign Up" 이 표시되면 안 된다.
            self.assertNotIn("Login", navbar_after_login.text)
            self.assertNotIn("Sign up", navbar_after_login.text)
            
            db.session.close() 


if __name__ == "__main__":
    unittest.main()

위와 같이 코드들을 수정해준 뒤에 관리자 페이지에 들어가면 아래처럼 유저 모델을 관리하는 탭이 생긴 것을 볼 수 있다.

 

카테고리, 게시물 구현 1 : 모델 작성하기

우선 한 카테고리에는 여러 개의 게시물이 포함될 수 있고, 한 유저는 여러 게시물을 작성할 수 있다는 것과 게시물은 작성자를 포함하므로 유저 모델과도 연결되어 있어야 한다는 것을 알아야 한다. 그래서 카테고리-게시물, 유저-게시물은 일대다 관계를 가지는 것도 알고 있어야 한다. 이러한 관계를 ORM을 사용하여 다룰 것이다. 우선 models.py에 게시물과 카테고리 클래스를 아래와 같이 만들어 준다.

코드를 보면 게시글을 나타내는 Post 모델에는 작성자(author_id)가 누구인지 어떤 카테고리(category_id)인지가 나타나야 하기 때문에 User와 Category의 id를 참조하는 코드가 있다. 

 

HTML Form 작성하기

이제 html form을 작성할 차례이다. 입력되어야 할 부분은 제목, 내용, 카테고리가 있다. 생성일자나 작성자는 자동으로 추가되기 때문에 따로 입력하는 곳이 필요하지는 않다.

따로 html 파일을 만들지 않고 기존에 있던 contact.html을 아래와 같이 수정해 준다.

    {% extends 'base.html' %}

    {% block title %}Create a Post{% endblock %}
    
    {% block header %}
    <header class="masthead" style="background-image: url('{{url_for('static', filename = 'assets/img/demo-image-02.jpg')}}')">
            <div class="container position-relative px-4 px-lg-5">
                <div class="row gx-4 gx-lg-5 justify-content-center">
                    <div class="col-md-10 col-lg-8 col-xl-7">
                        <div class="site-heading">
                            <h1>Create a Post</h1>
                            <h2>Post whatever you want!</h2>
                        </div>
                    </div>
                </div>
            </div>
        </header>
    {% endblock %}
    
    {% block content %}
    
        <!-- Main Content-->
        <main class="mb-4">
            <div class="container px-4 px-lg-5">
                <div class="row gx-4 gx-lg-5 justify-content-center">
                    <div class="col-md-10 col-lg-8 col-xl-12">
                        <div class="my-5">
                            <form id="createForm" method="POST">
                                {#                            {{ form.csrf_token }}#}
                                <div style="margin: 20px;">
                                    <input class="form-control" id="title" type="text" placeholder="Post Title"
                                           name="title" style="font-size: 40px;"/>
                                </div>
                                <div style="margin: 20px;">
                                    <textarea class="form-control" id="content" type="text" placeholder="Content"
                                              name="content" style="height: 500px;"></textarea>
    
                                </div>
                                <div style="margin: 20px;" id="category">
                                    <select class="form-control" name="category" required>
                                        <option value="" disabled selected>select a category</option>
    {#                                    <option value="python">python</option>#}
    {#                                    <option value="java">java</option>#}
    {#                                    <option value="rust">rust</option>#}
    {#                                    <option value="typescript">typescript</option>#}
                                    </select>
    
                                </div>
                                <br/>
                                <div style="text-align: center">
                                    <button class="btn btn-primary text-uppercase" id="submitButton" type="submit">
                                        Create
                                    </button>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </main>
    
    
    
    {% endblock %}

 

페이지에 들어가서 실행해 보면 아래와 같은 결과가 나온다.

 

테스트 코드 작성하기

테스트 코드로 검증하고 싶은 부분은 아래와 같다.

 

 

새로운 테스트를 추가하기 위해서 tests.py에 TesPostwithCategory 클래스를 생성하고, 아래와 같이 코드를 수정해 준다.

 

import unittest
from os import path

from flask_login import current_user
from bs4 import BeautifulSoup # pip install BeautifulSoup4

from blog import create_app
from blog import db
import os

from blog.models import get_user_model, get_post_model, get_category_model

basedir = os.path.abspath(os.path.dirname(__file__))
app = create_app()
app.testing = True

'''
회원가입, 로그인, 로그아웃 부분을 테스트
1. 2명의 유저를 데이터베이스에 넣어 본 후, 데이터베이스에 들어간 유저의 수가 총 2명이 맞는지를 확인한다.
2. auth/sign-up 에서 폼을 통해서 회원가입 요청을 보낸 후, 데이터베이스에 값이 잘 들어갔는지를 확인한다.
3. 로그인 전에는 네비게이션 바에 "login", "sign up" 이 보여야 하고, 로그인한 유저 이름과 "logout" 이 표시되면 안 된다.
'''


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

    # 테스트가 끝나고 나서 수행할 것, 테스트를 위한 데이터베이스의 내용들을 모두 삭제한다.
    def tearDown(self):
        os.remove('tests/test.db')
        self.ctx.pop()
        
                
    # 1. 2명의 유저를 데이터베이스에 넣어 본 후, 
    # 데이터베이스에 들어간 유저의 수가 총 2명이 맞는지를 확인한다.
    def test_signup_by_database(self):
        self.user_test_1 = get_user_model()(
            email="hello@example.com",
            username="testuserex1",
            password="12345",
            is_staff=True
        )
        db.session.add(self.user_test_1)
        db.session.commit()

        self.user_test_2 = get_user_model()(
            email="hello2@example.com",
            username="testuserex2",
            password="12345",
        )
        db.session.add(self.user_test_2)
        db.session.commit()
        

        # 데이터베이스에 있는 유저의 수가 총 2명인가?
        self.assertEqual(get_user_model().query.count(), 2)
        #assetEqual은 TestCase 클래스가 제공하는 메소드 중 하나로 
        #위 코드는 데이터베이스의 존재하는 유저들의 수가 2와 같은지를 체크하는 것이다.
        
        db.session.close()
        
        
    # 2. auth/sign-up 에서 폼을 통해서 회원가입 요청을 보낸 후, 
    # 데이터베이스에 값이 잘 들어갔는지를 확인한다.
    def test_signup_by_form(self):
        response = self.client.post('/auth/sign-up',data=dict(email="helloworld@naver.com", 
                username="hello", password1="dkdldpvmvl", password2="dkdldpvmvl"))
        self.assertEqual(1, get_user_model().query.count())
        
        db.session.close() 
    
    # 3. 로그인 전에는 네비게이션 바에 "login", "sig up" 이 보여야 하고, 로그인한 유저 이름과 "logout" 이 표시되면 안 된다.
    def test_before_login(self):
        # 로그인 전이므로, 네비게이션 바에는 "login", "sign up" 이 보여야 한다.
        response = self.client.get('/')
        soup = BeautifulSoup(response.data, 'html.parser')
        navbar_before_login = soup.nav  # nav 태그 선택

        self.assertIn("Login", navbar_before_login.text)  # navbar 안에 "Login" 이 들어있는지 테스트
        self.assertIn("Sign Up", navbar_before_login.text, )  # navbar 안에 "Sign Up" 이 들어있는지 테스트
        self.assertNotIn("Logout", navbar_before_login.text, )  # navbar 안에 "Logout" 이 없는지 테스트

        # 로그인을 하기 위해서는 회원가입이 선행되어야 하므로, 폼에서 회원가입을 진행해 준다.
        response = self.client.post('/auth/sign-up',
                                    data=dict(email="helloworld@naver.com", username="hello", password1="dkdldpvmvl",
                                              password2="dkdldpvmvl"))
        # 이후, auth/login 에서 로그인을 진행해 준다.
        with self.client:
            response = self.client.post('/auth/login',
                                        data=dict(email="helloworld@naver.com", username="hello",
                                                  password="dkdldpvmvl"),
                                        follow_redirects=True)
            soup = BeautifulSoup(response.data, 'html.parser')
            navbar_after_login = soup.nav

            # 로그인이 완료된 후, 네비게이션 바에는 로그인한 유저 이름과 "Logout" 이 표시되어야 한다.
            self.assertIn(current_user.username, navbar_after_login.text)
            self.assertIn("Logout", navbar_after_login.text)
            # 로그인이 완료된 후, 네비게이션 바에는 "Login" 과 "Sign Up" 이 표시되면 안 된다.
            self.assertNotIn("Login", navbar_after_login.text)
            self.assertNotIn("Sign up", navbar_after_login.text)
            
            db.session.close()
             
class TestPostwithCategory(unittest.TestCase):
    # 테스트를 위한 사전 준비
    def setUp(self):
        self.ctx = app.app_context()
        self.ctx.push()
        self.client = app.test_client()
        # 테스트를 위한 db 설정
        self.db_uri = 'sqlite:///' + os.path.join(basedir, 'test.db')
        app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
        app.config['TESTING'] = True
        app.config['WTF_CSRF_ENABLED'] = False
        app.config['SQLALCHEMY_DATABASE_URI'] = self.db_uri
        if not path.exists("tests/" + "test_db"):  # DB 경로가 존재하지 않는다면,
            db.create_all(app=app)  # DB를 하나 만들어낸다.

    # 테스트가 끝나고 나서 수행할 것, 테스트를 위한 데이터베이스의 내용들을 모두 삭제한다.
    def tearDown(self):
        os.remove('tests/test.db')
        self.ctx.pop()
    
    '''
    1. 임의의 카테고리를 넣어본 후, 데이터베이스에 카테고리가 잘 추가되어 있는지 확인한다.
    2. 카테고리를 넣은 후, /categories-list 에 접속했을 때, 넣었던 카테고리들이 잘 추가되어 있는지 확인한다. 
    3. 게시물을 작성할 때에, 로그인하지 않았다면 접근이 불가능해야 한다.
    4. 임의의 카테고리를 넣어본 후,
        웹 페이지에서 폼으로 게시물을 추가할 때에 option 태그에 값이 잘 추가되는지,
        게시물을 추가한 후 게시물은 잘 추가되어 있는지
        저자는 로그인한 사람으로 추가되어 있는지 확인한다.

    '''

    def test_add_category_and_post(self):
        # 이름 = "python" 인 카테고리를 하나 추가하고,
        self.python_category = get_category_model()(
            name="python"
        )
        db.session.add(self.python_category)
        db.session.commit()
        self.assertEqual(get_category_model().query.first().name, "python")  # 추가한 카테고리의 이름이 "python" 인지 확인한다.
        self.assertEqual(get_category_model().query.first().id, 1)  # id는 1로 잘 추가되어있는지 확인한다.

        # 이름 = "rust" 인 카테고리를 하나 추가하고,
        self.rust_category = get_category_model()(
            name="rust"
        )
        db.session.add(self.rust_category)
        db.session.commit()
        self.assertEqual(get_category_model().query.filter_by(id=2).first().name,
                         "rust")  # id가 2인 카테고리의 이름이 "rust" 인지 확인한다.

        # 이름 = "javascript" 인 카테고리를 하나 더 추가해 주자.
        self.rust_category = get_category_model()(
            name="javascript"
        )
        db.session.add(self.rust_category)
        db.session.commit()


        # 카테고리 리스트 페이지에 접속했을 때에, 추가했던 3개의 카테고리가 잘 추가되어 있는지?
        response = self.client.get('/categories-list')
        soup = BeautifulSoup(response.data, 'html.parser')
        self.assertIn('python', soup.text)
        self.assertIn('rust', soup.text)
        self.assertIn('javascript', soup.text)

        # 로그인 전에는, 포스트 작성 페이지에 접근한다면 로그인 페이지로 이동해야 한다. 리디렉션을 나타내는 상태 코드는 302이다.
        response = self.client.get('/create-post', follow_redirects=False)
        self.assertEqual(302, response.status_code)

       # 게시물의 작성자 생성
        response = self.client.post('/auth/sign-up',
                                    data=dict(email="helloworld@naver.com", username="hello", password1="dkdldpvmvl",
                                              password2="dkdldpvmvl"))

        # 위에서 만든 유저로 로그인
        with self.client:
            response = self.client.post('/auth/login',
                                        data=dict(email="helloworld@naver.com", username="hello",
                                                  password="dkdldpvmvl"),
                                        follow_redirects=True)
            # 로그인한 상태로, 게시물 작성 페이지에 갔을 때에 폼이 잘 떠야 한다.
            response = self.client.get('/create-post')
            self.assertEqual(response.status_code, 200) # 서버에 get 요청을 보냈을 때에, 정상적으로 응답한다는 상태 코드인 200을 돌려주는가?

            # 미리 작성한 카테고리 3개가 셀렉트 박스의 옵션으로 잘 뜨고 있는가?
            soup = BeautifulSoup(response.data, 'html.parser')
            select_tags = soup.find(id='category')
            self.assertIn("python", select_tags.text)
            self.assertIn("rust", select_tags.text)
            self.assertIn("javascript", select_tags.text)

            response_post = self.client.post('/create-post',
                                        data=dict(title="안녕하세요, 첫 번째 게시물입니다.",
                                                  content="만나서 반갑습니다!",
                                                  category="1"),
                                        follow_redirects=True)
            self.assertEqual(1, get_post_model().query.count()) # 게시물을 폼에서 작성한 후, 데이터베이스에 남아 있는 게시물의 수가 1개가 맞는가?

        # 게시물은 잘 추가되어 있는지?
        response = self.client.get(f'/posts/1')
        soup = BeautifulSoup(response.data, 'html.parser')

        # 게시물의 페이지에서 우리가 폼에서 입력했던 제목이 잘 나타나는지?
        title_wrapper = soup.find(id='title-wrapper')
        self.assertIn("안녕하세요, 첫 번째 게시물입니다.", title_wrapper.text)

        # 게시물 페이지에서, 로그인했던 유저의 이름이 저자로 잘 표시되는지?
        author_wrapper = soup.find(id='author-wrapper')
        self.assertIn("hello", author_wrapper.text)

        db.session.close()

if __name__ == "__main__":
    unittest.main()

 

코드를 수정하고 테스트를 실행해 보면 아래와 같은 오류가 발생한다.

 

 

카테고리가 데이터베이스 안에 들어가긴 했지만, 카테고리 리스트 페이지에는 나타나지 않는다는 것이다. 오류를 해결하기 위해서는 views.py에 있는 /categories-list를 아래와 같이 수정해준다.

수정하기 전에 from .models import get_category_model을 추가해준다.

 

 

categories에 모든 카테고리를 담고, 그것을 아래에서 넘겨줄 것이다. 그렇게 되면 이제 템플릿에서 사용이 가능해지게 되는 것이다. all()을 사용하면 모든 카테고리들을 리스트의 형태로 반환하기 때문에 템플릿에서 for문을 사용하여 모든 카테고리를 나타낼 수 있다. 나타내기 위해서 categories_list.html을 아래와 같이 수정해 준다.

 

{% extends 'base.html' %}

{% block title %}All Categories{% endblock %}

{% block header %}
    <!-- Page Header-->
    <header class="masthead" style="background-image: url('{{url_for('static', filename = 'assets/img/ipad.png')}}')">
        <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="site-heading">
                        <h1>All Categories</h1>
                        <span class="subheading">Blog categories...</span>
                    </div>
                </div>
            </div>
        </div>
    </header>
{% endblock %}

{% block content %}
    <!-- 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-10">


                {% for category in categories %}
                    <!-- Category item-->
                    <div class="post-preview">
                        <a href="post_detail.html">
                            <h2 class="post-title">{{ category.name }}</h2>
                        </a>
                    </div>
                    <!-- Divider-->
                    <hr class="my-4"/>
                {% endfor %}


            </div>
        </div>
    </div>
{% endblock %}

 

이제 테스트를 다시 돌려 보면 아까의 오류는 사라지고 아래와 같은 새로운 오류가 나타나게 된다.

 

 

 

이 오류는 위 코드처럼 상태 코드 302를 의도했지만 200으로 접근했긴 때문에 나타난 것이다. 이는 로그아웃 처리를 했던 것처럼 views.py에서 create_post를 아래처럼 수정해주면 된다.

 

 

다시 테스트를 돌려보면 아까의 오류는 사라지고 다른 오류가 발생한 것을 볼 수 있다.

 

 

이 오류를 해결하기 위해서는 /create-post에서 모든 카테고리를 불러와 준 다음 post_create_form.html을 아래와 같이 value에는 id를 태그 안에는 name을 표시해 주면 된다.

 

 

{% extends 'base.html' %}

{% block title %}Create a Post{% endblock %}

{% block header %}
    <header class="masthead"
            style="background-image: url('{{ url_for('static', filename='assets/img/post-sample-image.jpeg') }}'); height: 130px;">
        <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="site-heading">
                        <h1>Create a Post</h1>
                        <h2>Post whatever you want!</h2>
                    </div>
                </div>
            </div>
        </div>
    </header>
{% endblock %}

{% block content %}

    <!-- Main Content-->
    <main class="mb-4">
        <div class="container px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-12">
                    <div class="my-5">
                        <form id="createForm" method="POST">
                            <div style="margin: 20px;">
                                <input class="form-control" id="title" type="text" placeholder="Post Title"
                                       name="title" style="font-size: 40px;"/>
                            </div>
                            <div style="margin: 20px;">
                                <textarea class="form-control" id="content" type="text" placeholder="Content"
                                          name="content" style="height: 500px;"></textarea>

                            </div>
                            <div style="margin: 20px;" id="category">
                                <select class="form-control" name="category" required>
                                    <option value="" disabled selected>select a category</option>
                                    {% for category in categories %}
                                        <option value="{{ category.id }}">{{ category.name }}</option>
                                    {% endfor %}

                                </select>
                            </div>
                            <br/>
                            <div style="text-align: center">
                                <button class="btn btn-primary text-uppercase" id="submitButton" type="submit">
                                    Create
                                </button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </main>



{% endblock %}

 

그러면 아까의 오류는 해결되고 다른 오류가 발생한다.

 

 

데이터베이스에 있는 게시물의 수가 1개여야 하는데, 실제로는 0개여서 오류가 나는 것이다. 이제 이 부분을 해결할 차례이다.

 

게시물 작성하기

게시물 작성은 로그인 작업과 유사하다. 폼을 작성해서 /create-post로 POST 요청을 보면서 폼에서 받아온 데이터를 검증하고 저장하면 되는 것이다,. 데이터 검증을 쉽게 하기 위해서 forms.py에 코드를 추가해 준다

.

 

그리고 views.py로 가서 /create-post가 POST 요청을 받을 수 있도록 수정하고, 폼에서 받아온 검증된 데이터를 데이터베이스에 저장하고 home으로 리다이렉트 해주도록 수정한다.

 

 

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

 

 

 

이제 테스트를 하면 아까의 오류는 해결되었고 포스트를 해결할 차례이다.

 

 

 

우선 위와 같이 posts 앞에 /가 붙도록 코드를 수정해 준다. 그렇게 되면 포스트는 성공적으로 테이블에 추가되었고, 테이블의 정보를 뿌려주는 과정을 위의 라우팅에서 처리할 것이다. 그러기 위에서는 post_detail.html에 해당 포스트의 정보를 넘겨주도록 코드를 아래와 같이 수정해야 한다.

 

 

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

 

 

오류를 해결하기 위해서 제목과 저자가 들어갈 부분을 div 태그로 감싸도록  post_detail.html을 수정해 준다.

 

{% extends 'base.html' %}

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


{% block header %}
    <!-- Page Header-->
    <header class="masthead" style="background-image: url('assets/img/post-bg.jpg')">
        <div class="container position-relative px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-7">
                    <div class="post-heading">
                        <div id="title-wrapper"><h1>{{ post.title }}</h1></div>
                        <span class="meta">
                            <div id="author-wrapper"><p>Posted by : <a href="#!">저자명이 들어갈 부분</a></p></div>
                            <p>created_at : {{ post.created_at }}</p>
                            </span>
                    </div>
                </div>
            </div>
        </div>
    </header>
{% endblock %}

{% block content %}
    <!-- Post Content-->
    <article class="mb-4">
        <div class="container px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-7">
                    {{ post.content }}
                </div>
            </div>
        </div>
    </article>
{% endblock %}

 

 

그런데 이번엔 새로운 오류가 생겼다. 코드를 보면 title-wrapper로 감싼 부분인 제목은 통과됐지만 author-wrapper로 감싼 저자 부분은 저자를 표시할 수 없기 때문에 생긴 오류이다. 하지만 기존의 코드는 저자의 id만 받아 올 수 있다.  때문에 이름을 받아오기 위해서는 Post모델을 다시 살펴볼 필요가 있다.

 

 

user 부분에 backref라는 코드를 이용해서 다른 모델을 쉽게 참조할 수 있도록 해놨다. 그래서 post.user까지만 작성하면 User의 다른 정보에 접근할 수 있게 되는 것이다. 그래서 post_detail.html의 저자명이 들어갈 부분을 아래와 같이 수정하고

테스트를 돌리면 OK 표시가 뜨는 것을 볼 수 있다.

 

 

이제 실제로 테스트를 해볼 차례이다. 서버를 실행하기 전에 우리가 만든 두 개의 모델을 관리자 페이지에 등록시켜 주어야 한다.  __init__.py에 코드를 추가해 준다.

 

 

그리고 관리자 페이지에 들어가면 등록한 모델들이 나타나 있는 것을 볼 수 있다.  다음으로 카테고리 탭에서 카테고리를 하나 만들어 준다.

 

 

그다음 포스트 탭으로 이동해서 포스트도 하나 작성해준다.

 

 

그리고 /posts/1로 접속해 보면 작성한 포스트다 나타나는 것을 볼 수 있다.

 


이제 실제로 폼에서 게시물을 작성해도 잘 추가가 되는지 확인해 볼 차례이다.

우선 로그인하지 않은 상태에서 게시물 작성 페이지에 접속하면 로그인 페이지가 떠야 한다.

 

 

로그인해서 게시글을 작성하는 데로 들어가면 폼이 잘 뜨고 카테고리도 뜨는 것을 확인할 수 있다.

 

 

작성한 뒤에 id를 2로 해서 들어가 보면 작성한 내용이 뜨는 것을 확인할 수 있다.

 

 

다음 카테고리 부분으로 들어가면 관리자 페이지에서 만든 카테고리가 있는 것을 확인 할 수 있다.

 

 

여기서 TodayILearned에 들어가면 해당 카테고리에 속한 게시물들의 리스트가 나오도록 구현해 볼 것이다.

우선 categories_list.html에서 아래와 같이 코드를 수정해 준다.

 

url_for을 이용하여 views.py에 있는 post_list로 카테고리 id를 보내고 post_list는 받아온 카테고리 id에 맞는 카테고리, 포스트들은 넘겨주도록 아래와 같이 코드를 수정해 주면 된다.

 

 

그리고 카테고리를 눌러 주면 post_list.html의 내용이 웹 페이지에 나타나게 된다.

 

 

현재 post_list.html의 내용은 아래와 같다.

 

{% extends 'base.html' %}

{% block title %}Post List{% endblock %}

{% block header %}
    <!-- Page Header-->
    <header class="masthead" style="background-image: url('{{ url_for('static', filename='assets/img/home-bg.jpg') }}')">
        <div class="container position-relative px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-7">
                    <div class="site-heading">
                        <h1>(카테고리명) Posts.</h1>
                        <span class="subheading">() 개의 포스트가 있습니다.</span>
                    </div>
                </div>
            </div>
        </div>
    </header>
{% endblock %}

{% block content %}
    <!-- 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-10">
                <!-- Post preview-->
                <div class="post-preview">
                    <a href="post_detail.html">
                        <h6 class="post-title">Man must explore, and this is exploration at its greatest</h6>
                    </a>
                    <p class="post-meta">
                        Posted by
                        <a href="#!">Start Bootstrap</a>
                        on September 24, 2022
                    </p>
                </div>
                <!-- Divider-->
                <hr class="my-4"/>
                <!-- Post preview-->
                <div class="post-preview">
                    <a href="post_detail.html"><h6 class="post-title">I believe every human has a finite number of
                        heartbeats. I don't intend to waste any of mine.</h6></a>
                    <p class="post-meta">
                        Posted by
                        <a href="#!">Start Bootstrap</a>
                        on September 18, 2022
                    </p>
                </div>
                <!-- Divider-->
                <hr class="my-4"/>
                <!-- Post preview-->
                <div class="post-preview">
                    <a href="post_detail.html">
                        <h6 class="post-title">Science has not yet mastered prophecy</h6>
                        <h3 class="post-subtitle">We predict too much for the next year and yet far too little for the
                            next ten.</h3>
                    </a>
                    <p class="post-meta">
                        Posted by
                        <a href="#!">Start Bootstrap</a>
                        on August 24, 2022
                    </p>
                </div>
                <!-- Divider-->
                <hr class="my-4"/>
                <!-- Post preview-->
                <div class="post-preview">
                    <a href="post_detail.html">
                        <h6 class="post-title">Failure is not an option</h6>
                        <h3 class="post-subtitle">Many say exploration is part of our destiny, but it’s actually our
                            duty to future generations.</h3>
                    </a>
                    <p class="post-meta">
                        Posted by
                        <a href="#!">Start Bootstrap</a>
                        on July 8, 2022
                    </p>
                </div>
                <!-- Divider-->
                <hr class="my-4"/>
                <!-- Pager-->
                <div class="d-flex justify-content-end mb-4"><a class="btn btn-primary text-uppercase" href="#!">Older
                    Posts →</a></div>
            </div>
        </div>
    </div>
{% endblock %}

 

위 부분에는 카테고리명, 카테고리에 속한 포스트들의 개수를 보여주는 부분이다. 이것들을 나타내기 위해서 post_list에서 받아온 current_category, posts 인자들을 이용해서 코드를 아래와 같이 수정해 준다.

 

 

이제 해당 카테고리에 속한 포스트들을 나타내기 위해서 코드를 아래와 같이 수정한다.

 

 

실행해 보면 성공적으로 포스트들이 나타난 것을 볼 수 있다.

 

 

스태프 권한을 가진 사람만 게시물을 작성할 수 있도록 하기

이제 스태프 권한을 가진 사람만 게시물을 작성이 되도록 바꿀 차례이다.

먼저 이전에 했던 테스트 코드 3번을 조금 수정해보자

 

 

수정된 요구사항에 맞게 tests.py에서 TestPostWithCategory 클래스를 아래와 같이 수정해 준다.

import unittest
from os import path

from flask_login import current_user
from bs4 import BeautifulSoup # pip install BeautifulSoup4

from blog import create_app
from blog import db
import os

from blog.models import get_user_model, get_post_model, get_category_model

basedir = os.path.abspath(os.path.dirname(__file__))
app = create_app()
app.testing = True

'''
회원가입, 로그인, 로그아웃 부분을 테스트
1. 2명의 유저를 데이터베이스에 넣어 본 후, 데이터베이스에 들어간 유저의 수가 총 2명이 맞는지를 확인한다.
2. auth/sign-up 에서 폼을 통해서 회원가입 요청을 보낸 후, 데이터베이스에 값이 잘 들어갔는지를 확인한다.
3. 로그인 전에는 네비게이션 바에 "login", "sign up" 이 보여야 하고, 로그인한 유저 이름과 "logout" 이 표시되면 안 된다.
'''


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

    # 테스트가 끝나고 나서 수행할 것, 테스트를 위한 데이터베이스의 내용들을 모두 삭제한다.
    def tearDown(self):
        os.remove('tests/test.db')
        self.ctx.pop()
        
                
    # 1. 2명의 유저를 데이터베이스에 넣어 본 후, 
    # 데이터베이스에 들어간 유저의 수가 총 2명이 맞는지를 확인한다.
    def test_signup_by_database(self):
        self.user_test_1 = get_user_model()(
            email="hello@example.com",
            username="testuserex1",
            password="12345",
            is_staff=True
        )
        db.session.add(self.user_test_1)
        db.session.commit()

        self.user_test_2 = get_user_model()(
            email="hello2@example.com",
            username="testuserex2",
            password="12345",
        )
        db.session.add(self.user_test_2)
        db.session.commit()
        

        # 데이터베이스에 있는 유저의 수가 총 2명인가?
        self.assertEqual(get_user_model().query.count(), 2)
        #assetEqual은 TestCase 클래스가 제공하는 메소드 중 하나로 
        #위 코드는 데이터베이스의 존재하는 유저들의 수가 2와 같은지를 체크하는 것이다.
        
        db.session.close()
        
        
    # 2. auth/sign-up 에서 폼을 통해서 회원가입 요청을 보낸 후, 
    # 데이터베이스에 값이 잘 들어갔는지를 확인한다.
    def test_signup_by_form(self):
        response = self.client.post('/auth/sign-up',data=dict(email="helloworld@naver.com", 
                username="hello", password1="dkdldpvmvl", password2="dkdldpvmvl"))
        self.assertEqual(1, get_user_model().query.count())
        
        db.session.close() 
    
    # 3. 로그인 전에는 네비게이션 바에 "login", "sig up" 이 보여야 하고, 로그인한 유저 이름과 "logout" 이 표시되면 안 된다.
    def test_before_login(self):
        # 로그인 전이므로, 네비게이션 바에는 "login", "sign up" 이 보여야 한다.
        response = self.client.get('/')
        soup = BeautifulSoup(response.data, 'html.parser')
        navbar_before_login = soup.nav  # nav 태그 선택

        self.assertIn("Login", navbar_before_login.text)  # navbar 안에 "Login" 이 들어있는지 테스트
        self.assertIn("Sign Up", navbar_before_login.text, )  # navbar 안에 "Sign Up" 이 들어있는지 테스트
        self.assertNotIn("Logout", navbar_before_login.text, )  # navbar 안에 "Logout" 이 없는지 테스트

        # 로그인을 하기 위해서는 회원가입이 선행되어야 하므로, 폼에서 회원가입을 진행해 준다.
        response = self.client.post('/auth/sign-up',
                                    data=dict(email="helloworld@naver.com", username="hello", password1="dkdldpvmvl",
                                              password2="dkdldpvmvl"))
        # 이후, auth/login 에서 로그인을 진행해 준다.
        with self.client:
            response = self.client.post('/auth/login',
                                        data=dict(email="helloworld@naver.com", username="hello",
                                                  password="dkdldpvmvl"),
                                        follow_redirects=True)
            soup = BeautifulSoup(response.data, 'html.parser')
            navbar_after_login = soup.nav

            # 로그인이 완료된 후, 네비게이션 바에는 로그인한 유저 이름과 "Logout" 이 표시되어야 한다.
            self.assertIn(current_user.username, navbar_after_login.text)
            self.assertIn("Logout", navbar_after_login.text)
            # 로그인이 완료된 후, 네비게이션 바에는 "Login" 과 "Sign Up" 이 표시되면 안 된다.
            self.assertNotIn("Login", navbar_after_login.text)
            self.assertNotIn("Sign up", navbar_after_login.text)
            
            db.session.close()
             
class TestPostwithCategory(unittest.TestCase):
    # 테스트를 위한 사전 준비
    def setUp(self):
        self.ctx = app.app_context()
        self.ctx.push()
        self.client = app.test_client()
        # 테스트를 위한 db 설정
        self.db_uri = 'sqlite:///' + os.path.join(basedir, 'test.db')
        app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
        app.config['TESTING'] = True
        app.config['WTF_CSRF_ENABLED'] = False
        app.config['SQLALCHEMY_DATABASE_URI'] = self.db_uri
        if not path.exists("tests/" + "test_db"):  # DB 경로가 존재하지 않는다면,
            db.create_all(app=app)  # DB를 하나 만들어낸다.

    # 테스트가 끝나고 나서 수행할 것, 테스트를 위한 데이터베이스의 내용들을 모두 삭제한다.
    def tearDown(self):
        os.remove('tests/test.db')
        self.ctx.pop()
    
    '''
    1. 임의의 카테고리를 넣어본 후, 데이터베이스에 카테고리가 잘 추가되어 있는지 확인한다.
    2. 카테고리를 넣은 후, /categories-list 에 접속했을 때, 넣었던 카테고리들이 잘 추가되어 있는지 확인한다. 
    3. 게시물을 작성할 때에, 로그인하지 않았고, 스태프 권한을 가지고 있지 않다면 접근이 불가능해야 한다.
        - 스태프 권한을 가지고 있지 않은 사용자 1명, 게시물 작성 페이지에 접근할 수 없어야 한다.
        - 스태프 권한을 가지고 있는 사용자 1명, 게시물 작성 페이지에 접근할 수 있어야 한다.
    4. 임의의 카테고리를 넣어본 후,
        웹 페이지에서 폼으로 게시물을 추가할 때에 option 태그에 값이 잘 추가되는지,
        게시물을 추가한 후 게시물은 잘 추가되어 있는지
        저자는 로그인한 사람으로 추가되어 있는지 확인한다.

    '''

    def test_add_category_and_post(self):
        # 이름 = "python" 인 카테고리를 하나 추가하고,
        self.python_category = get_category_model()(
            name="python"
        )
        db.session.add(self.python_category)
        db.session.commit()
        self.assertEqual(get_category_model().query.first().name, "python")  # 추가한 카테고리의 이름이 "python" 인지 확인한다.
        self.assertEqual(get_category_model().query.first().id, 1)  # id는 1로 잘 추가되어있는지 확인한다.

        # 이름 = "rust" 인 카테고리를 하나 추가하고,
        self.rust_category = get_category_model()(
            name="rust"
        )
        db.session.add(self.rust_category)
        db.session.commit()
        self.assertEqual(get_category_model().query.filter_by(id=2).first().name,
                         "rust")  # id가 2인 카테고리의 이름이 "rust" 인지 확인한다.

        # 이름 = "javascript" 인 카테고리를 하나 더 추가해 주자.
        self.rust_category = get_category_model()(
            name="javascript"
        )
        db.session.add(self.rust_category)
        db.session.commit()


        # 카테고리 리스트 페이지에 접속했을 때에, 추가했던 3개의 카테고리가 잘 추가되어 있는지?
        response = self.client.get('/categories-list')
        soup = BeautifulSoup(response.data, 'html.parser')
        self.assertIn('python', soup.text)
        self.assertIn('rust', soup.text)
        self.assertIn('javascript', soup.text)

        # 로그인 전에는, 포스트 작성 페이지에 접근한다면 로그인 페이지로 이동해야 한다. 리디렉션을 나타내는 상태 코드는 302이다.
        response = self.client.get('/create-post', follow_redirects=False)
        self.assertEqual(302, response.status_code)

       # 게시물의 작성자 생성
        response = self.client.post('/auth/sign-up',
                                    data=dict(email="helloworld@naver.com", username="hello", password1="dkdldpvmvl",
                                              password2="dkdldpvmvl"))

        # 위에서 만든 유저로 로그인
        with self.client:
            response = self.client.post('/auth/login',
                                        data=dict(email="helloworld@naver.com", username="hello",
                                                  password="dkdldpvmvl"),
                                        follow_redirects=True)
            # 스태프 권한을 가지고 있지 않는 작성자 생성
        response = self.client.post('/auth/sign-up',
                                    data=dict(email="helloworld@naver.com", username="hello", password1="dkdldpvmvl",
                                              password2="dkdldpvmvl"))
        # 스태프 권한을 가지고 있지 않은 작성자가 포스트 작성 페이지에 접근한다면, 권한 거부가 발생해야 한다.
        with self.client:
            response = self.client.post('/auth/login',
                                        data=dict(email="helloworld@naver.com", username="hello",
                                                  password="dkdldpvmvl"),
                                        follow_redirects=True)
            response = self.client.get('/create-post', follow_redirects=False)
            self.assertEqual(403,
                             response.status_code)  # 스태프 권한을 가지고 있지 않은 사람이 /create-post 에 접근한다면, 서버는 상태 코드로 403을 반환해야 한다.
            response = self.client.get('/auth/logout')  # 스태프 권한을 가지고 있지 않은 작성자에서 로그아웃

        # 스태프 권한을 가지고 있는 작성자 생성, 폼에서는 is_staff 를 정할 수 없으므로 직접 생성해야 한다.
        self.user_with_staff = get_user_model()(
            email="staff@example.com",
            username="staffuserex1",
            password="12345",
            is_staff=True
        )
        db.session.add(self.user_with_staff)
        db.session.commit()

        # 스태프 권한을 가지고 있는 유저로 로그인 후, 게시물을 잘 작성할 수 있는지 테스트
        from flask_login import FlaskLoginClient
        app.test_client_class = FlaskLoginClient
        with app.test_client(user=self.user_with_staff) as user_with_staff:
            # 로그인한 상태로, 게시물 작성 페이지에 갔을 때에 폼이 잘 떠야 한다.
            response = user_with_staff.get('/create-post', follow_redirects=True)
            self.assertEqual(response.status_code,
                             200)  # 스태프 권한을 가지고 있는 사용자가 서버에 get 요청을 보냈을 때에, 정상적으로 응답한다는 상태 코드인 200을 돌려주는가?

            # 미리 작성한 카테고리 3개가 셀렉트 박스의 옵션으로 잘 뜨고 있는가?
            soup = BeautifulSoup(response.data, 'html.parser')
            select_tags = soup.find(id='category')
            self.assertIn("python", select_tags.text)
            self.assertIn("rust", select_tags.text)
            self.assertIn("javascript", select_tags.text)

            response_post = user_with_staff.post('/create-post',
                                             data=dict(title="안녕하세요, 첫 번째 게시물입니다.", content="만나서 반갑습니다!", category="1"),
                                             follow_redirects=True)


            self.assertEqual(1, get_post_model().query.count())  # 게시물을 폼에서 작성한 후, 데이터베이스에 남아 있는 게시물의 수가 1개가 맞는가?

        # 게시물은 잘 추가되어 있는지?
        response = self.client.get(f'/posts/1')
        soup = BeautifulSoup(response.data, 'html.parser')

        # 게시물의 페이지에서 우리가 폼에서 입력했던 제목이 잘 나타나는지?
        title_wrapper = soup.find(id='title-wrapper')
        self.assertIn("안녕하세요, 첫 번째 게시물입니다.", title_wrapper.text)

        # 게시물 페이지에서, 로그인했던 유저의 이름이 저자로 잘 표시되는지?
        author_wrapper = soup.find(id='author-wrapper')
        self.assertIn("staffuserex1", author_wrapper.text)

        db.session.close()

if __name__ == "__main__":
    unittest.main()

 

테스트를 해보면 다음과 같은 오류가 뜬다.

 

 

오류를 수정하려면 current_user가 is_staff == True 일 때 접근 가능해야 하고, 그렇지 않다면 접근이 불가능하게 해야 한다.

이는 views.py에서 create_post를 아래와 같이 수정해 주면 된다.

 

수정한 후 테스트를 해보면 OK가 뜨는 것을 확인할 수 있다.

 

게시물 Update 처리해 보기

이제 게시물의 수정, 삭제를 처리할 차례이다. 테스트 코드를 먼저 작성하고 시작해 보자. 요구사항은 아래와 같다.

 

이제 테스트 요구사항에 맞게 tests.py에 테스트 코드를 추가해 준다.

 

def test_update_post(self):
        '''
        임의의 유저를 2명 생성한다. smith, james
        smith 으로 로그인 후, 폼에서 게시물을 하나 생성한다.
        smith로 로그인한 상태에서 smith가 작성한 게시물에 들어갔을 때에, "수정하기" 버튼이 보여야 한다.
        수정하기 버튼을 누르고 수정 페이지에 들어가면, 폼에 원래 내용이 채워져 있어야 한다.
        이후 폼에서 내용을 바꾸고 수정하기 버튼을 누르면, 수정이 잘 되어야 한다.
        smith 에서 로그아웃 후, james 로 로그인 후 smith가 작성한 게시물에 들어갔을 때에, "수정하기" 버튼이 보이지 않아야 한다.
        james 가 smith가 작성한 게시물을 수정하려 한다면(url로 접근하려 한다면), 거부되어야 한다.
        '''

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

        # 2개의 카테고리 생성하기
        self.python_category = get_category_model()(
            name="python" # id == 1
        )
        db.session.add(self.python_category)
        db.session.commit()
        self.javascript_category = get_category_model()(
            name="javascript" # id == 2
        )
        db.session.add(self.javascript_category)
        db.session.commit()

        # smith로 로그인 후, 수정 처리가 잘 되는지 테스트
        from flask_login import FlaskLoginClient
        app.test_client_class = FlaskLoginClient
        # smith 로 게시물 작성, 이 게시물의 pk는 1이 될 것임
        with app.test_client(user=self.smith) as smith:
            smith.post('/create-post',
                       data=dict(title="안녕하세요,smith가 작성한 게시물입니다.",
                                 content="만나서 반갑습니다!",
                                 category="1"), follow_redirects=True)
            response = smith.get('/posts/1') # smith가 본인이 작성한 게시물에 접속한다면,
            soup = BeautifulSoup(response.data, 'html.parser')
            edit_button = soup.find(id='edit-button')
            self.assertIn('Edit', edit_button.text) # "Edit" 버튼이 보여야 함

            response = smith.get('/edit-post/1') # smith 가 본인이 작성한 포스트에 수정하기 위해서 접속하면,
            self.assertEqual(200, response.status_code) # 정상적으로 접속할 수 있어야 함, status_code==200이어야 함
            soup = BeautifulSoup(response.data, 'html.parser')

            title_input = soup.find('input')
            content_input = soup.find('textarea')

            # 접속한 수정 페이지에서, 원래 작성했을 때 사용했던 문구들이 그대로 출력되어야 함
            self.assertIn(title_input.text, "안녕하세요,smith가 작성한 게시물입니다.")
            self.assertIn(content_input.text,"만나서 반갑습니다!")

            # 접속한 수정 페이지에서, 폼을 수정하여 제출
            smith.post('/edit-post/1',
                       data=dict(title="안녕하세요,smith가 작성한 게시물을 수정합니다.",
                                 content="수정이 잘 처리되었으면 좋겠네요!",
                                 category="2"), follow_redirects=True)
            # 수정을 완료한 후, 게시물에 접속한다면 수정한 부분이 잘 적용되어 있어야 함
            response = smith.get('/posts/1')
            soup = BeautifulSoup(response.data, 'html.parser')
            title_wrapper = soup.find(id='title-wrapper')
            content_wrapper = soup.find(id='content-wrapper')

            self.assertIn(title_input.text, "안녕하세요,smith가 작성한 게시물을 수정합니다.")
            self.assertIn(content_input.text,"수정이 잘 처리되었으면 좋겠네요!")

            # 마찬가지로 smith로 접속한 상태이므로,
            response = smith.get('/posts/1') # smith가 본인이 작성한 게시물에 접속한다면,
            soup = BeautifulSoup(response.data, 'html.parser')
            edit_button = soup.find(id='edit-button')
            self.assertIn('Edit', edit_button.text) # "Edit" 버튼이 보여야 함
            smith.get('/auth/logout') # smith 에서 로그아웃
        # james 로 로그인
        with app.test_client(user=self.james) as james:
            response = james.get('/posts/1') # Read 를 위한 접속은 잘 되어야 하고,
            self.assertEqual(response.status_code, 200)
            soup = BeautifulSoup(response.data, 'html.parser')
            self.assertNotIn('Edit', soup.text) # Edit 버튼이 보이지 않아야 함
            response = james.get('/edit-post/1') # Update 를 위한 접속은 거부되어야 함
            self.assertEqual(response.status_code, 403)
            
        db.session.close()

 

테스트를 실행해 보면 아래와 같은 오류가 발생한다.

 

 

오류 내용을 보면 글을 조회하러 들어갔을 때 Edit 버튼이 보이지 않는 다고 한다. 이를 고치기 위해서 post_datail.html을 수정해 주어야 한다.

 

 

테스트를 실행하면 아래와 같은 오류가 발생한다. views.py에 라우팅 하지 않은 함수를 url_for에 사용했기 때문이다.

 

 

오류를 해결하기 위해서 views.py에 라우팅을 추가해서 수정 기능을 구현해야 한다. 수정 기능을 구현하기 전에 수정이 어떻게 이루어지는지 알 필요가 있다. 우선 '수정할 게시물이 무엇인가'를 알아야 하고, 수정 페이지에 들어가서는 폼에 원래 게시물의 내용이 채워져 있어야 한다. 이를 바탕으로 edit-post의 전체적인 형태를 구상해 보자.

 

 

첫 번째로 GET 요청이 들어왔을 때 폼을 띄워주는 코드를 작성한다.

 

 

포스트의 내용을 미리 채워서 보여줄 것이기 때문에 포스트가 무엇인지 중요하다. 카테고리 정보도 또한 필요하다.

이제 수정 폼인 postt_edit_form.html을 만들어서 코드를 작성할 차례이다. 이 폼의 대부분은  post_create_form.html을

복사해서 사용하는데 폼 내용이 미리 채워져 있어야 하기 때문에 html의 value.select 옵션의 selected를 사용할 것이다.

 

{% extends 'base.html' %}

{% block title %}Edit a Post{% endblock %}

{% block header %}
    <header class="masthead"
            style="background-image: url('{{ url_for('static', filename='assets/img/create-bg.jpeg') }}'); height: 130px;">
        <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="site-heading">
                        <h1>Edir a Post</h1>
                        <h2>Post whatever you want!</h2>
                    </div>
                </div>
            </div>
        </div>
    </header>
{% endblock %}

{% block content %}

    <!-- Main Content-->
    <main class="mb-4">
        <div class="container px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-12">
                    <div class="my-5">
                        <form id="createForm" method="POST">
                            {{ form.csrf_token }}
                            <div style="margin: 20px;">
                                <input class="form-control" value="{{ post.title }}" id="title" type="text"
                                       placeholder="Post Title"
                                       name="title" style="font-size: 40px;"/>
                            </div>
                            <div style="margin: 20px;">
                                <textarea class="form-control" id="content" type="text"
                                          placeholder="Content"
                                          name="content" style="height: 500px;">{{ post.content }}</textarea>

                            </div>
                            <div style="margin: 20px;" id="category">
                                <select class="form-control" name="category" required>
                                    <option value="" disabled>select a category</option>
                                    {% for category in categories %}
                                        {% if post.category == category %}
                                            <option value="{{ category.id }}" selected>{{ category.name }}</option>
                                        {% else %}
                                            <option value="{{ category.id }}">{{ category.name }}</option>
                                        {% endif %}
                                    {% endfor %}
                                </select>
                            </div>
                            <br/>
                            <div style="text-align: center">
                                <button class="btn btn-primary text-uppercase" id="submitButton" type="submit">
                                    Edit
                                </button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </main>



{% endblock %}

 

테스트를 실행해 보면 POST 요청을 했을 때, 적절한 반환 값이 없다는 에러가 아래와 같이 발생할 것이다.

 

 

이제 edit_post에서 아까 다루지 않았던 POST 요청이 들어왔을 때 폼에서 받아온 데이터로 포스트를 수정하는 코드를 작성할 것이다. 

 

@views.route("/edit-post/<int:id>", methods=["GET", "POST"])
@login_required
def edit_post(id):
    post = get_post_model().query.filter_by(id=id).first()  # id로부터 포스트를 가져오고,
    form = PostForm()  # form 을 사용할 것이므로 가져와 준다.
    categories = get_category_model().query.all()  # category 들도 모두 가져와 준다.
    
     # 현재 유저는 스태프 권한을 가지고 있어야 하고, 작성자만 게시물을 수정할 수 있어야 한다.
    if current_user.is_staff == True and current_user.username == post.user.username:
        if request.method == "GET":
            return render_template("post_edit_form.html", user=current_user, post=post, categories=categories, form=form)
        elif request.method == "POST" and form.validate_on_submit():
            post.title = form.title.data
            post.content = form.content.data
            post.category_id = int(form.category.data)
            db.session.commit()
            return redirect(url_for("views.home"))
    # 스태프 권한을 가지고 있지 않거나, 게시물의 작성자가 아닐 경우 403 error를 발생시킨다.
    else:
        abort(403)

 

 

이번에는 다른 오류가 발생했다. 작성자가 아닌 사람이 접속했는데도 Edit 버튼이 보여서 생긴 오류다. 이를 해결하려면

post_detail.html에서 버튼 부분을 수정해 주면 된다.

 

 

 

그리고 테스트를 실행해 보면 성공적으로 OK가 뜨는 것을 볼 수 있다. 이제 웹 페이지에서 잘 동작하는지  확인해보자

 

 

우선 로그인 후 포스트를 하나 작성한다.

 

 

작성 후 포스트에 들어가면 Edit 버튼이 뜨는 것을 볼 수 있고, 수정을 하면 수정한 내용대로 바뀐 것을 확인할  수 있다.

 

 

다른 유저가 쓴 글을 들어가 보면 Edit 버튼이 없는 것도 확인할 수 있다.

 

어드민 페이지 접근권한 설정하기

현재 어드민 페이지는 로그인 여부에 상관없이 접근이 가능한데 이제 로그인한 유저가 is_staff==True여야지 관리자 페이지에 접속할 수 있도록 해 볼 것이다. 우선 __init__.py를 아래와 같이 수정해 준다.

 

is_accessible은 기본적으로 True를 리턴하기 때문에 모든 사용자가 어드민 페이지에 들어갈 수 있었지만 이제 수정된

코드로 인해서 로그인하지 않은 유저나 스태프 권한이 없는 유저는 접근을 거부당하게 된다. 로그인하지 않은 상태로 어드민 페이지에 들어가 보면 아래와 같이 거부당한 것을 확인할 수 있다.

 

 

어드민 페이지에서 모델명 제대로 나오도록 설정하기

 

models.py에서 Category 부분을 위와 같이 수정해 주면 아래처럼 관리자 페이지에서 모델명이 나오도록 바뀌게 된다.

 

 

어드민 페이지 커스텀하기

어드민 페이지에서 유저를 새로 등록하게 될 경우 아래와 같이 비밀번호가 암화화를 거치지 않고 등록하게 된다.

그렇게 되면 로그인할 때 암화화 된 비밀번호를 체크하기 때문에 로그인도 맞게 입력해도 실행되지 않는다.

 

그래서 어드민 페이지에서 유저를 등록할 때에도 비밀번호가 암호화 되도록 해야 한다. 또 생성일자는 자동적으로 생성되기 때문에 어드민 페이지에서 관리할 필요가 없기 때문에 form_exclude_colums = 를 정의해서 원치 않는 필드를 관리자 페이지에서 생성하지 않도록 할 수 있다. 이것들이 적용되도록 __init__.py를 수정해 보도록 하자

import click
from flask.cli import with_appcontext
from flask import Flask, abort
from werkzeug.security import generate_password_hash
from wtforms import PasswordField, StringField
from wtforms.validators import InputRequired

from flask import Flask, abort
from .models import DB_NAME, db, get_user_model,get_post_model, get_category_model
from flask_sqlalchemy import SQLAlchemy
from os import path
from flask_login import LoginManager, current_user
#flask_login = 로그인 기능을 쉽게 구현할 수 있도록 도와주는 라이브러리
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView

from pprint import pprint

def create_app():
    app = Flask(__name__)
    app.config['SECRET_KEY'] = "IFP"
    
    # DB 설정하기
    app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_NAME}'
    # DB 관련 추가할 설정
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
    
     # flask-admin
    app.config['FLASK_ADMIN_SWATCH'] = 'Darkly'
    admin = Admin(app, name='blog',
                  template_mode='bootstrap3')
    
    # flask-admin에 model 추가
    class MyUserView(ModelView):
        def is_accessible(self):
            if current_user.is_authenticated and current_user.is_staff == True:
                return True
            else:
                return abort(403)

        class CustomPasswordField(StringField):
            def populate_obj(self, obj, name):
                setattr(obj, name, generate_password_hash(self.data))

        form_extra_fields = {
            'password': CustomPasswordField('Password', validators=[InputRequired()])
        }
        form_excluded_columns = {
            'posts', 'created_at'
        }

    class MyPostView(ModelView):
        def is_accessible(self):
            if current_user.is_authenticated and current_user.is_staff == True:
                return True
            else:
                return abort(403)

        form_excluded_columns = {
            'created_at', 'comments'
        }

    class MyCategoryView(ModelView):
        def is_accessible(self):
            if current_user.is_authenticated and current_user.is_staff == True:
                return True
            else:
                return abort(403)

        form_excluded_columns = {
            'category'
        }

    admin.add_view(MyUserView(get_user_model(), db.session))  # get_user_model 로 유저 클래스를 가져옴
    admin.add_view(MyPostView(get_post_model(), db.session))
    admin.add_view(MyCategoryView(get_category_model(), db.session))
    db.init_app(app)
    
    from .views import views
    app.register_blueprint(views, url_prefix="/")
    from .auth import auth
    app.register_blueprint(auth, url_prefix="/auth")
    
    #db 생성하는 함수 호출
    create_database(app)
    
    login_manager = LoginManager()  #LoginManager() 객체 생성
    login_manager.login_view = "auth.login"
    #login_manager.login_view -> 만약 로그인하지 않고 보기에 엑세스 하려고 하면 "auth.login"으로 리디렉션
    #만약 login_view 가 설정되어 있지 않으면 401오류와 함께 중단
    
    login_manager.init_app(app) #app 에 login_manager 연결
    
    @login_manager.user_loader  #사용자 정보 조회
    def load_user_by_id(id):
        return get_user_model().query.get(int(id))  #유저 id를 받아와서 그 유저의 정보를 반환
    
    return app

def create_database(app):
    if not path.exists("blog/" + DB_NAME):  #DB 경로가 있으면 호출
        db.create_all(app=app)              #없으면 생성

 

수정한 코드를 실행해서 관리자 페이지에서 유저를 등록하면 아래와 같이 비밀번호가 암호화되어서 등록된 것을 확인할 수 있다.

 

스태프 권한이 있는 유저가 로그인 시 Create New Post 버튼 나타내기

카테고리에 들어갔을 때 로그인한 유저가 스태프 권한이 있는 경우 바로 포스트를 작성할 수 있는 버튼을 만들어 보자

이는 post_detail.html에 있는 Edit 버튼과 유사하다. 이를 참고하여 post_list.html을 아래와 같이 수정해 준다.

 

 

우선 사용자가 로그인되어 있는지와 사용자가 스태프 권한이 있는지를 if문으로 체크를 해주고 조건이 맞으면 버튼이 보이도록 코드를 추가하였다. 그래서 실행을 해보면 아래와 같은 결과가 나오게 된다.

 

 

그래서 버튼을 눌러서 만들어 보면 아래와 같이 성공적으로 포스트가 추가된 것을 확인할 수 있다.