-
Flask Project - MyBlog views(post 생성,수정,삭제) with unittest프로젝트 2024. 4. 11. 19:11반응형
지금까지 blog 메인 홈페이지(index.html)과 회원가입, 로그인, 로그아웃 기능을 구현 및 테스트까지 해보았습니다. 이제 사용자가 작성한 글을 저장하기 위한 Post 모델과, 관리자만 추가 가능한 Category 모델을 생성해보겠습니다. 그리고 Post 생성, 수정, 삭제 기능 및 테스트까지 구현해보겠습니다.
1. Post, Category 모델 생성
Post는 제목, 본문, 저자, 카테고리, 생성 날짜가 필요합니다. 여기서 제목, 본문, 카테고리는 사용자에게 직접 입력을 받을 예정이고, 저자와 생성날짜는 서버에서 직접 처리할 예정입니다. 해당 정보를 기반으로 다음 단계에서 PostForm을 생성해보겠습니다.
Post model에서 가장 중요한 개념은 ForeignKey와 relationship입니다. 보통 외래 키(Foreign Key)는 부모 테이블의 기본 키(Primary Key)를 참조하여 자식 테이블과의 관계를 정의하는데 사용합니다. 그리고 이 때 relationship을 통해 명시적으로 관계를 설정할 수 있고, 양 테이블을 서로 연결하기 위해 back_populates 설정도 할 수 있습니다.
본문에서 author_id 필드는 user 테이블을 참조하는 값만 가질 수 있고 user 테이블에서 수정, 삭제될 경우 post 테이블의 데이터도 수정, 삭제되어 데이터 일관성을 유지할 수 있습니다. 또한 외래 키를 기반으로 테이블을 조인하여 관련된 데이터를 검색할 수 있습니다. 그리고 post에서는 post.user를 통해 user 객체에 접근 가능하고, user에서는 user.user_posts를 통해 post 객체로 접근 가능합니다.
Category는 관리자계정에서 직접 생성할 예정이므로, 카테고리 이름만 저장하면 됩니다.
모델 생성시 가장 중요한 부분은 relationship 설정입니다. relationship 설정을 통해 지연 로딩 유무를 설정할 수 있고, 이 설정으로 쿼리 최적화가 이루어지게됩니다.
lazy 속성의 기본값은 select(or True)로, 해당 필드 객체를 함께 가져오게됩니다. 해당 방법을 eager-loading 라고 합니다. eager-loading에는 select, selectin이 있는데, selectin은 1대 N 관계에서 N개의 객체를 한번에 가져올 수 있기 때문에 더 효율적입니다.
다른 방법으로는 lazy-loading(지연로딩)이 있고, lazy = 'dynamic'로 구현할 수 있습니다. 지연로딩은 해당 필드값을 AppenderQuery 객체로 반환한 뒤 참조 or 역참조를 진행할 경우 쿼리가 실행되면서 데이터를 가져오게됩니다.
아래는 models.py 전문입니다. 주석을 잘 읽어주세요.
# blog/models.py from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from flask_migrate import Migrate from datetime import datetime, timedelta, timezone from werkzeug.security import generate_password_hash # 한국 시간대 오프셋(UTC+9)을 생성합니다. KST_offset = timezone(timedelta(hours=9)) db = SQLAlchemy() migrate = Migrate() # relationship(관계 맺는 모델 이름, back_populates=연결 필드 이름) # Cascade = 1:N 관계에서 1쪽에 설정 # all = 모두 # save-update = session에 변경 add 시, 연결된 모든 객체도 session에 add # delete = 삭제될 때만 # delete-orphan = delete + 관계가 끊길 때도 추가 삭제 # lazy='selectin': 주 객체를 가져오는 쿼리에 관련된 모든 객체를 가져오는 서브쿼리를 사용하여 즉시 로드 # lazy='dynamic': 지연로딩 설정 가능 = 쿼리 객체 반환 후 사용시 쿼리 실행됨 # 실제 사용할 때는 lazy='dynamic'설정 후 sqlalchemy.orm.selectinload 메소드 사용 = N+1 문제 해결 # ForeignKey(다른 테이블의 컬럼 이름, 삭제 옵션) # db.backref => 반대쪽 모델에서 이 모델로 역참조 들어올 때, 타고 들어올 속성 명 # User <=> Post : 1:N 관계 -> Post(N) 쪽에 relationship 설정 # Category <=> Post : 1:N 관계 -> Post(N) 쪽에 relationship 설정 # flask-login 사용하기 위해 UserMixin 상속 class User(db.Model, UserMixin): __tablename__ = 'user' # 테이블 이름 명시적 선언 id = db.Column(db.Integer, primary_key=True) # primary key 설정 username = db.Column(db.String(150), unique=True) # username unique email = db.Column(db.String(150), unique=True) # email unique password = db.Column(db.String(150)) # password date_created = db.Column(db.DateTime, default=datetime.now(KST_offset)) # 회원가입 날짜, 시간 기록 post_create_permission = db.Column(db.Boolean, default=False) # 글 작성 권한 여부 admin_check = db.Column(db.Boolean, default=False) # 관리자 권한 여부 user_posts = db.relationship('Post', back_populates='user', cascade='delete, delete-orphan', lazy='dynamic') def __init__(self, username, email, password, post_create_permission=False, admin_check=False): self.username = username self.email = email self.password = generate_password_hash(password) self.date_created = datetime.now(KST_offset) self.post_create_permission = post_create_permission self.admin_check = admin_check def __repr__(self): return f'{self.__class__.__name__} {self.id}: {self.username}' class Post(db.Model): __tablename__ = 'post' # 테이블 이름 명시적 선언 id = db.Column(db.Integer, primary_key=True) # 글 고유 번호 title = db.Column(db.String(150), nullable=False) # 제목 content = db.Column(db.Text, nullable=False) # 본문 내용 date_created = db.Column(db.DateTime, default=datetime.now(KST_offset)) # 글 작성 시간 author_id = db.Column(db.Integer, db.ForeignKey('user.id', name='fk_post_user', ondelete='CASCADE'), nullable=False) category_id = db.Column(db.Integer, db.ForeignKey('category.id', name='fk_post_category', ondelete='CASCADE'), nullable=False) user = db.relationship('User', back_populates='user_posts') category = db.relationship('Category', back_populates='category_posts') def __init__(self, author_id, title='', content='', category_id=1): self.title = title self.content = content self.date_created = datetime.now(KST_offset) self.author_id = author_id self.category_id = category_id def __repr__(self): return f'{self.__class__.__name__} {self.id}: {self.title}' class Category(db.Model): __tablename__ = 'category' # 테이블 이름 명시적 선언 id = db.Column(db.Integer, primary_key=True) # 메뉴 고유 번호 name = db.Column(db.String(150), unique=True) # 메뉴 이름 category_posts = db.relationship('Post', back_populates='category', cascade='delete, delete-orphan', lazy='dynamic') def __repr__(self): return f'{self.__class__.__name__} {self.id}: {self.name}' def get_model(arg): models = { 'user': User, 'post': Post, 'category': Category, } return models[arg]
2. PostForm 생성
사용자로부터 정보 입력 받을 때, 저자는 현재 로그인한 유저를 자동으로 입력으로 채워넣으면 되지만 사용자화면에서도 확인할 수 있도록 readonly 형태로 필드를 추가했습니다. 이 때 서버측에서 author 폼 데이터는 사용하지 않고 current_user.id 를 사용할 것이기 때문에 validators는 사용하지 않았습니다.
생성자 오버라이드를 통해 category_id 필드에는 현재 존재하는 모든 카테고리를 동적으로 추가하고, author 필드에는 현재 사용자의 username을 넣어줬습니다.
# blog/forms.py from flask_login import current_user from flask_wtf import FlaskForm from wtforms import SelectField, StringField, EmailField, PasswordField, TextAreaField from wtforms.validators import DataRequired, Email, Length, EqualTo from blog.models import get_model class SignUpForm(FlaskForm): username = StringField('username', validators=[DataRequired('사용자 이름은 필수로 입력해야 합니다.'), Length(3,30, '사용자 이름이 너무 짧거나 깁니다.')]) email = EmailField('email', validators=[DataRequired('사용자 이메일은 필수로 입력해야 합니다.'), Email('이메일 형식을 지켜주세요.')]) password = PasswordField('password', validators=[DataRequired('비밀번호를 입력해주세요.'), Length(6, 30, '비밀번호 길이가 너무 짧거나 깁니다.'), EqualTo("password_check", message="비밀번호가 일치해야 합니다.")]) password_check = PasswordField('password_check', validators=[DataRequired()]) class LoginForm(FlaskForm): email = EmailField('email', validators=[DataRequired('이메일을 입력해주세요'), Email('이메일 형식을 지켜주세요.')]) password = PasswordField('password', validators=[DataRequired('비밀번호를 입력해주세요.'), Length(6, 30, '비밀번호 길이가 너무 짧거나 깁니다.')]) class PostForm(FlaskForm): title = StringField('title', validators=[DataRequired('제목을 작성해주세요.')]) content = TextAreaField('content', validators=[DataRequired('본문을 작성해주세요.')]) category_id = SelectField('category', coerce=int, validators=[DataRequired('카테고리를 지정해주세요.')]) author = StringField('author', render_kw={'readonly': True}) def __init__(self, *args, **kwargs): # 선택 항목 추가 super(PostForm, self).__init__(*args, **kwargs) self.category_id.choices = [(category.id, category.name) for category in get_model('category').query.all()] self.author.data = current_user.username
3. PostAdmin, CategoryAdmin 생성
이제 Post와 Category 모델을 관리할 수 있는 PostAdmin, CategoryAdmin 모델을 생성해보겠습니다.
Flask-admin에서는 클라이언트용으로 만들어둔 엔드포인트를 활용하지 않고, default로 설정된 내부 로직에 의해 모델을 생성, 수정, 삭제합니다. 그래서 해당 페이지를 수정하기 위해서는 ModelView를 상속한 클래스에서 form 데이터를 생성하거나 model을 직접 다루는 등 메서드 오버라이드가 필요합니다.
모든 Admin 클래스 공통적으로 접근 권한 확인하는 메서드를 AdminBase에서 오버라이드 해둡니다. 그리고 각 클래스에서 공통적으로, 모델 메인 페이지에서 표시할 필드(열)을 튜플 형태로 나열해줍니다.
PostAdmin에서는 PostForm을 form 데이터로 직접 활용합니다. 다만 author 필드는 username을 출력하기 위해 필요한 데이터일 뿐, 모델 생성에는 필요하지 않으므로 on_model_change 메소드 오버라이드를 통해 author_id를 설정해줍니다.
CategoryAdmin에서는 Post 모델에서 추가한 relationship 역참조(backref)에 의해 추가된 category_posts 필드를 제외시켜줍니다. 해당 필드는 post가 추가될 때 자동으로 추가되는 필드기때문에 관리자 페이지 Form으로 추가할 수 없도록 해줘야합니다.
# blog/admin_models.py from flask import abort from flask_admin.contrib.sqla import ModelView from flask_login import current_user from werkzeug.security import generate_password_hash from wtforms import BooleanField from blog.forms import SignUpForm, PostForm from blog.models import get_model class AdminBase(ModelView): def is_accessible(self): if current_user.is_authenticated == True and current_user.admin_check == True: return True else: return abort(403) column_formatters = { 'date_created': lambda view, context, model, name: model.date_created.strftime('%Y-%m-%d %H:%M:%S') } class UserAdmin(AdminBase): # 1. 표시 할 열 설정 column_list = ('id', 'username', 'email', 'date_created', 'post_create_permission', 'admin_check') # 2. 폼 데이터 설정 form = type('ExtendedSignUpForm', (SignUpForm,), { 'post_create_permission': BooleanField('post_create_permission', default=False), 'admin_check': BooleanField('admin_check', default=False) }) # 3. 사용자가 패스워드를 입력하고 저장할 때 해시화하여 저장하는 로직 추가 def on_model_change(self, form, model, is_created): model.password = generate_password_hash(form.password.data) super().on_model_change(form, model, is_created) class PostAdmin(AdminBase): # 1. 표시 할 열 설정 column_list = ('id', 'title', 'content', 'date_created', 'user', 'category') # 2. 폼 데이터 설정 form = PostForm # 3. 현재 사용자 아이디 모델에 추가하기 def on_model_change(self, form, model, is_created): model.author_id = current_user.id super().on_model_change(form, model, is_created) class CategoryAdmin(AdminBase): # 1. 표시 할 열 설정 column_list = ('id', 'name') # 2. 폼 표시 X 열 설정 form_excluded_columns = {'category_posts'} def get_all_admin_models(): return [[UserAdmin, get_model('user')], [PostAdmin, get_model('post')], [CategoryAdmin, get_model('category')] ]
4. post_write.html
post create, edit 모두 같은 양식을 사용할 수 있도록 공통된 양식을 만들었습니다. 회원가입, 로그인 페이지에서 했던것처럼 반복문 돌면서, 최대한 중복을 줄인 코드로 작성해보았습니다. 본문작성하는 부분만 따로 스타일적용을 해주었고 그 이외에는 공통되게 form-control class를 상속했습니다.
<!-- blog/templates/views/post_write.html --> {% extends "base.html" %} {% block title %} MyBlog - Post {{type}} {% endblock %} {% block header %} <header class="masthead" style="background-image: url('../static/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="site-heading"> <h2>Post {{post_id}} {{type}}</h2> <span class="subheading"></span> </div> </div> </div> </div> </header> {% endblock %} {% block 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-7"> <div class="my-5" style="border: 1px solid black; padding: 20px;"> <form id="postForm" method="POST"> {{form.csrf_token}} {% for field in form if field.name != 'csrf_token'%} <div class="form-floating"> {{ field(id=field.id, class="form-control" ~ (' is-invalid' if field.errors else ''), required=True, style=("font-size: 16px;" ~ ("height:500px; resize: none;" if field.name == 'content' else ""))) }} <label for="{{ field.id }}" {% if field.errors %} class="invalid-feedback" {% endif %}>{{ field.label.text }}</label> {% for error in field.errors %} <span class="invalid-feedback">{{ error }}</span> {% endfor %} </div> {% endfor %} <br /> <button class="btn btn-primary text-uppercase" id="submitButton" type="submit"> Post {{type}} </button> </form> </div> </div> </div> </div> </main> {% endblock %}
5. post_read.html 작성
post 데이터를 읽고, edit, delete 모두 할 수 있도록 버튼을 추가했습니다. post 데이터를 받아서 사용하고 있습니다. 자세한 코드는 아래를 참고하세요.
delete 버튼 클릭 시 자바스크립트 함수를 활용해서 서버에 요청을 보내도록 구현했습니다. 다만 fetch의 경우, 서버에서 직접 redirect를 리턴하면 클라이언트에서는 화면 이동이 되지 않는 버그가 존재합니다. 그래서 post_delete 메소드 작성할 때는 주의해야 합니다. 자세한건 아래에서 설명하겠습니다.
<!-- blog/templates/views/post_read.html --> {% extends "base.html" %} {% block title %} MyBlog - Post {{post.id}} {% endblock %} {% block header %} <header class="masthead" style="background-image: url('../static/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"> <!-- 제목 + post 저자 + 생성 날짜 --> <div class="post-heading"> <div id="title" name="title"><h2>{{post.title}}</h2></div> <span class="meta"> <div id="author" name="author"><p>Post Author: <a href="#!">{{post.user.username}}</a></p></div> <div id="category" name="category"><p>Category: <a href="#!">{{post.category.name}}</a></p></div> <p>Post created at : {{post.date_created | datetime}}</p> {% if user.id == post.user.id %} <button class="btn btn-info" id="edit_button"> <a href="{{url_for('views.post_edit', id=post.id)}}">Edit</a> </button> <button data_post_id="{{post.id}}" onclick="onDeletePost(this)" class="btn btn-danger" id="delete_button">Delete</button> {% endif %} </span> <span class="subheading"></span> </div> </div> </div> </div> </header> <script> function onDeletePost(button){ let confirm = window.confirm('정말 삭제하시겠습니까?'); if (confirm) { // 확인을 클릭한 경우에만 요청 보내기 let postId = button.getAttribute('data_post_id'); fetch('/post-delete/' + postId, {method:"GET"}) .then(response => { if (response.status === 200){ window.location.href = '/' } }) } } </script> {% endblock %} {% block 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" id="content" name="content"> <p>{{ post.content }}</p> </div> </div> </div> </article> {% endblock %}
6. views 엔드포인트 작성
위에서 작성한 post_create와 post_read를 참고하여 views 엔드포인트를 작성해보겠습니다. post create과 edit에서 공통되게 post를 전달해주도록 Post 모델의 생성자를 아래처럼 오버라이드했습니다. title, content, category_id의 default값을 설정해준 뒤, post create에서는 title이 빈 문자열, post edit에서는 빈 문자열이 아닌 것을 활용해서 구현하였습니다. 자세한 코드는 아래를 참고해주세요.
위에서 설명했듯이 post_delete에서는 삭제 완료 후 redirect를 리턴하면 안됩니다. 그러면 서버측에서 한번, 클라이언트 요청에 의한 답변으로 또 한번해서, 같은 페이지(views.home)을 두번 응답하게 되서 flash 삭제 완료 메세지가 사라지게 됩니다. 이를 해결하기 위해 jsonify로 메세지와 상태코드 200을 함께 보냅니다. 그리고 클라이언트에선 상태코드를 확인한 뒤 메인 홈페이지('/')로 직접 이동하면 됩니다.
post_create, post_edit, post_delete에 대한 자세한 내용은 아래 코드를 직접 확인해주세요. 직관적으로 작성했으니 이해하기 어렵지 않을겁니다.
class Post(db.Model): ''' 이전 코드 생략 ''' def __init__(self, author_id, title='', content='', category_id=1): self.title = title self.content = content self.date_created = datetime.now(KST_offset) self.author_id = author_id self.category_id = category_id
# blog/views.py from flask import Blueprint, abort, flash, jsonify, url_for, redirect, render_template, request from flask_login import current_user, login_required from blog.forms import PostForm from blog.models import db, get_model views = Blueprint('views', __name__) BASE_VIEWS_DIR = 'views/' # post = get_model('post').query.get(id) 대신 아래처럼 사용(deprecated) # post = db.session.get(get_model('post'), id) @views.route("/") @views.route("/home") def home(): return render_template(BASE_VIEWS_DIR + "index.html") @views.route("/about-me") def about_me(): return render_template(BASE_VIEWS_DIR + "about_me.html", user=current_user) @views.route('/contact') def contact(): return render_template(BASE_VIEWS_DIR + "contact.html", user=current_user) @views.route('/post-create', methods=['GET', 'POST']) @login_required def post_create(): # 글 작성 권한 없으면 abort if current_user.post_create_permission == False: return abort(403) form = PostForm() if request.method == 'POST' and form.validate_on_submit(): post = get_model('post')( title=form.title.data, content=form.content.data, category_id=form.category_id.data, author_id=current_user.id, ) db.session.add(post) db.session.commit() flash('Post 작성 완료!', category="success") return redirect(url_for('views.home')) else: # GET 요청 or form invalidated return render_template(BASE_VIEWS_DIR + "post_write.html", user=current_user, form=form, type='Create', ) @views.route('/post-edit/<int:post_id>', methods=['GET', 'POST']) @login_required def post_edit(post_id): # edit 요청 post 가져오기 post = db.session.get(get_model('post'), post_id) if post is None: flash('게시물을 찾을 수 없습니다.', 'error') return redirect(url_for('views.home')) # 작성자가 아니면 abort if current_user.id != post.author_id: return abort(403) form = PostForm() if request.method == 'GET': # 기존 post 데이터 채우기 form.title.data = post.title form.content.data = post.content form.category_id.data = post.category_id return render_template(BASE_VIEWS_DIR + "post_write.html", user=current_user, form=form, type="Edit", post_id=post_id, ) if request.method == 'POST' and form.validate_on_submit(): # 새로운 post 데이터 업데이트 post.title = form.title.data post.content = form.content.data post.category_id = form.category_id.data db.session.commit() # 바로 commit = update flash('Post 수정 완료!', category="success") return redirect(url_for('views.post', post_id=post_id)) else: flash('Post 수정 실패!', category="error") return render_template(BASE_VIEWS_DIR + "post_write.html", user=current_user, form=form, type="Edit", post_id=post_id, ) @views.route('/post-delete/<int:post_id>') @login_required def post_delete(post_id): # delete 요청 post 가져오기 post = db.session.get(get_model('post'), post_id) if post is None: flash('게시물을 찾을 수 없습니다.', 'error') return redirect(url_for('views.home')) # 작성자가 아니면 abort if current_user.id != post.author_id: return abort(403) db.session.delete(post) db.session.commit() flash('게시물이 성공적으로 삭제되었습니다.', 'success') return jsonify(message='success'), 200
7. views 엔드포인트 추가(posts-list, post, category)
먼저 post를 모아서 확인할 수 있도록 index.html을 수정해주도록 하겠습니다. 모든 post를 가져와서 나열해주고, post를 클릭하면 해당 post의 자세한 정보를 확인할 수 있는 페이지(post_read.html 렌더링하는 페이지)로 넘어가도록 구현하겠습니다. 그리고 마지막으로 카테고리별로 작성한 post를 확인할 수 있는 페이지도 구현하겠습니다.
먼저 index.html 파일을 posts_list.html로 변경해줍니다.
title block의 경우 메인 홈페이지에서는 home, 특정 카테고리를 선택한 페이지에서는 해당 카테고리 이름을 출력하도록 했습니다.
header block의 경우 카테고리 이름과 해당 카테고리에 속한 게시글 개수를 출력하도록 했습니다.
content block의 경우 제목과 카테고리 이름, 저자 이름, 생성 날짜를 출력하도록 했습니다. 그리고 해당 영역 클릭 시 해당하는 post의 상세페이지로 이동하도록 라우팅했습니다.
자세한 코드는 아래를 참고하세요.
<!-- blog/templates/views/posts_list.html --> {% extends "base.html" %} {% block title %} {% if category_name == 'all' %} MyBlog - home {% else %} MyBlog - {{category_name}} Category {% endif %} {% endblock %} {% block header %} <header class="masthead" style="background-image: url('../static/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="site-heading"> <h2 id="category_wrapper">Category : {{category_name}}</h2> <span class="subheading" id="posts_count">총 {{ posts | length }}개의 포스트가 있습니다.</span> </div> </div> </div> </div> </header> {% endblock %} {% block 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"> {% if posts | length %} {% for post in posts %} <div class="post-preview"> <!-- 제목 --> <a href="{{url_for('views.post', id=post.id)}}"> <h6 class="post-title" id="post_title">{{post.title}}</h6> </a> <!-- 작성자, 날짜 --> <p class="post-meta"> Category: <a href="#!" id="post_category">{{post.category.name}}</a> Posted by <a href="#!" id="post_owner_name">{{post.user.username}}</a> <br> {{post.date_created}} </p> </div> <!-- Divider--> <hr class="my-4" /> {% endfor %} {% else %} <h2 style="text-align: center;">Post가 존재하지 않습니다.</h2> <br><br> {% endif %} </div> </div> </div> {% endblock %}
위 posts_list.html을 기반으로 엔드포인트를 추가해보겠습니다.
먼저 home의 경우 모든 포스트를 가져온 뒤 posts_list.html에 넘겨주면 됩니다. 이때 selectinload를 사용해서 user와 category 정보도 함께 가져오도록 해야합니다. Post 모델에서 selectinload를 사용하지 않는다면 post에서 user, category 필드값을 사용할 때마다 쿼리가 실행되므로, N개의 post를 가져올 때 최대 N+1번의 쿼리가 실행됩니다. 이를 N+1 문제라고 합니다. category의 경우 일단 존재하는 카테고리를 화면에 출력하고, 사용자가 선택한 카테고리 id를 담아서 posts_list로 라우팅해주면 됩니다.
전체 코드는 아래와 같습니다.
# blog/views.py @views.route("/") @views.route("/home") def home(): posts = db.session.query(get_model('post')).options( selectinload(get_model('post').user), selectinload(get_model('post').category)).all() return render_template(BASE_VIEWS_DIR + "posts_list.html", user=current_user, posts=posts, type = 'home', category_name='all', ) @views.route('/category') def category(): categories = db.session.query(get_model('category')).all() return render_template(BASE_VIEWS_DIR + "category.html", user=current_user, categories=categories) from sqlalchemy.orm import selectinload @views.route("/posts-list/<int:category_id>") def posts_list(category_id): selected_category = db.session.get(get_model('category'), category_id) category_posts = db.session.query(get_model('post')).filter_by(category_id=category_id).options( selectinload(get_model('post').user)).all() if category_posts is None: flash('해당 카테고리는 존재하지 않습니다.', category="error") return redirect(url_for('views.category')) return render_template(BASE_VIEWS_DIR + "posts_list.html", user=current_user, posts=category_posts, type='category_posts', category_name=selected_category.name, ) # 해당하는 id의 포스트를 보여줌 @views.route('/post/<int:post_id>') def post(post_id): post = db.session.get(get_model('post'), post_id, options=[selectinload(get_model('post').user), selectinload(get_model('post').category)]) if post is None: flash('해당 포스트는 존재하지 않습니다.', category="error") return redirect(url_for('views.home')) return render_template(BASE_VIEWS_DIR + "post_read.html", user=current_user, post=post, )
<!-- blog/templates/views/category.html --> {% extends "base.html" %} {% block title %} MyBlog - All Category {% endblock %} {% block header %} <header class="masthead" style="background-image: url('../static/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"> <h2>All Categories</h2> <span class="subheading">See Post Categories</span> </div> </div> </div> </div> </header> {% endblock %} {% block 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" id="categories"> {% for category in categories %} <div class="category_post_preview"> <a href="{{url_for('views.posts_list', category_id=category.id)}}"> <h2 class="category_name">{{ category.name }}</h2> </a> </div> <hr class="my-4"/> {% endfor %} </div> </div> </div> {% endblock %}
여기까지 post와 관련 페이지, 기능들을 모두 구현했습니다. 다음에는 post 관련 페이지와 기능을 테스트하는 테스트 코드 작성을 해보겠습니다.
post 생성, 수정, 삭제 기능이 페이지에서 잘 동작하는지 테스트하기 위해서는 사전에 category 생성과 user 생성 + 로그인이 이루어져야하기에 메소드로 정의한 뒤 활용했습니다.
총 5개의 테스트를 작성했습니다.
1: 카테고리 생성이 정상적으로 이루어졌는지 확인.
2: post 생성 페이지에 2번 접근한 뒤 페이지 반응을 확인(로그인 X 유저, 로그인 O 유저)
3: post 생성 후 db체크, post 페이지, category 페이지 접근 후 체크
4: post 생성 후 수정 페이지 2번 접근한 뒤 페이지 반응 확인(저자 X 유저, 저자 O 유저)
5: post 생성 후 삭제 요청 2번(저자 X 유저, 저자 O 유저)
# tests/test_post.py import os import sys # 현재 스크립트의 부모 디렉터리를 상위로 추가 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask_login import login_user, logout_user from blog.models import db, get_model from tests.test_base import TestBase from bs4 import BeautifulSoup class PostTest(TestBase): name = 'POST' # 매 시작 전 카테고리 추가 def setUp(self): super().setUp() self.add_categories() # 매 종료 후 로그아웃 def tearDown(self): logout_user() super().tearDown() # 카테고리 2개 추가(category 1, category 2) def add_categories(self): db.session.add(get_model('category')(name='category 1')) db.session.commit() db.session.add(get_model('category')(name='category 2')) db.session.commit() # 회원가입(db에 직접 넣기) 후 로그인(login_user -> current_user 사용 가능) def signUpAndLogin(self, post_create_permission=False): user = get_model('user')( username='11111', email='test1@example.com', password='123456', post_create_permission=post_create_permission, ) db.session.add(user) db.session.commit() login_user(user, remember=True) return user # post 생성 def create_post(self): return self.test_client.post('/post-create', data=dict( title='test title', content='test content', category_id=2, # 'category 2' author_id=1 )) ''' 1. 카테고리 생성 db 직접 확인 + category 페이지 접속 후 확인 ''' def test_1_make_category(self): # 1. 카테고리 추가 후 db에 적용 잘 됐는지 확인 self.assertEqual(get_model('category').query.filter_by(id=2).first().name, 'category 2') # 2. category 페이지 접속했을 때 카테고리 목록이 잘 출력되는지 확인 response = self.test_client.get('/category') category_list = BeautifulSoup(response.data, 'html.parser') self.assertIn('category 1', category_list.text) self.assertIn('category 2', category_list.text) ''' 2. post 생성 페이지 접근 확인(로그인 전 + 후) 로그인 전 => 홈으로 redirect = 302 code 체크 로그인 후(권한 X) => abort => 홈으로 redirect = 302 code 체크 로그인 후(권한 O) => 페이지 접근 가능 = 200 code 체크 글 작성 시 category 목록 확인 => 개수 2개 = category 1, 2만 있어야 함 ''' def test_2_create_post_page(self): # 1. 로그인 전 # 로그인 페이지 리다이렉트 = 302 code 체크 response_before_login = self.test_client.get('/post-create') self.assertEqual(response_before_login.status_code, 302) # 2. 회원 가입 + 로그인 후(글 권한 X) = abort = redirect 302 체크 user = self.signUpAndLogin() response_create_post_page = self.test_client.get('/post-create') self.assertEqual(response_create_post_page.status_code, 302) # 3. 권한 변경 후 다시 시도 user.post_create_permission = True db.session.commit() response_create_post_page = self.test_client.get('/post-create') self.assertEqual(response_create_post_page.status_code, 200) # 4. 카테고리 목록 확인 category_list = BeautifulSoup(response_create_post_page.data, 'html.parser').find(id='category_id') self.assertEqual(len(category_list.find_all('option')), 2) self.assertIn('category 1', category_list.text) self.assertIn('category 2', category_list.text) ''' 3. post 생성 post 생성 후 db 직접 체크 post 페이지 직접 접근 후 제목, 저자, 카테고리, 본문 잘 나오는지 확인 카테고리 접근 후 post가 해당 카테고리에 속했는지 확인 default가 1이므로, category 2 생성 후 접근 ''' def test_3_after_create_post(self): # 1. 회원 가입 + 로그인 후 post 생성 self.signUpAndLogin(post_create_permission=True) self.create_post() self.assertEqual(get_model('post').query.count(), 1) # db 체크 # 2. post 페이지 접근 후 확인 response_post_page = self.test_client.get('/post/1') source = BeautifulSoup(response_post_page.data, 'html.parser') self.assertIn('test title', source.find(id='title').text) self.assertIn('test content', source.find(id='content').text) self.assertIn('category 2', source.find(id='category').text) self.assertIn('11111', source.find(id='author').text) # 3. 카테고리 접근 후 post 확인 response_category_page = self.test_client.get('/posts-list/2') source = BeautifulSoup(response_category_page.data, 'html.parser') self.assertIn('category 2', source.find(id='category_wrapper').text) self.assertIn('test title', source.find(id='post_title').text) self.assertIn('총 1개', source.find(id='posts_count').text) self.assertIn('11111', source.find(id='post_owner_name').text) ''' 4. post 수정 post 생성 후 수정 페이지 2번 접근(저자 O, 저자 X) post 페이지 직접 접근 후 제목, 저자, 카테고리, 본문 잘 나오는지 확인 카테고리 접근 후 post가 해당 카테고리에 속했는지 확인 ''' def test_4_update_post(self): # 1. 회원 가입 + 로그인 후 post 생성 user1 = self.signUpAndLogin(post_create_permission=True) self.create_post() # 2. post 페이지 접근 후 수정 페이지 이동 (Edit, Delete 있어야 함) response_post_page = self.test_client.get('/post/1') source = BeautifulSoup(response_post_page.data, 'html.parser') self.assertIn('Edit', source.find(id='edit_button').text) self.assertIn('Delete', source.find(id='delete_button').text) response_edit_page = self.test_client.get('/post-edit/1') self.assertEqual(response_edit_page.status_code, 200) # 3. 수정 페이지에서 원본 데이터 출력 화인 source = BeautifulSoup(response_edit_page.data, 'html.parser') self.assertIn('test title', source.find(id='title')['value']) # input 태그는 내부 값 X => value 값 꺼내야 함 self.assertIn('test content', source.find(id='content').text) self.assertIn('category 2', source.find(id='category_id').find('option', selected=True).text) # 4. 수정 데이터 전송 후 확인 self.test_client.post('/post-edit/1', data=dict( title='test title update', content='test content update', category_id=1, # 'category 1' author_id=1, # 'XXXXX' )) response_post_page = self.test_client.get('/post/1') source = BeautifulSoup(response_post_page.data, 'html.parser') self.assertIn('test title update', source.find(id='title').text) self.assertIn('test content update', source.find(id='content').text) self.assertIn('category 1', source.find(id='category').text) self.assertIn('11111', source.find(id='author').text) logout_user() # 5. 저자 X 생성 후 로그인 user2 = get_model('user')( username='22222', email='test2@example.com', password='123456', post_create_permission=True, ) db.session.add(user2) db.session.commit() login_user(user2, remember=True) # 6. 새로운 유저로 post 접근 후 edit 확인 # read는 성공해야 하고 edit, delete 버튼 X response_post_page = self.test_client.get('/post/1') source = BeautifulSoup(response_post_page.data, 'html.parser') self.assertEqual(response_post_page.status_code, 200) self.assertIsNone(source.find(id='edit_button')) self.assertIsNone(source.find(id='delete_button')) # 7. 새로운 유저로 post 수정 페이지 접근시 abort = redirect 302 확인 response_edit_page = self.test_client.get('/post-edit/1') self.assertEqual(response_edit_page.status_code, 302) ''' 5. post 삭제 post 생성 후 삭제 요청 2번(저자 O, 저자 X) ''' def test_5_delete_post(self): # 1. 회원 가입 + 로그인 후 post 생성 user1 = self.signUpAndLogin(post_create_permission=True) self.create_post() # 2. 포스트 삭제 요청 전송 후 확인(성공 = 200 code) response = self.test_client.get('/post-delete/1') self.assertEqual(response.status_code, 200) self.assertEqual(response.json['message'], 'success') # 3. 삭제 포스트 접근 확인(redirect = 302 code) response = self.test_client.get('/post/1') self.assertEqual(response.status_code, 302)
여기까지 MyBlog 프로젝트의 기초작업은 모두 끝이 났습니다. 다음에는 배포와 함께 CI/CD를 진행해보겠습니다.
반응형'프로젝트' 카테고리의 다른 글
Flask Project - MyBlog CI/CD 적용하기(feat. Github Actions) (1) 2024.04.19 Flask Project - MyBlog 배포하기(nginx+gunicorn+flask with docker-compose) (2) 2024.04.18 Flask Project - MyBlog auth(회원가입, 로그인, 로그아웃) with unittest (0) 2024.04.09 Flask Project - MyBlog Flask-admin(관리자 페이지 생성 및 관리) (2) 2024.04.08 Flask Project - MyBlog Flask-sqlalchemy(with Flask-migrate) DB 관리 (0) 2024.04.08