TDD
테스트 주도 개발(Test Driven Development)
소프트웨어를 개발하는 여러 방법론 중 하나를 말합니다.
TDD에서는 제품의 기능 구현을 위한 코드와 별개로,
해당 기능이 정상적으로 움직이는지 검증하기 위한 테스트 코드를 작성하여
테스트가 전체 개발을 주도해 나가는 것입니다.
오늘은 코드를 수정하는 단계마다 테스트 코드를 이용해서 검증해보겠습니다.
Python에서 기본적으로 존재하는 테스트 프레임워크인 unittest 라이브러리를 알아보려 합니다.
우선 내장 모듈인 unittest를 불러와서 import 한 뒤,
클래스 이름을 지어주고, TestCase 클래스를 상속받아 테스트를 진행합니다.
import unittest
class TestSomething(unittest.TestCase):
def setUp(self):
print("테스트가 수행되기 전 자동 호출되는 메서드")
def tearDown(self):
print("테스트가 끝나고 나서 호출되는 메서드 \n")
def test_add(self):
print("첫 번째로 진짜 테스트하고 싶은 것")
self.assertEqual(1+1, 2)
# assertEqual은 1+1 == 2 인지 검사
def test_mult(self):
print("두 번째로 진짜 테스트하고 싶은 것")
setUp() - 테스트 메서드를 호출하기 바로 직전에 호출되는, 즉 테스트를 준비하기 위해 호출되는 메서드
teardown() - 테스트 메서드가 불리고 결과가 기록된 후에 호출되는 메서드
결과적으로 메서드들이 아래와 같이 동작하는 것을 확인할 수 있습니다.
코드 중에 self.assertEqual(1+1, 2) 부분이 있는데
이것은 상속받았던 TestCase 클래스가 제공하는 메서드 중 하나입니다.
따라서 assertEqual(1+1, 2) 은 1+1 == 2 가 참인지 체크하는 것입니다.
☑ 원하는 기능의 코드 작성
↘
☑ 테스트 코드 검증
이제 본격적으로 위 과정을 반복하며
관리자 페이지, 카테고리, 게시물 등을 다뤄보겠습니다.
저번 포스팅 에서 기본적으로 회원가입/로그인/로그아웃 까지 처리해봤는데요.
이번에는 더욱 넓게 생각해서
추가적으로 카테고리와 게시물을 구현한 뒤에,
카테고리와 게시물에 보여질 데이터들을 관리할 수 있도록
관리자 페이지를 만들고,
스태프 권한을 추가해보도록 하겠습니다.
스태프권한을 위한 모델 수정
사용자 중 스태프 권한이 있는 경우에만 관리자 페이지 접근을 허용하기 위해서
모델의 코드에서 마지막 줄을 추가하도록 합니다.
models.py
from . import db
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from sqlalchemy.sql import func
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True) # id : 유일 키, Integer
email = db.Column(db.String(150), unique=True) # email : 같은 이메일을 가지고 있는 유저가 없도록 함, String
username = db.Column(db.String(150), unique=True) # username : 같은 이름을 가지고 있는 유저가 없도록 함, String
password = db.Column(db.String(150)) # password : 비밀번호, String
+
# 스태프 권한이 있는 유저인지 아닌지를 판별, Boolean
create_at = db.Column(db.DateTime(timezone=True), default=func.now())
is_staff = db.Column(db.Boolean, default=False)
여태의 코드를 테스트 코드로
Test Case 1) 우리가 만든 모델이 잘 작동하는가?
def test_signup_by_datebase(self):
self.user_test_1 = get_user_model()(
email='testemail1@test.com',
username='testUser01',
password='qwerasdf',
is_staff=True
)
db.session.add(self.user_test_1)
db.session.commit()
self.user_test_2 = get_user_model()(
email='testemail2@test.com',
username='testUser02',
password='qwerasdf'
)
db.session.add(self.user_test_2)
db.session.commit()
self.assertEqual(User.query.count(), 2)
db.session.close()
첫 번째로 테스트할 것은 우리가 작성한 모델이 잘 동작하느냐입니다.
User 모델을 작성했었고, 그 유저 모델을 데이터베이스에 넣은 후, 잘 저장되었는지를 테스트합니다.
Test Case 2) 폼으로 회원가입을 진행해도 DB에 값이 잘 추가되는가?
def test_signup_by_form(self):
response = self.client.post('/auth/sign-up', data=dict(
email="testemail@test.com", username="testUser00", password1="qwerasdf", password2="qwerasdf"))
self.assertEqual(User.query.count(), 1)
db.session.close()
response 뒤의 코드는 브라우저에서 폼을 작성하고, 요청하는 것을 수행합니다.
DB가 비어있는 상태에서 폼으로 회원가입을 진행한다면 DB에 회원은 1명만이 존재해야 합니다.
따라서 한 명만이 존재한다면 = self.assertEqual(get_user_model().query.count(), 1) 테스트 코드는 OK가 나올 것 입니다.
Test Case 3) 로그인 전과 로그인 후의 네비게이션 바가 동적으로 바뀌는가?
from bs4 import BeautifulSoup
def test_before_login(self):
# 로그인 전의 네비게이션 바 크롤링
response = self.client.get('/')
soup = BeautifulSoup(response.data, 'html.parser')
navbar_before_login = soup.nav
# 로그인과 회원가입 버튼이 있는지, 로그아웃 버튼이 없는지 확인
self.assertIn("Login", navbar_before_login.text)
self.assertIn("Sign Up", navbar_before_login.text, )
self.assertNotIn("Logout", navbar_before_login.text, )
# 회원가입 후,
response = self.client.post('/auth/sign-up', data=dict(
email="testemail@test.com", username="testUser00", password1="qwerasdf", password2="qwerasdf"))
with self.client:
# 로그인
response = self.client.post('/auth/login', data=dict(
email="testemail@test.com", username="testUser00", password="qwerasdf"),
follow_redirects=True)
# 로그인 후의 네비게이션 바 크롤링
soup = BeautifulSoup(response.data, 'html.parser')
navbar_after_login = soup.nav
# 로그인 후 네비게이션 바에 유저 이름과 로그아웃 버튼이 있는지,
# 회원가입 버튼과 로그인 버튼이 있는지 확인
self.assertIn(current_user.username, navbar_after_login.text)
self.assertIn("Logout", navbar_after_login.text)
self.assertNotIn("Login", navbar_after_login.text)
self.assertNotIn("Sign up", navbar_after_login.text)
db.session.close()
네비게이션 바가 동적으로 바뀌는지 확인하기 위해서는 네이게이션 바 안에 있는 텍스트를 확인해야합니다.
근데 여기서 네이게이션 바는 html 요소인데 어떻게 확인해야 할지 생각이 들겁니다.
바로 이때 유용한 것이 BeautifulSoup입니다.
아래와 같이 모듈을 설치해주세요.
$ pip install beautifulsoup4
테스트 방법
이제 테스트를 실행해볼건데, 여기서 두가지 실행 방법을 살펴보겠습니다.
1) 터미널에서 테스트
python -m unittest tests/tests.py
저는 tests 폴더 안에 있는 tests.py 을 실행할거기 때문에 경로를 위와 같이 설정해줍니다.
2) VSCode에서의 테스트
테스트 코드에 작성된 모든 테스트 케이스들을 모두 확인하면서도,
어떤 케이스가 잘 진행되었는지,
어떤 케이스가 잘 진행되지 않았는지 알 수 있습니다.
원하는 케이스를 개별적으로 돌리는 것도 가능하고 한눈에 알아볼 수 있는 좋은 방법입니다.
위와 같이 모두 정상적으로 실행되는 것을 볼 수 있습니다.
2번째 방법을 원하신다면 아래 영상을 참고해주세요.
관리자 페이지 만들기
flask-admin을 사용하여 관리자 페이지를 간편하게 구축해보겠습니다.
우선 아래와 같이 모듈을 설치해줍니다.
$ pip install flask-admin
설치 이후, __init__.py 에 아래와 같이 추가를 해줍니다.
# 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')
그런 다음, __init.py__에 아래와 같이 수정해줍니다.
+
__init__.py
admin.add_view(ModelView(get_user_model(), db.session))
근데 위와 같은 코드를 추가하면 순환 참조 에러가 발생하게 됩니다.
에러를 해결하기 위해 아래와 같이 수정해줍니다.
🔄
models.py
from flask_login import UserMixin
from sqlalchemy.sql import func
from flask_sqlalchemy import SQLAlchemy
# init 으로부터 옮김
db = SQLAlchemy()
DB_NAME = "blog.db"
# User 클래스를 반환하는 함수 정의
def get_user_model():
return User
init.py에 있던 코드를 models.py로 옮겨줍니다.
🔄
auth.py
import logging
from flask import Blueprint, render_template, request, url_for, flash
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 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()
# 🔄 user = User.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()(
# 🔄 signup_user = User(
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()
# 🔄 email_exists = User.query.filter_by(email=form.email.data).first()
username_exists = get_user_model().query.filter_by(username=form.username.data).first()
# 🔄 username_exists = User.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("User created!!!")
return redirect(url_for("views.home")) # 저장이 완료된 후 home으로 리다이렉트
# GET요청을 보낸다면 회원가입 템플릿을 보여줌
return render_template("signup.html", form=form, user=current_user)
User을 get_user_model()로 바꿔줍니다.
서버를 동작시킨 후, /admin으로 접속해봅시다.
실제로 관리자 페이지를 보면 User 탭이 추가가 되어있는 것을 확인 할 수 있습니다.
카테고리 / 게시물 구현
카테고리와 게시물의 관계에 대해 생각을 해보겠습니다.
저의 블로그를 생각해보면.
여러개의 카테고리와 그 카테고리 중 하나에 여러개의 게시물이 존재합니다.
즉, 카테고리와 게시물
유저와 게시물
일대다 관계입니다.
우선 ORM을 이용하여 먼저 게시물(Post) 모델을 만들어 보겠습니다.
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True) # id : 유일 키, integer
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text(), nullable=False)
created_at = db.Column(db.DateTime(timezone=True), default=func.now()) # 생성일자, 기본적으로 현재가 저장되도록 함
author_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) # 외래 키, user 테이블의 id 참조할 것임
user = db.relationship('User', backref = db.backref('posts', cascade='delete'))
category_id = db.Column(db.Integer, db.ForeignKey('category.id', ondelete='CASCADE'), nullable=False) # 외래 키, category 테이블의 id 참조할 것임
category = db.relationship('Category', backref=db.backref('category', cascade='delete'))
# comments = db.relationship("Comment", backref="post", passwive_deletes=True)
여기서 author_id = ... 부분은 user테이블의 id를 참조하겠다는 뜻이고,
user = ... 부분은 역참조 코드인데, 아래 부분을 보면 자세하게 나와있습니다.
더 자세한 부분을 이해하고싶다면 아래링크를 참고해주세요.
author_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) # 외래 키, user 테이블의 id 참조할 것임
위 코드를 다시 살펴보면 ondelete=’CASCADE” 는 저자 모델이 삭제되었을 때에
그 저자가 쓴 포스트가 모두 삭제되도록 하는 것을 의미하고,
nullable=False 를 통해서 게시물에는 무조건 저자가 있어야 한다는 것을 의미합니다.
이후, 카테고리 모델도 아래와 같이 작성해 줍니다.
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True) # 유일 key
name = db.Column(db.String(150), unique=True)
def __repr__(self):
return f'<{self.__class__.__name__}(name={self.name})>'
HTML Form 작성하기
게시물을 작성하기 위해선 서버에 데이터를 보내야하기 때문에 POST메소드를 사용하는 html form을 작성해보겠습니다.
기존에 있었던 contact.html 파일의 이름을 post_create_form.html 으로 바꾸어 아래와 같이 작성해줍니다.
{% extends 'base.html' %}
{% block title %}Create a Post{% endblock %}
{% block header %}
<header class="masthead"
style="background-image: url(''); 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>
{% for category in categories %}
<option value="">{{ 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 %}
실제로 아래와 같이 select 태그에 카테고리가 없는걸로 보입니다.
여기서 새로운 테스트를 추가해 보겠습니다.
1. 임의의 카테고리를 넣어본 후, 데이터베이스에 카테고리가 잘 추가되어 있는지 확인
2. 카테고리를 넣은 후, /categories-list 에 접속했을 때, 넣었던 카테고리들이 잘 추가되어 있는지 확인
3. 게시물을 작성할 때에, 로그인하지 않았다면 접근이 불가능해야 함
4. 임의의 카테고리를 넣어본 후,
웹 페이지에서 폼으로 게시물을 추가할 때에 option 태그에 값이 잘 추가되는지,
게시물을 추가한 후 게시물을 잘 추가되어 있는지,
저자는 로그인한 사람으로 추가되어 있는지 확인
위에는 테스트 코드를 사용함으로서, 검증해야할 부분들을 적어놓은 것이고,
그 부분들을 만족하는 테스트 코드를 작성해보겠습니다.
TestPostwithCategory
TestPostwithCategory 클래스를 아래와 같이 추가해줍니다.
class TestPostwithCategory(unittest.TestCase):
def setUp(self):
self.ctx = app.app_context()
self.ctx.push()
self.client = app.test_client()
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.create_all(app=app)
db.session.close()
# 다른 테스트들에 영향을 줄 수 있기 때문에 테스트 후 데이터베이스 삭제
def tearDown(self):
os.remove('tests/test.db')
self.ctx.pop()
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()
# 추가한 카테고리의 이름이 "python" 인지 확인
self.assertEqual(get_category_model().query.first().name, "python")
# id가 1인지 확인
self.assertEqual(get_category_model().query.first().id, 1)
# "rust" 카테고리 추가
self.rust_category = get_category_model()(
name="rust"
)
db.session.add(self.rust_category)
db.session.commit()
# id가 2인 카테고리의 이름이 "rust" 인지 확인
self.assertEqual(get_category_model().query.filter_by(id=2).first().name,
"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')
# 서버에 get 요청을 보냈을 때에, 정상적으로 응답한다는 상태 코드인 200을 돌려주는지 확인
self.assertEqual(response.status_code, 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)
# 게시물을 폼에서 작성한 후, 데이터베이스에 남아 있는 게시물의 수가 1개인지 확인
self.assertEqual(1, get_post_model().query.count())
# 게시물은 잘 추가되어 있는지 확인
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()
오류 1)
테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
error 내용을 보면 'python'이 카테고리 리스트에서 찾을 수 없다고 나옵니다.
리스트 페이지에 카테고리가 표시되지 않고 있다는 것을 알 수 있습니다.
카테고리 리스트 페이지에 접속하는 것이기 때문에
views.py/categories_list() 에 카테고리들을 context로 넘겨주어서,
페이지에 접속하면 그 값을 보여주어야 합니다.
그렇기 때문에 categories-list.html 와 views.py 를 아래와같이 작성해주도록 합니다.
views.py
@views.route("/categories-list")
def categories_list():
categories = get_category_model().query.all()
return render_template("categories_list.html",user=current_user, categories=categories)
categories-list.html
{% 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-7">
{% for category in categories %}
<!--Category item-->
<div class="post-preview">
<a href="{{ url_for('views.post_list', id=category.id) }}">
<h2 class="post-title">{{ category.name }}</h2>
</a>
</div>
<!-- Divider-->
<hr class="my-4" />
{% endfor %}
</div>
</div>
</div>
{% endblock %}
오류 2)
테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
error 내용을 보면 의도되었던 응답 상태 코드는 302인데, 200으로 접근했기 때문에
테스트를 통과하지 못했다고 적혀 있습니다.
이것은 로그아웃을 처리했던 부분과 똑같이 처리할 수 있습니다.
아래와 같이 데코레이터를 추가해줍니다.
@views.route("/create-post")
@login_required # 추가
def create_post():
return render_template("post_create_form.html", user=current_user, categories=categories)
오류 3)
테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
error 내용을 보면 'python'이 'select a category'에 없다고 나옵니다.
즉, select 태그에 카테고리가 표시되지 않고있음을 말합니다.
마찬가지로, 모든 카테고리를 컨텍스트로 넘겨준 뒤에, 포스트 작성 페이지에서 보여주도록 하겠습니다.
views.py 와 post_create_form.html 을 아래와같이 작성해주도록 합니다.
views.py
@views.route("/create-post")
@login_required
def create_post():
categories = get_category_model().query.all()
return render_template("post_create_form.html", user=current_user, categories=categories)
post_create_form.html
<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.name }}</option>
{% endfor %}
</select>
</div>
오류 4)
error 내용을 보면 데이터베이스에 있는 게시물의 수가 1개여야 하는데,
실제로는 0이라고 하고 있습니다.
이 부분은 계속해서 다음 부분에서 해결해보겠습니다.
게시물 작성하기
게시물 작성은 저번에 처리했던 로그인 작업과 유사합니다.
폼을 통해 게시물을 작성하는 것으로 폼에서 받아온 데이터를 검증하고,
그것을 데이터 베이스에 저장하면 될 것 입니다.
우선 데이터 검증을 위해 forms.py 에 아래와 같이 추가를 해줍니다.
class PostForm(FlaskForm):
title = StringField('title', validators=[DataRequired()])
content = TextAreaField('content', validators=[DataRequired()])
category = StringField('category', validators=[DataRequired()])
form으로부터 받아온 검증된 데이터를 데이터베이스에 저장하고, 저장을 완료하고 home으로
리다이렉트 해주는 코드를 views.py에 아래와 같이 작성해줍니다.
@views.route("/create-post", methods=['GET', 'POST'])
@login_required
def create_post():
form = PostForm()
if request.method == "POST" and form.validate_on_submit():
post = get_post_model()(
title=form.title.data,
content=form.content.data,
category_id=form.category.data,
author_id=current_user.id,
)
db.session.add(post)
db.session.commit()
return redirect(url_for("views.home"))
else:
categories = get_category_model().query.all()
return render_template("post_create_form.html", user=current_user, categories=categories, form=form)
그리고 게시물 작성 폼에서 아래와 같이 추가해줍니다.
<form id="createForm" method="POST">
{{ form.csrf_token }}
<div style="margin: 20px;">
포스트는 성공적으로 테이블에 추가가 될겁니다.
이제 테이블에 있는 게시물의 id로 포스트 정보를 보여주도록 할 겁니다.
views.py 에 아래와 같이 추가해줍니다.
@views.route('/posts/<int:id>')
def post_detail(id):
post = get_post_model().query.filter_by(id=id).first()
return render_template("post_detail.html", user=current_user, post=post)
테이블에 있는 정보를 뿌려주는 과정을 위의 라우팅에서 처리할 겁니다.
이제 템플릿에 포스트에 대한 정보를 뿌려주어야 할 겁니다.
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 %}
위에 보시면 저자명에 대한 부분은 아직 처리를 못한 상태입니다.
여태 받아온 데이터에는 저자의 이름이 아닌 id만이 있습니다.
저자의 이름을 원한다면 모델 클래스를 먼저 살펴봐야 합니다.
class Post(db.Model):
id = db.Column(db.Integer, primary_key=True) # id : 유일 키, integer
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.Text(), nullable=False)
created_at = db.Column(db.DateTime(timezone=True), default=func.now()) # 생성일자, 기본적으로 현재가 저장되도록 함
author_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) # 외래 키, user 테이블의 id 참조할 것임
user = db.relationship('User', backref = db.backref('posts', cascade='delete'))
category_id = db.Column(db.Integer, db.ForeignKey('category.id', ondelete='CASCADE'), nullable=False) # 외래 키, category 테이블의 id 참조할 것임
category = db.relationship('Category', backref=db.backref('category', cascade='delete'))
# comments = db.relationship("Comment", backref="post", passwive_deletes=True)
위를 보시면 저자의 id는 있지만 username을 알 수 있는 코드가 없음을 알 수 있습니다.
user = db.relationship('User', backref = db.backref('posts', cascade='delete'))
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 모델을 참조하는 것을 알 수 있습니다.
때문에 쉽게 User 모델에 접근할 수 있습니다.
그렇다면 저자명에 들어갈 부분은 아래와 같이 수정해주면 됩니다.
<div id="author-wrapper"><p>Posted by : <a href="#!">{{post.user.username}}</a></p></div>
관리자페이지에 모델 등록
그 다음 위에서 만들었던 Post와 Category 모델을 관리자 페이지에 등록을 시켜줍니다.
우선 클래스를 반환하는 함수를 models.py에 아래와 같이 정의해줍니다.
def get_user_model():
return User
# Post 클래스를 반환하는 함수 정의
def get_post_model():
return Post
# Category 클래스를 반환하는 함수 정의
def get_category_model():
return Category
그런 다음, __init__.py에 아래와 같이 모델을 등록해줍니다,
admin.add_view(ModelView(get_user_model(), db.session)) # get_user_model 로 유저 클래스를 가져옴
+
admin.add_view(ModelView(get_post_model(), db.session))
admin.add_view(ModelView(get_category_model(), db.session))
TEST
이제 실제로 서버를 실행해서 확인을 해보겠습니다.
서버를 동작시킨 후, /admin으로 접속해봅시다.
보시면 탭이 잘 추가된 걸 확인 할 수 있습니다.
여기서 Category와 Post를 하나씩 만들어 주겠습니다.
이제 리스트 페이지에 들어가봅시다.
위와 같이 카테코리가 잘 추가되어 있습니다.
이제 포스트도 잘 작성 되었는지 /posts/1 로 접속해보겠습니다.
이번에는 관리자 페이지가 아닌 게시물을 직접 작성해보겠습니다.
CREATE버튼을 누르고 포스트가 잘 작성 되었는지 /posts/2 로 접속해보겠습니다.
위와 같이 잘 작동하는 것을 확인 할 수 있습니다.
게시물 리스트 보여주기
위에 보시면 'food' 라는 카테고리 안에 '빵' 과 '면' 이 있을 겁니다.
'food' 에 속한 리스트 '빵' 과 '면' 을 보여주고,
원하는 리스트를 클릭할 경우 해당 게시물을 보여주도록 구현해보겠습니다.
우선 카테고리 id를 url에 입력 받을 수 있도록
views.py에 아래와 같이 추가를 해줍니다.
@views.route("/post-list/<int:id>")
def post_list():
return render_template("post_list.html", user=current_user)
categories_list.html 에서 원하는 카테고리를 클릭할 경우
카테고리 id에 해당하는 post-list 페이지로 갈 수 있도록 해주었습니다.
추가로 categories_list.html 에 아래와 같이 수정해줍니다.
{% for category in categories %}
<!-- Category item-->
<div class="post-preview">
<a href="{{ url_for('views.post_list', id=category.id) }}">
<h2 class="post-title">{{ category.name }}</h2>
</a>
</div>
<!-- Divider-->
<hr class="my-4"/>
{% endfor %}
포스트 리스트 페이지 템플릿에 현재 카테고리 정보를 전달해주면 됩니다.
@views.route("/post-list/<int:id>")
def post_list(id):
current_category = get_category_model().query.filter_by(id=id).first() # +
posts = get_post_model().query.filter_by(category_id=id) # +
return render_template("post_list.html", user=current_user, posts=posts, current_category=current_category)
이제 포스트 리스트 페이지를 생성 후 아래와 같이 작성해주세요.
{% 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>Category : {{ current_category.name }} </h1>
<span class="subheading">({{ posts.count() }}) 개의 포스트가 있습니다.</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-->
{% for post in posts %}
<div class="post-preview">
<a href="{{ url_for('views.post_detail', id=post.id) }}">
<h6 class="post-title">{{ post.title }}</h6>
</a>
<p class="post-meta">
Posted by
<a href="#">{{ post.user.username }}</a>
on {{ post.created_at }}
</p>
</div>
{% endfor %}
<!-- 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 %}
실제로 실행해보면 아래와 같이 잘 추가 된 것을 확인할 수 있습니다.
이제 원하는 대로 게시물을 추가 후 조회를 할 수 있습니다.
근데 여기까지는 사용자가 로그인을 한 경우 글쓰기가 가능하도록 구현한 것이기 때문에
아무개나 회원가입을 하고 게시물을 쓴다면 관리하기 어려울 수 있습니다.
때문에 스태프 권한을 가진 사람만 글을 추가할 수 있도록 해보겠습니다.
스태프 권한을 가진 사람만
3. 게시물을 작성할 때에, 로그인하지 않았고, 스태프 권한을 가지고 있지 않다면 접근이 불가능해야 함
- 스패트 권한을 가지고 있지 않은 사용자 1명, 게시물 작성 페이지에 접근할 수 없어야 함
- 스태프 권한을 가지고 있는 사용자 1명, 게시물 작성 페이지에 접근할 수 있어야 함
스태프 권한을 추가해줌으로서, 새로운 조건이 생기게 되었습니다.
그렇다면 위의 조건에 맞게 테스트 코드도 수정해주어야합니다.
아래와 같이 TestPostWithCategory 클래스를 아래와 같이 수정해 줍니다.
class TestPostwithCategory(unittest.TestCase):
def setUp(self):
self.ctx = app.app_context()
self.ctx.push()
self.client = app.test_client()
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.create_all(app=app)
db.session.close()
# 다른 테스트들에 영향을 줄 수 있기 때문에 테스트 후 데이터베이스 삭제
def tearDown(self):
os.remove('tests/test.db')
self.ctx.pop()
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()
# 추가한 카테고리의 이름이 "python" 인지 확인한다.
self.assertEqual(get_category_model().query.first().name, "python")
# id는 1로 잘 추가되어있는지 확인한다.
self.assertEqual(get_category_model().query.first().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)
# 게시물을 폼에서 작성한 후, 데이터베이스에 남아 있는 게시물의 수가 1개가 맞는가?
self.assertEqual(1, get_post_model().query.count())
# 게시물은 잘 추가되어 있는지?
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()
스태프권한 보유자만 접근
테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
current_user가 staff 권한이 있을 때만 접근이 가능해야 하고,
권한이 없다면 접근이 불가능하게끔 해야하는데
위에는 권한이 없을 때도 접근이 가능하다는 오류로 볼 수 있습니다.
이는 views.py 에 create_post() 에 조건문만 아래와 같이 간단하게 추가해주면 해결이 됩니다.
@views.route("/create-post", methods=['GET', 'POST'])
@login_required
def create_post():
if current_user.is_staff == True:
form = PostForm()
if request.method == "POST" and form.validate_on_submit():
post = get_post_model()(
title=form.title.data,
content=form.content.data,
category_id=form.category.data,
author_id=current_user.id,
)
db.session.add(post)
db.session.commit()
return redirect(url_for("views.home"))
else:
categories = get_category_model().query.all()
return render_template("post_create_form.html", user=current_user, categories=categories, form=form)
else:
return abort(403)
게시물 Update 처리
게시물을 수정 처리 한다고 가정했을 때,
요구사항은 아래와 같을 것 입니다.
임의의 유저를 2명 생성
유저1 으로 로그인 후, 폼에서 게시물을 하나 생성
유저2 로 로그인한 상태에서 유저1 가 작성한 게시물에 들어갔을 때에, "수정하기" 버튼이 보여야 함
수정하기 버튼을 누르고 수정 페이지에 들어가면 ,폼에 원래 내용이 채워져 있어야 함
이후 폼에서 내용을 바꾸고 수정하기 버튼을 누르면, 수정이 잘 되어야 함
유저1 에서 로그아웃 후, 유저2 로 로그인 후 유저1 가 작성한 게시물에 들어갔을 때에,
"수정하기" 버튼이 보이지 않아야 함
2유저가 1유저가 작성한 게시물을 수정하려 한다면(url로 접근하려 한다면), 거부되어야 함
위의 조건에 맞게 테스트 코드를 추가해보도록 하겠습니다.
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="python1" # id == 1
)
db.session.add(self.python_category)
db.session.commit()
self.javascript_category = get_category_model()(
name="javascript1" # 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" 버튼이 보여야 함
# smith 가 본인이 작성한 포스트에 수정하기 위해서 접속하면,
response = smith.get('/edit-post/1')
# 정상적으로 접속할 수 있어야 함, status_code==200이어야 함
self.assertEqual(200, response.status_code)
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_wrapper.text, "안녕하세요,smith가 작성한 게시물을 수정합니다.")
self.assertIn(content_wrapper.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()
오류 1)
추가한 후, 테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
게시물의 작성자로 로그인한 후, 글을 조회하러 들어갔을 때에 "Edit" 버튼이 보이지 않는다고 뜹니다.
아직 "Edit" 버튼이 없기 때문에 그렇습니다.
post_detail.html 에 아래 같이 코드를 추가해줍니다.
<button class="btn btn-info" id="edit-button">
<a href="{{ url_for("views.edit_post", id=post.id) }}">Edit</a>
</button>
오류 2)
추가한 후, 테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
라우팅에 추가하지 않은 함수를 url_for에 사용했기 때문입니다.
post/post의 id 요청을 받았을 때에, 우리는 포스트를 수정할 수 있는 폼을 띄워줄 수 있도록
views.py 에 아래와 같은 라우트 함수를 추가하도록 하겠습니다.
@views.route("/edit-post/<int:id>", methods=["GET", "POST"])
@login_required
def edit_post(id):
# post = db.session.query(get_post_model()).filter_by(id=id).first()
post = get_post_model().query.filter_by(id=id).first()
form = PostForm()
categories = get_category_model().query.all()
if current_user.is_staff == True and current_user.username == post.user.username:
if request.method == "GET":
# 원래 게시물 내용
return render_template("post_edit.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"))
else:
abort(403)
폼에 있었던 항목들을 복사해서 사용해주도록 합니다.
post_create_form.html 있던 내용들을 이용해서
post_edit.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/contact-bg.jpg') }}'); 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" 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 %}
별도의 value를 추가해서 폼에서 받아온 테이터들이 그대로 미리 채워져있도록 하였습니다.
오류 3)
테스트 코드를 돌려보면 아래와 같이 오류가 나는 것을 확인할 수 있습니다.
게시물 작성자가 아닌 사람이 게시물에 접속했을 때 수정하기 버튼이 보여서 그렇습니다.
그렇다면 버튼 부분에 if문을 감싸주면 될겁니다.
post_detail.html 에 아래 같이 코드를 수정해줍니다.
{% if post.user.username == current_user.username %}
<button class="btn btn-info" id="edit-button">
<a href="{{ url_for("views.edit_post", id=post.id) }}">Edit</a>
</button>
{% endif %}
여기까지 수정까지 잘 작동할 것 입니다.
근데 제일 중요한 부분이 있습니다.
지금은 로그인을 했든, 안 했든, 상관없이 관리자 페이지에 접속할 수 있습니다.
관리자페이지 접근 권한 설정
로그인이 되어있고, 로그인 한 사용자가
스태프 권한을 가진 경우에만 관리자 페이지에 접근 할 수 있어야합니다.
__init__.py 에 아래와 같이 추가해줍니다.
# flask-admin에 model 추가
# ModelView 클래스를 상속받아 MyAdminView 클래스를 만든다.
class MyAdminView(ModelView):
def is_accessible(self):
return current_user.is_authenticated and current_user.is_staff == True
def inaccessible_callback(self, name, **kwargs):
return abort(403)
admin.add_view(MyAdminView(get_user_model(), db.session))
admin.add_view(MyAdminView(get_post_model(), db.session))
admin.add_view(MyAdminView(get_category_model(), db.session))
ModelView를 상속 받은 MyAdminView 클래스를 만들었고,
current_user.is_authenticated 은 로그인 상태인지
current_user.is_staff 는 스태프 권한이 있는지
체크하는 것으로 스태프 권한이 있는 유저만 접근 가능하도록 했습니다.
이제 테스트 코드를 돌려보면 아래와 같이 모두 OK로 잘 작동하는 걸 확인할 수 있습니다.
이제 실제로 서버를 동작시켜 보겠습니다.
포스트를 하나 작성해줍니다.
작성자로 접속했을 때 "Edit" 버튼이 보이는 것을 확인할 수 있습니다.
"Edit" 버튼을 누르고 수정을 해줍니다.
수정이 잘 되어있는 걸 확인할 수 있습니다.
관리자 페이지에도 접근이 잘 작동하는지 확인해보겠습니다.
로그아웃을 한 후에 접근해보도록 하겠습니다.
그럼 위와 같이 "요청한 리소스에 액세스할 수 있는 권한이 없습니다" 라는 메시지가 뜨면서 접근이 막히게 됩니다.
이제 스태프 권한이 있는 계정으로 접근해보도록 하겠습니다.
위와 같이 관리자페이지가 잘 들어가지는 것을 확인할 수 있습니다.
근데 여기서 이상한 부분은 패스워드가 해싱되어있지 않는 것을 볼 수 있습니다.
첫 번째 계정은 관리자 페이지를 통해 만든 계정이고
두 번째 계정은 실제 폼에서 데이터를 전달하여 만든 계정입니다.
두 번째 계정만 해싱처리가 되어있는 이유는
저번에 회원가입 처리를 했을 때 해싱처리를 했기 때문입니다.
관리자 페이지 커스텀
그렇다면 회원가입을 처리했던 방식처럼
__init__.py에 아래와 같이 코드를 수정해줍니다.
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'
}
새로운 계정을 등록하고 다시 확인해보면
해싱처리가 잘 되어 있는 것을 알 수 있습니다.
이제 마지막으로
원치 않는 필드를 관리자 페이지에서 생성하지 않도록
User 탭 뿐만 아니라,
Post 탭과 Category 탭도 접근 권한을 설정해주면 됩니다.
__init.py__ 에 아래와 같이 수정해주도록 하겠습니다.
class MyUserView(ModelView):
def is_accessible(self):
if current_user.is_authenticated and current_user.is_staff == True:
return True
else:
return True
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 True
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 True
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))
이상으로 테스트 코드 / 관리자 페이지 / 카테고리, 게시물 구현까지 다뤄봤습니다!
'flask' 카테고리의 다른 글
flask clone coding [1] (Flask-SQLAlchemy 3.0 변경사항) (0) | 2022.11.06 |
---|---|
CORS란? 그리고 해결방법은? / CSR vs SSR (0) | 2022.11.02 |
Flask - 회원가입/로그인/로그아웃 처리 [블로그 웹 애플리케이션 개발] (0) | 2022.07.27 |
Flask - 라이브러리 설치부터 정적 파일 다루기까지 ( Blueprint / jinja template engine / render_template() ) (0) | 2022.07.11 |
Flask - python으로 쉽게 데이터베이스 다루기 (0) | 2022.07.07 |