[REAL Python – Flask] – “블로그 웹 애플리케이션 개발(2) – 테스트 코드 도입, 관리자 페이지, 카테고리, 게시물 다루기”
[REAL Python – Flask] – “블로그 웹 애플리케이션 개발(2) – 테스트 코드 도입, 관리자 페이지, 카테고리, 게시물 다루기”
스태프 권한을 위한 모델 수정하기
모델의 코드에서, 위의 마지막 줄을 추가하도록 합니다. 어드민 페이지는 말 그대로 어드민 페이지인데, 일반 유저들도 들어가서 회원을 마음대로 삭제하거나 하면 굉장히 골치아픈 상황이 되겠죠. 어드민 페이지는 스태프 권한을 가진 사람만 접근하도록 허용할 겁니다.
테스트 코드로 지금까지 작성했던 코드 테스트하기
지금까지는 구현한 기능이 별 것 없었으므로 앱을 직접 실행해 본 다음 구현한 기능을 직접 수행해 보는 것으로 테스트를 완료했습니다. 예를 들면, “로그인을 하고 나면 네비게이션 바에는 로그아웃 버튼이 보여야 한다” 라는 기능을 구현한 후, 서버를 실행한 다음 로그인 폼에서 로그인을 직접 진행하고, 네비게이션 바에 로그인 버튼이 보이는지 직접 눈으로 확인했습니다.
이제는 방법을 바꿔 볼 겁니다. 먼저 요구사항을 정리한 테스트 코드를 작성하고, 실제 코드를 작성하는 겁니다. 만약 테스트 케이스에 통과한다면, 코드를 리팩토링하는 식으로 말이죠!
테스트 주도 개발(Test-driven development TDD)[1]은 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나이다. 개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다. 그런 후에, 그 테스트 케이스를 통과하기 위한 최소한의 코드를 생성한다. 마지막으로 작성한 코드를 표준에 맞도록 리팩토링한다.
https://ko.wikipedia.org/wiki/%ED%85%8C%EC%8A%A4%ED%8A%B8_%EC%A3%BC%EB%8F%84_%EA%B0%9C%EB%B0%9C
그 전, 지금까지 작성한 코드를 테스트해봄으로서 어떠한 방식으로 테스트가 진행되는지 살펴보겠습니다.
맨 처음, 로그인과 로그아웃을 구현할 때에 제 머릿속에 있던 생각은 위와 같았습니다. 그 생각의 과정을, 테스트 코드로 옮기면 되는 것입니다. 시작!
일단, 테스트를 위해서 위와 같은 디렉토리를 만들고, tests.py 를 하나 만들어 주겠습니다.
그리고, 헷갈릴 수 있으니 상단의 필요한 import 코드들을 모두 게시하겠습니다!
import unittest
from os import path
from flask_login import current_user
from bs4 import BeautifulSoup # pip install BeautifulSoup
from blog import create_app
from blog import db
import os
from blog.models import User
basedir = os.path.abspath(os.path.dirname(__file__))
app = create_app()
app.testing = True
맨 첫 번째 줄에, import unittest
라는 문구가 보이죠. unittest
는 단위 테스트 프레임워크입니다.
https://docs.python.org/ko/3/library/unittest.html#module-unittest 공식 문서를 참고하면 좋을 것 같습니다. 아무튼, 우리는 우리의 코드를 테스트하기 위해서 unittest
라는 프레임워크를 사용할 겁니다.
class TestAuth(unittest.TestCase):
회원가입, 로그인, 로그아웃 등을 테스트할 것이므로, 클래스 이름을 TestAuth 로 지어 주었습니다. TestCase 클래스를 상속받아 작성된 걸 볼 수 있네요.
먼저, 테스트를 하기 위해서 필요한 몇 가지 기본 작업을 수행하겠습니다. 위에서는 각각 setUp()
과 tearDown()
에 대해 말해주고 있습니다. 아주 간단히, 테스트를 준비하기 위해서 setUp() 를, 테스트가 끝나고 나서는 teardown() 이 호출된다는 것입니다.
각각의 메서드들을 살펴보면 위에서는 데이터베이스 설정, 아래에서는 데이터베이스 파일을 삭제하도록 하였습니다. 하나의 테스트가 끝나면, 그 테스트한 데이터베이스가 바로 삭제될 수 있도록 해야 다른 테스트들에 영향을 주지 않기 때문입니다.
테스트 케이스 1. 우리가 만든 모델이 잘 동작하는가?
첫 번째로 테스트할 것은 우리가 작성한 모델이 잘 동작하느냐입니다. User 모델을 작성했었고, 그 유저 모델을 데이터베이스에 넣은 후, 잘 저장되었는지를 테스트합니다.
self.user_test_1=어쩌고저쩌고… 의 코드는 유저 2명을 데이터베이스에 저장하는 코드인 것이 자명하죠. 그런데, 맨 밑의 self.assertEqual(User.query.count(), 2)
은 무엇일까요?
그것은 우리가 위에서 상속받았던, TestCase 클래스가 제공하는 메서드 중 하나입니다. self.assertEqual(User.query.count(), 2)
라는 코드는 데이터베이스에 존재하는 유저들의 수가 2와 같느냐? 를 체크하는 것입니다. 기존의 테스트 방식이었던, 서버를 실행하고 -> 폼에서 회원가입을 진행하고 -> 유저가 잘 들어갔는지 데이터베이스를 확인 하는 것보다, 위의 코드로 테스트가 자동화되었습니다.
테스트 케이스 2. 폼으로 회원가입을 진행해도 데이터베이스에 값이 잘 추가되는가?
데이터베이스에 아무것도 없는 상태에서, 폼으로 회원가입을 진행한다면 회원은 딱 한 명만 존재해야 합니다.
그렇게 하기 위해서는 우리가 만들었던 회원가입 페이지에 POST 요청을 보내야겠죠. response= 부터 시작되는 코드는, 우리가 실제로 브라우저에서 폼을 작성하고, 요청을 보내는 것과 같습니다. 그렇기 때문에, data= 로 시작하는 코드를 보면 폼에 들어갈 데이터들이 입력되는 것을 볼 수 있죠.
저 요청이 이루어지면, 어떤 일이 일어날까요?
auth/sign-up 으로 post 요청이 이루어진다면, 위의 if문이 참일 경우가 동작할 겁니다.아래로 쭉 가면서, 입력한 데이터에 문제가 없다면 이메일 중복, 유저네임 중복, 회원가입, 리다이렉트까지 진행되겠죠?
테스트 케이스 하나(test_ 로 시작하는 메서드들)가 끝나고 나면 데이터베이스는 초기화되므로, 위의 과정을 거치고 난다면 데이터베이스에는 총 한 명의 유저만 있어야 할 겁니다. 테스트 코드를 실행해 보세요. OK라는 메시지가 터미널을 반겨줄 겁니다.
테스트 케이스 3. 로그인 전과 로그인 후의 네비게이션 바가 동적으로 바뀌는가?
위의 코드를 살펴보겠습니다. 첫째로는 /
의 경로로 GET 요청을 보내서 로그인 전이므로, 네비게이션 바에 로그인 버튼, 회원가입 버튼이 포함되어 있는지를 확인할 겁니다.
둘째로는, 폼에서 회원가입과 로그인을 진행해준 후, 로그인한 상태에서 우리가 원하던 방식으로 네비게이션 바가 바뀌는지를 확인합니다. 테스트 코드를 돌려 보세요. OK라고 뜰 겁니다!
이렇게 세 가지의 테스트 케이스를 만들어서 우리가 만든 코드들을 검증해 봤습니다. 오늘 구현할 카테고리, 게시물을 구현할 때에 테스트 코드를 먼저 도입한 다음 코드를 작성하는 방식으로 개발을 진행할 겁니다.
flask-admin 으로 관리자 페이지 만들기
보통, 대부분의 사이트들에는 관리자 페이지가 존재합니다. 이번에는 외부 라이브러리인 flask-admin 을 사용하여 어드민 페이지를 간편하게 구축해볼 것입니다.
pip install flask-admin 을 입력하여 flask-admin 설치를 완료해 줍니다.
설치 후, __init__.py
에 아래의 내용을 추가해 줍니다.
이후, 서버를 시작한 다음 /admin 으로 들어가 보세요. 아래와 같이 어드민 페이지가 하나 만들어져 있는 것을 확인할 수 있을 겁니다.
그런데, 아무래도 위에 탐색 바만 있고 아무런 기능도 없어 보이죠. 우리가 추가한 User 모델을 어드민 페이지에 추가해 보겠습니다.
…이전에 발생한 문제!
admin.add_view(ModelView(User, db.session))
와 같은 코드를 __init__.py 에 추가하면 순환 참조 에러가 발생합니다. (실제로, 저 코드를 __init__.py 에 추가하고, 서버를 실행해 보세요. 에러가 발생할 겁니다.)
그렇기에, 우리는 코드를 조금 수정할 필요가 있습니다.
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from sqlalchemy.sql import func
# init 으로부터 옮김
db = SQLAlchemy()
DB_NAME = "blog_db"
class User(db.Model, UserMixin):
#. 생략...
# User 클래스를 반환하는 함수 정의
def get_user_model():
return User
첫째로는, models.py 에 #init으로부터 옮긴다는 코드를 추가해 줍니다. 그리고, 맨 아래에 User 클래스를 반환하는 함수를 정의했습니다. 나중에 다른 곳에서 유저 클래스가 필요하다면, 저 함수를 사용해서 유저 모델을 가져올 수 있을 겁니다.
from flask import Flask
from .models import DB_NAME, db, get_user_model
from os import path
from flask_login import LoginManager
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
# app을 만들어주는 함수를 지정해 주자.
def create_app():
app = Flask(__name__) # Flask app 만들기
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
# blueprint 등록, '/' 를 기본으로 한다.
app.register_blueprint(views, url_prefix="/")
from .auth import auth
# blueprint 등록, '/auth' 를 기본으로 한다.
app.register_blueprint(auth, url_prefix="/auth")
# DB 생성하는 함수 호출하기
from .models import User
create_database(app)
login_manager = LoginManager() # LoginManager() 객체를 만들어 준다.
login_manager.login_view = "auth.login" # 만약 로그인이 필요한 곳에 로그인하지 않은 유저가 접근할 경우, 로그인 페이지로 리다리엑트 되도록 해 준다.
login_manager.init_app(app)
# 받은 id로부터, DB에 있는 유저 테이블의 정보에 접근하도록 해 줌.
# login manager는, 유저 테이블의 정보에 접근해, 저장된 세션을 바탕으로 로그인되어 있다면 로그인 페이지로 안 가도 되게끔 해 줌.
@login_manager.user_loader
def load_user_by_id(id):
return User.query.get(int(id))
return app
# DB 추가
def create_database(app):
if not path.exists("blog/" + DB_NAME): # DB 경로가 존재하지 않는다면,
db.create_all(app=app) # DB를 하나 만들어낸다.
24번 줄을 잘 살펴볼 필요가 있습니다. get_user_model() 을 사용하여 유저 모델을 어드민 페이지에 등록합니다.
from flask_login import login_user, logout_user, current_user, login_required
from .forms import SignupForm, LoginForm
from .models import db, get_user_model
from flask import Blueprint, render_template, request, url_for, flash
from werkzeug.utils import redirect
from werkzeug.security import generate_password_hash, check_password_hash
auth = Blueprint("auth", __name__)
@auth.route("/login", methods=['GET', 'POST']) # 로그인에서 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):
flash("Logged in!", category='success')
login_user(user, remember=True)
return redirect(url_for('views.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()
return redirect(url_for("views.home")) # 로그아웃하면 views의 blog_home으로 리다이렉트됨
@auth.route("/sign-up", methods=['GET', 'POST']) # 회원가입에서 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()
# 이메일 중복 검사
if email_exists:
flash("이메일이 이미 존재합니다...", category='error')
# 유저네임 중복 검사
elif username_exists:
flash("유저네임이 중복됩니다...", category='error')
# 위의 모든 과정을 통과한다면, 폼에서 받아온 데이터를 새로운 유저로서 저장
else:
db.session.add(signup_user)
db.session.commit()
flash("get_user_model() created!!!")
return redirect(url_for("views.home")) # 저장이 완료된 후 home으로 리다이렉트
# GET요청을 보낸다면 회원가입 템플릿을 보여줌
return render_template("signup.html", form=form, user=current_user)
그리고, 유저 모델을 직접 가져오는 것이 아닌 함수를 호출해서 가져오는 것으로 바꾸었으므로 원래 있던 소스의 User를 get_user_model() 을 호출하는 방식으로 바꾸어 줍니다.
import unittest
from os import path
from flask_login import current_user
from bs4 import BeautifulSoup
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('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(2, get_user_model().query.count())
# 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())
# 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)
if __name__ == "__main__":
unittest.main()
그리고, 테스트 코드에서도 User를 get_user_model() 을 이용해서 유저 클래스를 가져오는 것으로 변경합니다. 그리고, 테스트를 실행해 보면 여전히 OK가 뜰 것입니다.
그리고, python ./app.py 를 터미널에 입력한 후 /admin 에 접속해 봅시다. 유저 모델을 관리할 수 있는 탭이 생긴 것을 볼 수 있을 것입니다.
카테고리, 게시물 구현 1: 모델 작성하기
이제는 블로그의 핵심 기능이라고 할 수 있는 카테고리와 게시물을 다룰 겁니다. 그 전, 카테고리와 게시물이 어떤 방식으로 존재하는지를 한번 생각해 보겠습니다.
예를 들면, “파이썬 공부” 라는 카테고리에는 여러 개의 게시물이 포함될 수 있습니다. 아래의 그림과 같이 말이죠!
그렇기 때문에, 게시물에는 “우리는 어떤 카테고리 소속이에요~” 를 나타낼 수 있어야 합니다. 또한, 게시물은 저자를 가지므로 유저 모델과도 연결되어 있어야 합니다. 한 카테고리에는 여러 개의 게시물이 포함될 수 있고, 한 유저는 여러 게시물을 작성할 수 있습니다. 카테고리-게시물, 그리고 유저-게시물은 일대다 관계입니다.
그러면 위의 관계를 어떻게 다루어야 할까요? 우리는 지금까지 데이터베이스에 쿼리를 직접 날리는 방식이 아닌 ORM 을 사용하여 데이터베이스를 다루어 왔습니다. 이번에도, 그렇게 해 볼 겁니다.
먼저 위의 Post 모델 코드를 작성해 줍니다. (카테고리 모델은 아직 작성하지 않은 것이 맞으므로, 그대로 진행해도 좋습니다.) Post 클래스 코드 중 맨 아래 여덟 줄을 살펴보겠습니다. author_id 컬럼은 user테이블의 id를 참조하겠다는 뜻이고, user= 로 시작하는 코드는 역참조 코드인데, https://wikidocs.net/81045 를 참고하면 훨신 자세한 설명이 될 것입니다. category_id 컬럼은 category 컬럼의 id를 참조할 것이라고 되어 있고, 똑같이 역참조를 설정하였습니다. 정리해 본다면 다음과 같을 겁니다.
그렇기 때문에, 포스트 모델에는 1. 어떤 유저가 글을 작성했는지(author_id) , 2. 어떤 카테고리에 속해 있는지(category_id) 에 대한 정보가 담기게 됩니다.
author_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'),
nullable=False)
그러면 위의 코드가 무엇을 의미하는지를 추측할 수 있을 것입니다. ondelete=’CASCADE” 는 저자 모델이 삭제되었을 때에 그 저자가 쓴 포스트가 모두 삭제되도록 하는 것을 의미하고, nullable=False 를 통해서 게시물에는 무조건 저자가 있어야 한다는 것을 의미합니다.
이후, 카테고리 모델도 작성해 줍니다. 아래와 같을 겁니다.
HTML Form 작성하기
회원가입, 로그인과 마찬가지로 서버에 데이터를 보내야 하므로 POST 메소드를 사용하는 html form 을 작성해 볼 것입니다. 입력되어야 할 부분은 “제목”, “내용”, “카테고리” 가 될 겁니다. “생성일자”, “저자” 는 각각 자동으로 추가될 것이기에 따로 입력하는 곳이 필요하진 않겠죠?
위처럼 폼을 만들 수 있을 겁니다. 필자는 역시 디자인에는 감각이 제로에 수렴하므로.. 적당하게 contact 템플릿을 바꿔치기 했습니다.
{% extends 'base.html' %}
{% block title %}Create 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>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 %}
그러면, 이제 테스트 코드를 작성할 수 있습니다.
예상하셨겠지만, 이쪽의 셀렉트 박스 부분에는 데이터베이스에 있는 카테고리가 표시되어야 하겠죠? 이것도 나중에 처리해 볼 겁니다.
테스트 코드 작성하기
제가 테스트 코드를 사용함으로서, 검증하고 싶은 부분은 위와 같이 나열되어 있습니다. 이제 위의 조건을 만족하는 테스트 코드를 작성할 겁니다.
새 테스트를 추가하기 위해서, TestPostwithCategory
클래스를 작성하고, 사전 작업을 위와 같이 해 줍니다.
import unittest
from os import path
from flask_login import current_user
from bs4 import BeautifulSoup
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('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)
# 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(get_user_model().query.count(), 1)
# 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)
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('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)
if __name__ == "__main__":
unittest.main()
이후, 위와 같은 테스트 코드를 작성해 줍니다. 익숙하지 않겠지만, 코드를 작성하다 보면 왜 이런 코드로 검증을 진행하는지 이해하기 어렵지 않을 것입니다.
이후, 테스트 코드를 돌려 볼까요?
첫 번째로 해결해야 할 부분입니다. 카테고리가 데이터베이스 안에 들어가긴 했지만, 카테고리 리스트 페이지에는 나타나지 않는다는 것입니다.
첫 번째 드는 생각은, “/categories-list 에 접속할 것이므로, views.py 에 있는 해당 url을 처리하는 라우팅을 손보면 되지 않겠는가?” 입니다.
이전에 render_template() 를 소개할 때에, context 로서 변수를 넘길 수 있다고 했었죠? user=current_user 와 같이 사용한 것처럼, 우리는 categories=데이터베이스에 있는 카테고리들
처럼 컨텍스트를 넘겨줄 겁니다.
일단, 카테고리 모델을 사용할 것이므로 위의 코드를 작성합니다.
categories 에 모든 카테고리들을 담고, 그것을 아래에서 컨텍스트로 넘겨줄 겁니다. 그러면, 이제 템플릿에서 {{ categories }} 와 같은 코드를 사용할 수 있습니다.
그리고, 생각해 보면 카테고리는 “파이썬” “러스트” “자바스크립트” 처럼 여러 개가 될 겁니다. 실제로, all() 은 모든 row들을 리스트의 형태로 반환합니다.
그렇기 때문에, 아래와 같이 템플릿 엔진의 for문을 사용해서 모든 카테고리들을 나타낼 수 있습니다.
전체 코드는 아래와 같을 겁니다.
{% 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/categories-bg.jpeg') }})">
<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으로 접근했기 때문에 테스트를 통과하지 못했다고 적혀 있네요. 아래의 부분에서 테스트를 통과하지 못했습니다.
이는 우리가 로그아웃을 처리했던 부분과 똑같이 처리할 수 있습니다.
위의 데코레이터를 추가한 다음, 테스트 코드를 돌려 봅시다. 위에서 문제가 되었던 부분의 테스트가 통과한 것을 확인할 수 있을 것입니다.
그리고, 새로운 곳에서 테스트를 통과하지 못했네요. 어떤 부분인고 하니!
글 작성 폼에서, 카테고리를 고르는 select 태그에서 데이터베이스에 추가했던 카테고리가 나타나지 않기 때문에 테스트를 통과하지 못했다고 알려주고 있네요. 이것을 해결해 보겠습니다.
이번에도, 흐름은 비슷합니다.
위처럼, 모든 카테고리들을 불러와 준 다음,
위처럼 value에는 id를, 태그 안에는 name을 표시해 주면 될 겁니다.
그리고 테스트를 돌려 보면, 위의 부분은 통과되었고 이제 데이터베이스에 값을 저장해야 한다고 나와 있네요.
이제는, 이 부분을 해결할 겁니다.
게시물 작성하기
게시물 작성은 이전에 처리했던 로그인 작업과 굉장히 유사합니다. 첫째로 드는 생각은, 폼을 작성하고 “제출” 버튼을 누르면 /create-post 라는 곳으로 POST 요청을 보낼 것이라는 사실입니다. 그러면, 1. 폼에서 받아온 데이터를 검증하고 2. 그것을 데이터베이스에 저장하면 됩니다.
로그인이나 회원가입과 다른 점이 있다면, 이번에는 이메일이 중복되는지 등을 검사할 필요가 없습니다. 조금 더 간단할 겁니다.
일단, 백엔드에서 데이터 검증을 더욱 쉽게 하기 위해서 forms.py 에 내용을 추가해 줍니다.
이후, POST 요청을 보낼 것이므로 views.py
의 create-post 를 담당하는 라우트로 가 줍니다.
일단, POST 요청을 받을 수 있게끔 위의 코드를 추가해야 합니다.
그리고, form에서 받아온 검증된 데이터를 데이터베이스에 저장하고, 저장을 완료하고 나서 home으로 리다이렉트 해 주는 코드를 작성해 줍니다. 최종적으로 완성된 코드는 아래와 같을 겁니다.
그리고 게시물 작성 폼에서 해당 코드를 추가해 줍니다.
이후, 테스트를 돌려 봅니다. 위의 부분은 해결되었고, 이제 포스트를 보여줄 일이 남았네요.
주의사항 : 이전에 공유한 코드에서 ‘/posts/<int:id>’ 중 맨 앞의 ‘/’ 를 추가해 주세요!! 위의 사진처럼 되어야 합니다.
포스트는 성공적으로 테이블에 추가되었고, 그 테이블에 있는 정보를 뿌려주는 과정을 위의 라우팅에서 처리할 겁니다. 예를 들면, ‘posts/1″ 에 접속했을 때에는 post 테이블의 id=1 인 게시물의 정보(제목, 저자, 생성일자 등..) 이 나와야 합니다.
그렇게 하기 위해서는, 게시물을 보여주는 post_detail.html 의 내용이 동적으로 변화해야겠죠? 간단한 예시로, 현재 필자의 블로그가 그러합니다.
위의 서로 다른, 두 게시물을 보겠습니다. 제목도, 생성일자도, 내용도 모두 다르지만 공통된 형식을 따르고 있는 것을 볼 수 있죠? 우리가 플라스크로 만들고 있는 블로그 웹 애플리케이션에서는 어떻게 처리할 수 있을까요?
잠시, 예전에 가져왔던 부트스트랩 템플릿을 살펴보겠습니다. 변화하는 부분을 살펴보면, 다음과 같을 겁니다.
우리는 위의 템플릿을 활용할 것이므로, 위에서 어떤 곳에 어떤 내용이 들어갈지를 추측할 수 있습니다. 그러기 위해서는, post_detail.html에 해당 포스트의 정보를 넘겨주어야 합니다.
그러면, 위와 같은 코드를 작성할 수 있을 겁니다. url에서 id를 받아오고, id로 포스트를 찾아서 템플릿에 “네가 보여줄 포스트는 이거야” 라고 포스트 정보들을 모두 넘겨줄 수 있는 것이죠!
그리고 나서 테스트 코드를 돌려 볼까요? 이제는 템플릿에 포스트에 대한 정보를 뿌려주어야 한다고 알려줄 겁니다.
일단, 테스트 코드의 요구사항을 맞추기 위해서 제목이 들어갈 부분, 저자가 들어갈 부분을 div 태그로 감싸 주겠습니다. id는 각각 title_wrapper, author_wrapper 가 될 겁니다. 아래와 같이 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.contene }}
</div>
</div>
</div>
</article>
{% endblock %}
변화하는 위의 부분을 잘 살펴봅시다. 라우팅 함수에서 넘겨준 post를 바탕으로 여러가지 정보들을 가져와 뿌려주고 있죠? 테스트 코드를 돌려 보면 title-wrapper 안에 우리가 작성한 게시물 제목이 들어있는 것은 통과했을 겁니다.
그런데, 새로운 곳에서 문제가 생깁니다. 저자를 어떻게 표시해야 할까요? 예컨대, 아래와 같은 코드는 저자를 표시할 수 없습니다. 단지 저자의 id만 나타낼 뿐이죠.
우리가 render_template 로 템플릿에 정보를 넘겨줄 때에, post=post 처럼 post에 관한 것을 보내주었습니다. 그리고, 우리는 데이터를 뿌려줄 때에 post 모델에 정의되었던 칼럼들만 “.” 으로 연결해서 뿌려주었죠. 예컨대, post.title 은 아래의 정보에 접근한다는 것을 의미합니다.
그렇기에 “게시물을 작성한 저자의 id” 는 나타낼 수 있어도, “게시물을 작성한 저자의 username” 은 나타내기 쉽지 않은 것입니다. 우리가 작성한 Post 모델 클래스에서도 보듯이, author_username 과 같은 컬럼은 존재하지 않으니까요.
이 상황을 타개하는 방법은 위에서 적어놓았던 relationship 을 이용하는 것입니다. “backref” 라는 코드를 이용해서 다른 모델을 쉽게 참조할 수 있도록 하였습니다. 즉, post.user
까지 작성하면, User의 id, email, username 등에 접근할 수 있게 되는 겁니다.
그렇기 때문에, 위와 같은 코드를 사용하면 저자의 이름을 쉽게 나타낼 수 있는 것이죠! 테스트를 돌리면, 모든 테스트를 통과했다는 뜻의 OK가 나올 겁니다.
이제, 실제로 테스트를 해 볼까요? 서버를 실행,, 하기 전, 우리가 만든 두 개의 모델을 관리자 페이지에 등록시켜 줍니다.
서버를 동작시킨 후, /admin 에 접속해 봅시다. 우리가 등록한 모델들이 잘 나타나있는 것을 볼 수 있을 겁니다.
카테고리 탭에서, 카테고리를 하나 만들어 줍시다.
이후, 포스트 탭으로 이동해서 포스트도 하나 작성해 줍시다. User, Category에 약간 이상한 이름이 나오는 것은 이따가 해결하겠습니다.
그리고, 포스트 디테일 페이지를 위해서 posts/포스트의 id 로 접속해 보면, 우리가 작성한 포스트가 잘 나타나는 것을 볼 수 있을 겁니다.
이제, 실제로 폼에서 게시물을 작성해도 게시물이 잘 추가되는지 직접 확인해 볼까요?
먼저 로그인을 하지 않은 상태로 게시물 작성 페이지에 접속하면 아래의 로그인 페이지가 떠야 합니다.
로그인을 해 줍니다. 필자의 경우 username=goddessana 로 로그인하였습니다.
이제 게시물 작성 페이지에 들어가면 정상적으로 폼이 뜰 겁니다. 임의의 게시물을 하나 입력해볼까요?
우리가 관리자 페이지에서 추가했던 TodayILearned 라는 카테고리가 게시물 작성 폼의 옵션으로도 잘 뜨고 있네요.
이후, create를 누르면 홈페이지로 이동할 것이고, 두 번째 만들어진 포스트의 id는 2이므로 /posts/2 에 접속해 봅시다.
폼으로 작성된 게시물도 잘 추가된 것을 볼 수 있네요!
또, 우리가 관리자 페이지에서 카테고리를 추가했으므로 categories-list 에 접속하면, 만든 카테고리가 나와야 합니다.
정상적으로 동작하는 걸 확인할 수 있죠?
그런데, “TodayILearned” 에 테스트 코드의 경우의 수를 입력하는 것을 깜빡했네요. 우리가 원하는 건, 위의 “TodayILearned” 에 들어가면 해당 카테고리에 속한 게시물들의 리스트가 나오는 겁니다. 이번에는, 바로 구현해 보죠!
먼저, 접속하는 곳의 링크를 바꾸기 위해서는 categories-list.html의 위의 부분을 바꿔 주어야 합니다.
우리는 views.py에서 카테고리의 id를 받기로 정해 주었으므로, 아래와 같이 코드를 수정하면 되겠죠? url_for 을 통해서 url을 구축했고, category.name 으로 카테고리 이름을 뿌려주도록 처리했습니다.
이후 카테고리 리스트 페이지에서 카테고리명을 클릭하면 위의 링크로 이동할 것이고, 에러가 뜰 겁니다.
카테고리의 id를 받아서 그에 맞는 포스트들의 리스트가 필요하므로, 아래와 같은 코드를 작성할 수 있습니다. id에 맞는 카테고리, 포스트들을 인자로 넘겨주면 되겠죠?
current_category 에 현재 카테고리 정보를 담고, 템플릿에 전달해줍니다.
그러면, 다시 에러가 났던 주소로 접속했을 때에 정상적으로 페이지가 뜨는 것을 확인할 수 있을 겁니다.
이제, 이 곳에 포스트에 대한 정보를 뿌려주면 됩니다.
현재의 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 %}
일단 이 곳에는 카테고리명, 카테고리에 속한 포스트들의 개수를 보여줄 겁니다. 위에서 보내준 정보들은 “하나의 카테고리(current_category)”, “하나의 카테고리에 속하는 여러 개의 포스트(posts)” 들이었죠? 그것을 이용해서 처리해주면 될 겁니다.
그러면, 의도했던 대로 카테고리 제목과 몇 개의 포스트가 존재하는지를 뿌려줄 수 있습니다. 만세!
이제는 해당 카테고리에 속한 포스트들 리스트를 뿌려주면 됩니다. 어떻게 할 지 예상이 가지 않나요?
실제로 테스트해 보면, 해당 카테고리에 속한 게시물들을 잘 뿌려주는 걸 볼 수 있을 겁니다.
이제, 우리가 원하는 대로 게시물을 추가하고, 조회할 수 있게 되었습니다! 기본 틀은 잡은 것 같네요.
스태프 권한을 가진 사람만 게시물을 작성할 수 있도록 하기
지금까지 우리가 구현한 기능은 “회원가입되어 있고, 로그인한 사람만 글쓰기가 가능하다” 입니다. 문제는 아직 회원가입할 때에 폼에 작성하는 이메일이 진짜 가입하려는 사람 소유인지 알 수도 없고, 그렇기 때문에 누구던 우리 블로그에 접근해서 글을 도배할 수도 있을 겁니다. 이러한 부분을 해결하기 위해서 우리는 스태프 권한을 가진 사람만 글을 추가할 수 있도록 해 보겠습니다.
먼저, 테스트 코드를 조금 수정하겠습니다. 요구사항이 조금 바뀌었죠? 3번을 조금 수정해 주겠습니다.
위의 요구사항에 맞춘 테스트 코드는 아래와 같을 겁니다. TestPostWithCategory 클래스를 아래와 같이 수정해 줍니다.
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('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.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)
그리고 테스트 코드를 수행해 보면, 아래와 같은 곳에서 테스트가 통과되지 못했다고 알려주네요.
current_user 가 is_staff==True 이면 접근이 가능해야 하고, 그렇지 않다면 접근이 불가능하게끔 해야 합니다.
그것은 위와 같은 코드를 추가함으로서 해결할 수 있습니다. abort(403) 은 403 에러를 발생시킨다는 의미입니다. 이후, 테스트를 수행해 본다면 OK가 나올 겁니다.
- 테스트 코드 주의할 점 : 유저를 폼 요청이 아니라 데이터베이스에 직접 추가할 경우, 아래와 같은 로그인 전용 클라이언트를 만들어야 합니다.
게시물 Update 처리해 보기
이제는 게시물의 “수정”, “삭제” 를 처리해 보겠습니다. 테스트 코드를 먼저 작성함으로서 시작해 보죠! 요구사항을 정리해 보면, 아래와 같을 겁니다.
위의 내용을 바탕으로 테스트 코드를 작성해 봅시다.
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)
테스트 코드를 실행해 볼까요?
게시물의 작성자로 로그인한 후, 본인이 작성한 글을 조회하러 들어갔을 때에 “Edit” 버튼이 보이지 않는다고 하네요. 이 부분을 고치기 위해서, 우리는 post-detail.html 을 수정해 주어야 합니다.
테스트 코드에서는 id를 기준으로 버튼을 찾고 있으므로, 위의 코드를 입력해서 “Edit” 버튼을 추가해 줍시다.
또 테스트 코드를 돌려 보면, 아래와 같은 에러가 발생할 겁니다. 라우팅에 추가하지 않은 함수를 url_for에 사용했기 때문입니다.
이 부분을 해결하기 위해서, views.py 에 코드를 조금 추가해 주겠습니다. edit-post/post의 id 요청을 받았을 때에, 우리는 포스트를 수정할 수 있는 폼을 띄워주어야 합니다.
수정 기능을 구현하기 전, 수정은 어떻게 이루어지는지 잠시 생각해보고 가겠습니다.
- 수정은 게시물 생성과 다르게, 특정 게시물을 상대로 수정하는 것이므로 “수정할 게시물이 무엇인가” 가 필요합니다.
- 수정 페이지에 들어가서 띄워지는 폼에는, “원래 게시물” 의 내용이 채워져 있어야 합니다.
좋아요, 첫째로 “수정할 게시물이 무엇인가” 가 필요하므로, post의 id가 꼭 필요하겠죠? 전체적인 라우트 함수의 형태는 아래와 같을 겁니다.
첫째로, get 요청이 들어왔을 때에 폼을 띄워주는 코드를 작성해 봅시다.
포스트의 내용을 폼에 미리 채워서 보여줄 것이므로, 포스트는 무엇인지가 꼭 필요합니다. 게시물을 생성할 때 사용하는 생성 폼에서처럼, 카테고리 정보도 또한 필요하겠죠? 넘겨줄 정보는 다 정해두었으므로, 이제 수정 폼을 post_edit_form.html 이라는 이름으로 하나 만들어 주겠습니다.
폼의 대부분은 post_create_form 을 복사해서 사용합니다. 하나의 차이점은, “폼에 내용이 미리 채워져 있어야 한다” 는 것! 이를 위해서 html의 value, select 옵션의 selected를 이용할 겁니다.
위와 같은 로직이 적용될 수 있겠죠? 별도의 value를 추가해서 미리 채워져 있도록 하였고, 밑에서는 포스트의 카테고리 == 카테고리일 경우 미리 선택되어 있도록 하였습니다.
이후, 테스트를 수행해 보세요. 위의 부분은 해결되었고, 이제 “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 버튼이 보이는데??” 라는 에러가 발생할 겁니다. 이 부분을 처리해 보죠!
이 부분이 접속한 사람 == 글쓴이 라면 보여져야 하고, 접속한 사람 != 글쓴이 라면 보여지지 않아야 합니다.
위와 같은 템플릿 태그로 처리할 수 있겠죠? 테스트를 돌려 보면 OK라고 알려줄 겁니다.
실제로 테스트를 해 보겠습니다!
어드민페이지 접근권한 설정하기
현재, 우리가 만든 어드민 페이지는 로그인 여부에 상관없이 접근할 수 있습니다. 이번에는 이 부분을 해결해 볼 겁니다. 우리가 원하는 것은, “로그인이 되어 있고, 로그인한 유저가 is_staff==True 여야지만 관리자 페이지에 접속할 수 있어야 한다!” 입니다.
__init__.py 의 소스를 위와 같이 수정해 줍니다. 맨 처음에 모델을 등록할 때는 ModelView를 바로 사용했는데, 우리는 사용자에 따라 접근을 제한할 것이므로 위와 같은 MyAdminView 클래스를 새로 정의했네요. 두 개의 함수를 오버라이딩했는데, 그것들은 무엇일까요?
먼저, is_accessible 은 아래와 같이 정의되어 있습니다.
기본적으로 True를 리턴하고 있죠? 그렇기 때문에 모든 사용자에게 어드민 페이지에 대한 접근을 허용했던 것입니다.
그렇다면, 1. 로그인하지 않은 유저, 2. 로그인했지만 스태프 권한을 가지고 있지 않은 유저라면, 접근을 거부당해야 합니다. 회원가입 폼에서, 임의의 유저를 만들어서 테스트해 보세요. 403 에러가 발생할 겁니다.
어드민 페이지에서 모델명 제대로 나오도록 설정하기
어드민 페이지에서 이런 식으로 카테고리가 표시되는 문제가 있었습니다. 이번엔, 이 부분을 해결해 보겠습니다.
__repr__
을 정의해주면 해결됩니다.
class Category(db.Model):
# 생략 ...
def __repr__(self):
return f'<{self.__class__.__name__}(name={self.name})>'
위와 같이 __repr__ 을 정의해주면,
위처럼 어드민 페이지에 나타나는 내용을 변경할 수 있습니다.
어드민 페이지 커스텀하기
지금까지의 어드민 페이지에는 몇 개의 문제가 있습니다. 첫째로는, 유저 모델을 잘 불러올 수는 있지만 등록할 때에 문제가 있습니다.
기본적으로, 우리가 회원가입을 어떻게 처리했는지 기억나시나요? 우리는, 보안상의 이유로 hash 함수를 사용해서 비밀번호를 임의의 값으로 암호화하는 작업을 거쳤습니다. 회원가입을 하면, 회원가입 폼에 입력된 비밀번호가 암호화되어 데이터베이스에 저장되는 식이었죠!
그런데, 어드민 페이지에서는 이와는 다르게 폼에서 제출된 데이터가 암호화 작업을 거치지 않습니다.
위의 사진을 보세요. 세 번째만 어드민 페이지에서 추가한 유저인데, 암호화된 값이 아닌 날 것 그대로의 값이 들어간 걸 확인할 수 있죠?
그렇다면 문제가 생깁니다. 실제로 위의 이메일과 비밀번호로 로그인을 시도하면 로그인되지 않을 겁니다. 그 이유는 우리가 로그인을 처리했던 방식에 있습니다.
왜냐하면, 우리가 로그인을 처리할 때에는 폼에서 받아온 데이터를 암호화한 다음, 암호화한 비밀번호가 데이터베이스에 있는 값과 일치한지 검증하는 함수를 사용했기 때문입니다. 이를 해결하기 위해서는, 어드민 페이지에서 유저를 추가할 때에도 비밀번호가 암호화되도록 해야 합니다.
또, 아무래도 생성일자는 자동 생성되므로 어드민 페이지에서 관리할 필요가 없죠. 아래 보면 아시겠지만, form_excluded_columns = 를 정의해 줌으로서 원치 않는 필드를 관리자 페이지에서 생성하지 않도록 할 수 있습니다. 그것들도 함께 처리해 보겠습니다.
import click
from flask import Flask, abort
from flask.cli import with_appcontext
from sqlalchemy.exc import IntegrityError
from werkzeug.security import generate_password_hash
from wtforms import PasswordField, StringField
from wtforms.validators import InputRequired
from .models import DB_NAME, db, get_user_model, get_post_model, get_category_model, get_comment_model
from os import path
from flask_login import LoginManager, current_user
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
# app을 만들어주는 함수를 지정해 주자.
def create_app():
app = Flask(__name__) # Flask app 만들기
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'
}
class MyCommentView(ModelView):
def is_accessible(self):
if current_user.is_authenticated and current_user.is_staff == True:
return True
else:
return abort(403)
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))
admin.add_view(MyCommentView(get_comment_model(), db.session))
db.init_app(app)
from .views import views
# blueprint 등록, '/' 를 기본으로 한다.
app.register_blueprint(views, url_prefix="/")
from .auth import auth
# blueprint 등록, '/auth' 를 기본으로 한다.
app.register_blueprint(auth, url_prefix="/auth")
# DB 생성하는 함수 호출하기
from .models import User
create_database(app)
login_manager = LoginManager() # LoginManager() 객체를 만들어 준다.
login_manager.login_view = "auth.login" # 만약 로그인이 필요한 곳에 로그인하지 않은 유저가 접근할 경우, 로그인 페이지로 리다리엑트 되도록 해 준다.
login_manager.init_app(app)
# 받은 id로부터, DB에 있는 유저 테이블의 정보에 접근하도록 해 줌.
# login manager는, 유저 테이블의 정보에 접근해, 저장된 세션을 바탕으로 로그인되어 있다면 로그인 페이지로 안 가도 되게끔 해 줌.
@login_manager.user_loader
def load_user_by_id(id):
return User.query.get(int(id))
# Custom Command Line
import click
from flask.cli import with_appcontext
@click.command(name="create_superuser")
@with_appcontext
def create_superuser():
# 정보 입력받기
username = input("Enter username : ")
email = input("Enter email : ")
password = input("Enter password : ")
is_staff = True
try:
superuser = get_user_model()(
username = username,
email = email,
password = generate_password_hash(password),
is_staff = is_staff
)
db.session.add(superuser)
db.session.commit()
except IntegrityError:
print('\033[31m' + "Error : username or email already exists.")
print(f"User created! : {email}")
app.cli.add_command(create_superuser)
return app
# DB 추가
def create_database(app):
if not path.exists("blog/" + DB_NAME): # DB 경로가 존재하지 않는다면,
db.create_all(app=app) # DB를 하나 만들어낸다.
위의 코드를 입력하고, 관리자 페이지에 들어가서 임의의 유저를 추가해 보세요. 유저가 잘 추가된 것을 알 수 있을 겁니다.