-
Flask Project - MyBlog 댓글 추가하기(feat. flask-migrate)프로젝트 2024. 4. 22. 19:07반응형
사용자, 게시글, 카테고리에 이어서, 댓글 기능을 추가해보도록 하겠습니다.
먼저 DB 스키마 모델을 만든 뒤에 해당 모델을 기반으로 Form을 만들어보겠습니다. 해당 모델을 기반으로 comment 관련 엔드포인트 생성과 기능을 구현한뒤에는 테스트 코드를 작성해서 테스트해보고, CommentAdmin 모델을 만들어서 관리자 페이지용 모델도 생성해보겠습니다. 마지막으로 저번에 구현한 CI/CD 시스템 기반으로 배포가 잘 이루어졌는지 확인도 해보겠습니다.
1. Comment 모델 생성
User, Post, Category 모델에 이어서 Comment 모델도 추가해보도록 하겠습니다.
주요 필드는 content, author_id, post_id입니다. 그리고 author_id, post_id와 관련지어 연관 모델을 user와 post로 참조가능하게 설정했습니다. User, Post 모델에서는 user_comments, post_comments로 역참조 가능하도록 설정했고, selectin lazy로 설정해서, 필요할 때 sqlalchemy.orm.selectinload 메소드를 사용해서 함께 가져올 수 있도록 했습니다.
# flask_app/blog/models.py ''' 이전 코드 생략 ''' class Comment(db.Model): id = db.Column(db.Integer, primary_key=True) # 댓글 고유 번호 content = db.Column(db.Text(), nullable=False) # 댓글 내용 date_created = db.Column(db.DateTime(timezone=True), default=datetime.now(KST_offset)) # 댓글 생성 시간 author_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) post_id = db.Column(db.Integer, db.ForeignKey('post.id', ondelete='CASCADE'), nullable=False) user = db.relationship('User', backref=db.backref('user_comments', cascade='delete, delete-orphan'), lazy='selectin') post = db.relationship('Post', backref=db.backref('post_comments', cascade='delete, delete-orphan'), lazy='selectin') def __repr__(self): return f'{self.__class__.__name__}(title={self.content})>' def get_model(arg): models = { 'user': User, 'post': Post, 'category': Category, 'comment': Comment, } return models[arg]
2. CommentForm 생성
사용자는 post에 들어가서 댓글을 작성하기 때문에 author_id, post_id는 입력받을 필요 없습니다. 그래서 content 필드만 만들었습니다.
class CommentForm(FlaskForm): content = TextAreaField('content', validators=[DataRequired('댓글을 작성해주세요.')])
3. Comment 엔드포인트 생성(Comment-create, edit, delete)
post 생성, 수정, 삭제 흐름과 유사하게 코드를 작성했습니다.
@views.route('/comment-create/<int:post_id>', methods=['POST']) @login_required def comment_create(post_id): form = CommentForm() if request.method == 'GET': flash('잘못된 요청입니다.', category="error") return redirect(url_for('views.post', post_id=post_id)) if form.validate_on_submit(): comment = get_model('comment')( content=form.content.data, author_id=current_user.id, post_id=post_id ) db.session.add(comment) db.session.commit() return redirect(url_for('views.post', post_id=post_id)) else: flash('댓글 작성 실패!', category="error") return redirect(url_for('views.post', post_id=post_id)) @views.route('/comment-edit/<int:post_id>-<int:comment_id>', methods=['POST']) @login_required def comment_edit(post_id, comment_id): form = CommentForm() post = db.session.get(get_model('post'), post_id) if post is None: flash('해당 포스트는 존재하지 않습니다.', category="error") return redirect(url_for('views.home')) if request.method == 'GET': flash('잘못된 요청입니다.', category="error") return redirect(url_for('views.post', post_id=post_id)) comment = db.session.get(get_model('comment'), comment_id) if comment is None: flash('해당 댓글은 존재하지 않습니다.', category="error") return redirect(url_for('views.post', post_id=post_id)) if current_user.id != comment.author_id: return abort(403) if form.validate_on_submit(): comment.content = form.content.data db.session.commit() flash('댓글 수정 완료!', category="success") return redirect(url_for('views.post', post_id=post_id)) else: flash('댓글 수정 실패!', category="error") return redirect(url_for('views.post', post_id=post_id)) @views.route('/comment-delete/<int:comment_id>') @login_required def comment_delete(comment_id): # delete 요청 comment 가져오기 comment = db.session.get(get_model('comment'), comment_id) if comment is None: flash('해당 댓글은 존재하지 않습니다.', 'error') return jsonify(message='error'), 400 # 작성자가 아니면 abort if current_user.id != comment.author_id: return abort(403) db.session.delete(comment) db.session.commit() flash('댓글이 성공적으로 삭제되었습니다.', 'success') return jsonify(message='success'), 200
4. post_read.html 수정
댓글 부분은 크게 사용자 입력 받는 Comment Form 부분, Comment List 출력하는 부분으로 나뉩니다. Comment Form 부분은 form 태그로 만들면 되고, Comment List에서 Single Comment 출력 부분은 사용자 이름, 댓글 생성 시간, 댓글 내용, 작성자인 경우에는 수정, 삭제 버튼을 출력해주면 됩니다.
댓글 수정 버튼의 경우 부트스트랩의 modal을 활용했는데요. 부트스트랩에서는 data-toggle 및 data-target 속성을 사용하여 모달을 키고 끄는 기능을 구현합니다.
- data-toggle="modal": 이 속성은 요소를 클릭했을 때 모달을 토글하는 역할을 합니다. 이 속성은 보통 버튼 또는 링크와 같은 인터랙티브한 요소에 적용됩니다. 부트스트랩 5에서는 data-bs-toggle을 사용합니다.
- data-target="#myModal": 이 속성은 모달의 타겟을 지정합니다. 여기서 #myModal은 모달의 id를 나타냅니다. 모달이 열리면 이 속성에 지정된 id를 가진 요소가 표시됩니다. 부트스트랩 5에서는 data-bs-target을 사용합니다.
<!-- flask_App/blog/templates/views/post_read.html --> {% block header %} <!-- 이전 생략 --> <script> function onDeleteComment(button){ let confirm = window.confirm('정말 삭제하시겠습니까?'); if (confirm) { // 확인을 클릭한 경우에만 요청 보내기 let commentId = button.getAttribute('data_comment_id'); fetch('/comment-delete/' + commentId, {method:"GET"}) .then(response => { window.location.reload(); if (response.status === 200){ } else { } }) } } </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> <!--comment --> <section class="mb-5" style="max-width: 90%; margin:0 auto"> <div class="card bg-light"> <div class="card-body" id="commentWrapper"> <!-- Comment form--> <form class="mb-4" method="post" action="{{ url_for('views.comment_create', post_id=post.id) }}" style="padding-bottom: 10px;"> {{ form.csrf_token }} <textarea class="form-control mb-3" rows="3" name="content" placeholder="Please leave a comment!"></textarea> <div style="text-align: right"> <button class="btn btn-info" id="submitButton" style="width: 150px;height: 45px; font-size: 12px;" type="submit"> Comment </button> </div> </form> <!-- Comment List --> {% if not comments %} <div id="emptyComment" style="text-align: center;">댓글이 없습니다.</div> {% else %} {% for comment in comments %} <div id="commentList"> <!-- Single comment--> <div class="row border-bottom"> <div class="col-3" style="margin:0 0; font-size: 12px; border-right:1px solid;"> <p style="margin:0 0"> {{comment.user.username}}</p> <p style="margin:0 0">{{comment.date_created | datetime}}</p> </div> <div class="col-9" style="margin:0 0; font-size: 16px;"> <div class="row"> {% if current_user.id==comment.author_id %} <p class="col-9" style="margin:0 0">{{comment.content}}</p> <p id="editAndDeleteButton" class="col-3 d-flex justify-content-center align-items-center" style="margin: 6px 0px"> <button class="btn btn-secondary" style="padding: 6px;" data-bs-toggle="modal" data-bs-target="#editCommentModal{{ comment.id }}"> <i class="fa-solid fa-pencil"></i> </button> <button data_comment_id="{{comment.id}}" onclick="onDeleteComment(this)" class="btn btn-danger" style="padding: 6px;"> <i class="fa-solid fa-trash"></i> </button> </p> {% else %} <p class="col" style="margin:0 0">{{comment.content}}</p> {% endif %} </div> </div> </div> <!-- Modal --> <div class="modal fade" id="editCommentModal{{ comment.id }}" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="exampleModalLabel">Comment Edit</h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> </div> <form method="POST" class="form-control" action="{{ url_for('views.comment_edit', post_id=post.id, comment_id=comment.id) }}"> {{ form.csrf_token }} <div class="modal-body"> <input id="commentContent" type="text" class="form-control" name="content" value="{{ comment.content }}"/> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary"data-bs-dismiss="modal">Close</button> <button type="submit" class="btn btn-primary">Edit comment</button> </div> </form> </div> </div> </div> </div> {% endfor %} {% endif%} </div> </div> </section> {% endblock %}
5. 테스트 코드 작성
댓글 생성, 수정, 삭제 기능 및 페이지를 테스트하는 테스트 코드를 작성해보았습니다. 자세한 설명은 주석을 참고해주세요.
# flask_app/tests/test_comment.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 CommentTest(TestBase): name = 'COMMENT' # 매 시작 전 카테고리 추가 def setUp(self): super().setUp() self.signUpAndLoginTwoUsers() self.create_post() # 매 종료 후 로그아웃 def tearDown(self): logout_user() super().tearDown() # 유저 2명 생성 후 유저 1로 로그인 def signUpAndLoginTwoUsers(self, post_create_permission=False): self.user1 = get_model('user')( username='11111', email='test1@example.com', password='123456', post_create_permission=post_create_permission, ) db.session.add(self.user1) db.session.commit() self.user2 = get_model('user')( username='22222', email='test2@example.com', password='123456', post_create_permission=post_create_permission, ) login_user(self.user1, remember=True) # post 생성 def create_post(self): post = get_model('post')( title='test title', content='test content', category_id=2, # 'category 2' author_id=1 ) db.session.add(post) db.session.commit() ''' 1. comment 추가 확인 사용자 2명 접속해서 댓글 내용 확인 + 수정, 삭제 버튼 확인 ''' def test_1_comment_add(self): # 1. 댓글 생성 self.test_client.post('/comment-create/1', data=dict(content="test1")) self.assertEqual(get_model('comment').query.count(), 1) # db 확인 # 2. 댓글 생성자 댓글 확인 response = self.test_client.get('post/1') post_response = BeautifulSoup(response.data, 'html.parser') comment = post_response.find(id='commentList') self.assertIn('test1', comment.text) # 댓글 내용 확인 self.assertIn('11111', comment.text) # 작성자 확인 button = post_response.find(id='editAndDeleteButton') self.assertIsNotNone(button) # 해당 id 존재해야함 self.assertIsNotNone(button.find('i', {'class': 'fa-solid fa-pencil'})) # 수정 버튼 self.assertIsNotNone(button.find('i', {'class': 'fa-solid fa-trash'})) # 수정 버튼 # 3. 댓글 관찰자 댓글 확인 logout_user() # 로그아웃 login_user(self.user2, remember=True) # 다른 유저로 로그인 response = self.test_client.get('post/1') post_response = BeautifulSoup(response.data, 'html.parser') comment = post_response.find(id='commentList') self.assertIn('test1', comment.text) # 댓글 내용 확인 self.assertIn('11111', comment.text) # 작성자 확인 button = post_response.find(id='editAndDeleteButton') self.assertIsNone(button) # 해당 id 없어야 함 ''' 2. comment 수정 확인 수정 modal 잘 뜨는지 확인 수정 요청 날려서 확인(유저 2개 사용) ''' def test_2_comment_edit(self): # 1. 댓글 생성 response = self.test_client.post('/comment-create/1', data=dict(content="test1"), follow_redirects=True) # 2. modal 버튼 찾기 modal_button = BeautifulSoup(response.data, 'html.parser').find('button', {'class': 'btn btn-secondary'}) self.assertIsNotNone(modal_button) # 3. modal 들어가서 내용 확인 modal_content = BeautifulSoup(response.data, 'html.parser').find('div', {'id':'editCommentModal1'}) self.assertEqual('Comment Edit', modal_content.find('h5', {'class': 'modal-title'}).text.strip()) # 모달 제목에 Comment Edit 표시 self.assertEqual('test1', modal_content.find(id='commentContent')['value'].strip() ) # 모달 내용에 댓글 내용 표시 # 4. 수정 요청 후 확인 self.test_client.post('/comment-edit/1-1', data=dict(content="after_edit")) response = self.test_client.get('post/1') post_response = BeautifulSoup(response.data, 'html.parser') comment = post_response.find(id='commentList') self.assertNotIn('test1', comment.text) # 이 내용 없어야 됨 self.assertIn('after_edit', comment.text) # 이 내용 있어야 됨 self.assertIn('11111', comment.text) # 작성자 확인 # 5. 다른 유저가 수정 요청 후 확인 logout_user() # 로그아웃 login_user(self.user2, remember=True) # 다른 유저로 로그인 self.test_client.post('/comment-edit/1-1', data=dict(content="hacking_request")) response = self.test_client.get('post/1') post_response = BeautifulSoup(response.data, 'html.parser') comment = post_response.find(id='commentList') self.assertNotIn('hacking_request', comment.text) # 이 내용 없어야 됨 self.assertIn('after_edit', comment.text) # 이 내용 있어야 됨 ''' 3. comment 삭제 확인 삭제 요청 후 확인(유저 2개 사용) ''' def test_3_comment_delete(self): # 1. 댓글 생성 self.test_client.post('/comment-create/1', data=dict(content="test1")) # 2. 삭제 요청 후 확인 response = self.test_client.get('/comment-delete/1') self.assertEqual(response.status_code, 200) self.assertEqual(response.json['message'], 'success') response = self.test_client.get('post/1') post_response = BeautifulSoup(response.data, 'html.parser') self.assertIsNone(post_response.find(id='commentList')) # commentWrapper 없어야 함 self.assertIsNotNone(post_response.find(id='emptyComment')) # emptyComment 있어야 함 # 3. 다른 유저 삭제 요청 후 확인 self.test_client.post('/comment-create/1', data=dict(content="test1")) logout_user() # 로그아웃 login_user(self.user2, remember=True) # 다른 유저로 로그인 self.test_client.get('/comment-delete/1') # 삭제 요청 response = self.test_client.get('post/1') post_response = BeautifulSoup(response.data, 'html.parser') self.assertIsNotNone(post_response.find(id='commentList')) # commentWrapper 있어야 함 self.assertIsNone(post_response.find(id='emptyComment')) # emptyComment 없어야 함
6. CommentAdmin 모델 생성
# flask_app/blog/admin_models.py ''' 이전 코드 생략 ''' class CommentAdmin(AdminBase): # 1. 표시 할 열 설정 column_list = ('id', 'content', 'date_created', 'author_id', 'post_id') # 2. 폼 표시 X 열 설정 form_excluded_columns = {'date_created'} def get_all_admin_models(): return [[UserAdmin, get_model('user')], [PostAdmin, get_model('post')], [CategoryAdmin, get_model('category')], [CommentAdmin, get_model('comment')]]
7. CI/CD 수정(migrations)
새로운 모델이 생성되면서 컨테이너를 껐다가 다시 실행하게되면 create_all 메소드로 테이블이 생성될겁니다. 그러나 이 메소드는 이미 생성된 테이블은 그냥 넘어가기때문에, 스키마 변경된 모델의 경우 업데이트가 안됩니다. 따라서 flask-migrate를 활용해서 db를 update해줘야합니다. 컨테이너에서 db 버전관리를 하면 재실행 시 폴더 추적이 안되므로, volumes를 활용해서 migrations 폴더를 관리해줍니다.
호스트에서 migrations 폴더를 생성한 뒤에 마운트해주면 컨테이너 내부에서 flask db init할 때 이미 폴더가 존재한다며 오류가 뜹니다. 따라서 기존에 컨테이너를 실행한 상태에서 아래 명령어대로 실행해주면 됩니다. 자세한 설명은 주석을 확인해주세요.
# flask_app 컨테이너 ID 확인하기 docker ps -qf "name=flask_app" # 해당 컨테이너 접속 docker exec -it [container id] /bin/bash # 접속 후 migrations 초기화 flask db init # 해당 migrations 폴더를 호스트에 복사 # docker cp <container_name>:/flask_app/migrations /path/to/host/migrations docker cp flask_app:/flask_app/migrations /home/ubuntu/MyBlog_project/flask_app/migrations
이제 docker-compose 파일에서 volumes를 수정해주면 됩니다.
# docker-compose.yml version: "3.7" services: flask_app: # 서비스 이름 build: context: ./flask_app # 도커 이미지 빌드 경로 dockerfile: Dockerfile # 도커 파일 이름 지정 container_name: flask_app # 컨테이너 이름 restart: always command: gunicorn -b 0.0.0.0:8888 --env FLASK_DEBUG=0 app:app volumes: - /flask_app/blog/db:/flask_app/blog/db - /flask_app/migrations:/flask_app/migrations nginx_server: # 서비스 이름 build: context: ./nginx # 도커 이미지 빌드 경로 dockerfile: Dockerfile # 도커 파일 이름 지정 container_name: nginx_server # 컨테이너 이름 restart: always ports: - "8080:8080" # 호스트 포트 -> 컨테이너 포트 depends_on: - flask_app
추후 배포할 때마다 db upgrade가 될 수 있도록 run_docker.sh 파일도 수정해줍니다.
#!/bin/bash echo killing old docker processes docker-compose rm -fs echo building docker containers as daemon docker-compose up --build -d # Flask 애플리케이션 컨테이너의 ID 가져오기 FLASK_CONTAINER_ID=$(docker ps -qf "name=flask_app") echo "Flask application container ID: $FLASK_CONTAINER_ID" # 컨테이너 내부에서 migrate와 upgrade 명령 실행 # docker exec $FLASK_CONTAINER_ID flask db init docker exec $FLASK_CONTAINER_ID flask db migrate docker exec $FLASK_CONTAINER_ID flask db upgrade
8. CI/CD 확인해보기
CI/CD 결과 확인 배포가 잘 된것을 확인할 수 있습니다.
반응형'프로젝트' 카테고리의 다른 글
Flask Project - MyBlog contactForm, mypage 추가하기 (0) 2024.04.23 Flask Project - MyBlog CI/CD 적용하기(feat. Github Actions) (1) 2024.04.19 Flask Project - MyBlog 배포하기(nginx+gunicorn+flask with docker-compose) (1) 2024.04.18 Flask Project - MyBlog views(post 생성,수정,삭제) with unittest (0) 2024.04.11 Flask Project - MyBlog auth(회원가입, 로그인, 로그아웃) with unittest (0) 2024.04.09