현재까지는 포스트를 작성할 때마다 관리자 페이지에 들어가서 작성해야 했다. 15장에서는 폼을 사용해 여러 방문자가 새로운 글을 작성하고 수정할 수 있는 페이지를 구현하는 방법을 배운다.

📌포스트 작성 페이지 만들기

포스트 작성 페이지에는 방문자가 포스트의 제목과 내용 등을 입력할 수 있고, 입력한 값을 서버로 전송해서 데이터베이스에 저장할 수 있는 빈 칸이 있어야 한다. 장고에서는 이러한 기능을 제공해준다.

📖views.py에 CreateView 추가하기

먼저 blog/views.py를 연 다음 장고가 제공하는 CreateView를 상속받아서 PostCreate라는 클래스를 만든다.
CreateView는 CRUD작업에서 "Create" 기능을 구현하는 데 일반적으로 사용된다.

model=Post로 Post 모델을 사용한다고 선언하고, Post 모델의 필드 중에서 어떤것을 사용할지를 리스트로 작성해서 fields에 저장한다.
필자는 title, hook_text, content, head_image, file_upload, category를 넣었다.

author는 방문자가 글을 쓰기 위해 로그인을 이미 했다면 Post 모델의 author 필드를 다시 채울 필요가 없기 때문이다. 그리고 날짜는 Post 모델에서 이미 자동으로 생성되게끔 만들었기 때문에 안넣어도 된다.

# blog/views.py
from django.shortcuts import render
from django.views.generic import ListView, DetailView, CreateView
from .models import Post, Category, Tag

(..생략..)
class PostCreate(CreateView):
	model=Post
    fields = ['title', 'hook_text', 'content', 'head_image', 'file_upload', 'category']

📖URL에 create_post/ 추가하기

이제 blog/urls.py를 열어 방문자가 blog/create_post/로 URL을 입력하는 경우 방금 만든 PostCreate 클래스를 사용하도록 다음과 같이 추가해준다.

from django.urls import path
from . import views

urlpatterns = [
    path('', views.PostList.as_view()),
    path('<int:pk>/', views.PostDetail.as_view()),
    path('category/<str:slug>/', views.category_page),
    path('tag/<str:slug>/', views.tag_page),
    path('create_post/', views.PostCreate.as_view()), # 추가

📖템플릿 만들기

앞서 Post 모델로 PostList, PostDetail 클래스를 만들면 모델명 뒤네 _list_detail을 붙인 post_list.html, post_detail.html을 요구했던 것처럼, CreateView 모델로 만들어진 클래스는 <app_name>/<model_name>_form.html 양식을 따라야한다.

blog/base.html을 확장해서 post_form.html을 만들었다. 왜냐하면 이전에 만들었던 base.html의 메인 영역만 채우면 되기 때문이다.

{{ form }}은 Django 폼을 렌더링하는 데 사용되며, 템플릿에서 폼 필드를 자동으로 생성한다. 이를 통해 사용자 입력을 받을 수 있는 폼을 쉽게 만들 수 있다.

{% extends 'blog/base_full_width.html' %}

{% block head_title %}Create Post - Blog{% endblock %}

{% block main_area %}
    <h1>Create New Post</h1>
    <hr/>
	{{ form }}

{% endblock %}

페이지를 열어보면 다음과 같이 좀 정리가 안되었지만 PostCreate클래스에 넣었던 field 에 해당하는 내용이 폼 형태로 나오는 것을 볼 수 있다.

깔끔하게 정리하려면 다음과 같이 <table> 태그로 {{ form }}을 감싸주면 된다.

{% extends 'blog/base_full_width.html' %}

{% block head_title %}Create Post - Blog{% endblock %}

{% block main_area %}
    <h1>Create New Post</h1>
    <hr/>
  	<table>
    	{{ form }}
  	</table>
{% endblock %}

그러면 위와 같이 많이 깔끔해진 모습(?)을 볼 수 있다.

그리고 최종적으로 <form>태그와 제출 버튼을 템플릿에 다음과 같이 추가한다.

{% extends 'blog/base_full_width.html' %}

{% block head_title %}Create Post - Blog{% endblock %}

{% block main_area %}
    <h1>Create New Post</h1>
    <hr/>
    <form method="post" enctype="multipart/form-data">{% csrf_token %}
        <table>
            {{ form }}
        </table>
        <button type="submit" class="btn btn-primary float-right">Submit</button>
    </form>
{% endblock %}

<form> 태그는 사용자가 기록한 값을 서버로 보내기 위한 용도로 사용된다고 생각하면 된다. 서버로 값을 보내는 방식에넌 GET과 POST가 있는데, 여기서는 method="post"로 설정해 POST 방식을 사용했다.

enctype="multipart/form-data"는 파일도 같이 정송하겠다는 의미다.

원래 HTML 문법에서는 <form> 태그를 쓸 때 이 form의 내용이 어느 URL로 전송되어야 하는지를 나타내는 action이 포함되어야 한다. 하지만 장고는 이런 부분까지 알아서 처리해주기 때문에 특별한 목적이 있지 않는 한 action은 쓰지 않아도 된다.

마지막으로 {% csrf_token %}은 웹 사이트를 CSRF 공격으로부터 보호하기 위해 장고가 제공하는 기능이다. 그러므로 장고에서 form을 이용할 때는 {% csrf_token %}<form> 태그 안에 꼭 넣자!

📖포스트를 로그인한 방문자만 작성할 수 있게 하기

현재 포스트 작성은 로그인을 했든 안했든 모든 사람이 작성할 수 있는 상황이다.

위 사진처럼 관리자 페이지가 아닌, 포스트 작성 페이지에서 포스트를 새로 작성하면, 작성자가 NONE을 나오는 것을 볼 수 있다. 관리자 계정으로 로그인 한 상태인데도 말이다. 당연히 로그아웃한 상태에서도 포스트를 작성할 수 있다.

로그인한 방문자만 포스트를 작성할 수 있게 바꿔보자.

👉테스트코드 작성

사용자가 로그인하지 않았을 때 포스트 작성 페이지가 정상적으로 열리지 않는지 확인하기 위해 blog/tests.py에 다음과 같이 test_create_post() 함수를 작성했다.

또한 포스트 작성 페이지에서 블로그 포스트를 작성한 후 제출 버튼을 클릭했을 때 잘 제출이 되는지 확인하는 코드도 작성해봤다.

def test_create_post(self):
    # 로그인하지 않으면 status code가 200이면 안된다
    response = self.client.get('/blog/create_post/')
    self.assertNotEqual(response.status_code, 200)

    # 로그인한다.
    self.client.login(username='steve', password='somepassword')
  
    response = self.client.get('/blog/create_post/')
  	self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')

    self.assertEqual('Create Post - Blog', soup.title.text)
    main_area = soup.find('div', id='main-area')
    self.assertIn('Create New Post', main_area.text)

    self.client.post(
        '/blog/create_post/',
        {
            'title': 'Post Form 만들기',
            'content': 'Post Form 페이지를 만듭시다.',
        }
    )
    self.assertEqual(Post.objects.count(), 4)
    last_post = Post.objects.last()
    self.assertEqual(last_post.title, "Post Form 만들기")
    self.assertEqual(last_post.author.username, 'steve')

먼저 로그인하지 않았을 때는 status_code 값이 200이 아닌지를 확인하면 된다.

사용자가 로그인했을 때를 테스트하기 위해서 self.clientlogin()함수를 사용한다. setUp()함수에서 만들었던 사용자의 username과 password를 login() 함수에 인자로 넣으면 된다.

self.client.post() 함수로 첫 번째 인수인 경로에 두번째 인수인 딕셔너리 정보를 POST 방식으로 보낸다. Post 모델로 만든 폼은 title과 content 필드가 필수로 채워져야 하므로 딕셔너리에 해당 내용을 담았다.

Post.objects.last() 로 Post 레코드 중 맨 마지막 레코드를 가져오고, POST 방식으로 보낸 정보가 마지막으로 저장되었다면 마지막 Post 레코드의 title 필드 값을 확인한다. 마지막으로 steve라는 username을 가진 사용자로 로그인한 상태이므로 author의 username은 steve여야 한다.

👉PostCreate에 LoginRequiredMixin 추가

PostCreate클래스에 LoginRequiredMixin 클래스를 추가하면 로그인했을 때만 정상적으로 페이지가 보이게 된다.

LoginRequiredMixin은 특정 view에 대한 인증 및 로그인 요구 사항을 적용하는 데 사용할 수 있는 Django에서 제공하는 class-based view mixin 이다. 인증된 사용자만 특정 보기에 액세스할 수 있도록 Django의 인증 시스템과 함께 자주 사용한다.

from django.shortcuts import render, redirect
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from .models import Post, Category, Tag
from django.core.exceptions import PermissionDenied


class PostCreate(LoginRequiredMixin, UserPassesTestMixin, CreateView):
    model = Post
    fields = ['title', 'hook_text', 'content', 'head_image', 'file_upload', 'category']

테스트를 돌려보니 로그인 부분은 통과했는데, 마지막으로 작성한 포스트에 author 필드 값이 없다는 에러가 떴다. author 필드가 로그인한 사용자로 자동 저장되어야 하는데 그렇지 않아 발생한 문제다.

Mixin 클래스 더 알아보기

Django에서는 View 클래스와 함께 여러 Mixin 클래스를 사용하여 기능을 상속하고 결합할 수 있습니다. Mixin 클래스는 다양한 기능 세트로 View를 구성하고, 혼합할 수 있는 기능을 제공하도록 설계되었습니다.
여러 Mixin 클래스를 사용하려면 메인 View 클래스와 함께 상속 순서로 나열하기만 하면 됩니다. 각 Mixin 클래스는 자체 속성, 메서드 및 동작을 최종 View 클래스에 제공할 수 있습니다.

다음은 Mixin 클래스와 함께 추가 상속을 사용하는 방법의 예입니다.

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from myapp.mixins import MyMixin1, MyMixin2

class MyView(LoginRequiredMixin, MyMixin1, MyMixin2, TemplateView):
   template_name = 'myapp/myview.html'

위의 예에서 MyView는 TemplateView와 함께 여러 Mixin 클래스를 결합한 예시입니다. 상속 순서는 Mixin 클래스의 속성과 메서드가 적용되는 순서를 결정하기 때문에 중요합니다.

MyView에 대한 요청이 있으면 다음 상속 체인을 따릅니다.

  1. MyView 클래스
  2. TemplateView 클래스
  3. MyMixin2 클래스
  4. MyMixin1 클래스
  5. LoginRequiredMixin 클래스

각 Mixin 클래스는 최종 View 클래스에 추가 특성, 메서드 또는 동작을 제공할 수 있습니다. 예를 들어 'LoginRequiredMixin'은 로그인 요구 사항을 적용하는 반면 'MyMixin1' 및 'MyMixin2'는 사용자 지정 기능을 추가하거나 기존 방법을 재정의할 수 있습니다.

여러 Mixin 클래스를 사용할 때 잠재적인 충돌 또는 메서드 이름 충돌을 인식하는 것이 중요합니다. 두 개의 Mixin이 동일한 메서드를 정의하는 경우 상속 순서에서 먼저 나타나는 Mixin 클래스의 메서드가 우선합니다.


📖자동으로 author 필드 채우기

blog/views.py를 수정하자.

CreateView에서 제공하는 form_valid() 함수를 사용할것이다.

PostCreate 클래스는 CreateView를 상속받아 만들었으므로 PostCreate에서 제공한 폼에 사용자가 제대로 내용을 입력하면 form_valid() 함수가 실행된다.

form_valid 함수는 방문자가 폼에 담아 보낸 유효한 정보를 사용하여 포스트를 만들고, 이 포스트의 고유 경로로 보내주는(redirect) 역할을 한다.

👉form_valid() 작성하기

다음과 같이 PostCreate에서 form_valid() 함수를 재정의하면 CreateView에서 기본으로 제공하는 form_valid() 함수의 기능을 확장할 수 있다.

CreateView 혹은 UpdateViewform_valid()는 폼 안에 들어온 값을 바탕으로 모델에 해당하는 인스턴스를 만들어 데이터베이스에 저장한 다음 그 인스턴스의 경로로 리다이렉트하는 역할을 한다. 이때 CreateViewform_valid() 함수를 오버라이딩하여 데이터베이스에 저장하기 전에 폼에 담고 있지 않았던 작성자 정보를 추가하자.

class PostCreate(LoginRequiredMixin, UserPassesTestMixin, CreateView):
    model = Post
    fields = ['title', 'hook_text', 'content', 'head_image', 'file_upload', 'category']
  

    def form_valid(self, form):
        current_user = self.request.user
        if current_user.is_authenticated and (current_user.is_staff or current_user.is_superuser):
            form.instance.author = current_user
            return super(PostCreate, self).form_valid(form)
        else:
            return redirect('/blog/')

self.request.user은 웹 사이트의 방문자를 의미.

is_authenticated로 방문자가 로그인한 상태인지 아닌지 확인.

로그인을 한상태라면 form에서 생성한 instance 즉, 새로 생성한 포스트의 author 필드에 current_user를 담는다. 그 상태에서 CreateView의 기본 form_valid() 함수에 현재의 form을 인자로 보내 처리한다.

만약, 방문자가 로그인하지 않은 상태라면 redirect() 함수를 사용해 /blog/ 경로로 돌려보낸다.

이후 테스트를 돌려보면 OK 사인이 나온다!!

그리고 웹 브라우저에서 127.0.0.1:8000/blog/create_post/ 주소를 직접 입력(아직 버튼을 안만들어서..)하여 들어가고 새로운 포스트를 작성해보자.

그러면 다음과 같이 작성자명이 자동으로 추가된다.

📖스태프만 포스트를 작성할 수 있게!!

관리자 페이지에서 Users 메뉴로 들어가면 다음과 같이 사용자의 등급을 일반 사용자와 스태프, 최고 관리자 까지 부여할 수 있다.

일반 사용자 말고 스태프 혹은 최고 관리자만 포스트를 작성할 수 있게 해보자.

👉테스트 코드 작성

steve에게 스태프 권환을 부여하고, mike는 일반 사용자로 설정하였다.

일반 사용자인 mije는 포스트 작성 페이지에 접근하면 안 되므로 status_code가 200이면 안된다.

반면 steve는 스태프이기 때문에 페이지가 정상적으로 열려야 한다.

다음 사진과 같이 위 내용을 반영하여 테스트코드를 수정했다.

👉UserPassesTestMixin 추가하기

blog/views.py에서 PostCreate 클래스를 수정하자.

앞에서 했던 것과 유사하게 UserPassesTestMixin를 인자로 추가하고, test_func() 함수를 추가해 이 페이지에 접근 가능한 사용자를 superuser 또는 staff로 제한할 수 있다.

form_valid() 에서도 로그인한 사용자가 superuser 이거나 staff인 경우에만 동작하도록 수정했다.

이후 테스트 코드를 실행해보면 OK가 나온다.

실제 웹 브라우저에서도 확인해보자. 관리자 계정을 로그아웃 한 이후 포스트 작성 페이지 URL을 입력하여 들어가면 페이지가 열리지 않는 것을 볼 수 있다.

👉새 포스트 작성 버튼 만들기

post_list.html을 다음과 같이 수정하자. 이때 로그인이 되어 있는 경우에 사용자가 superuser 이거나 staff 일 때만 버튼이 보이도록 설정했다.

실제 웹 브라우저를 열어 보면, 관리자 계정일 때는 보이고, 로그아웃하면 보이지 않는다.

이제 staff 권한으로 잘 작성이 되는지 확인한다. 관리자 페이지에 들어가 Users 옆에 있는 + 버튼을 눌러 staff 권한의 common_user을 생성했다.

이후 common_user로 로그인을 하고 관리자 페이지로 들어가보자. staff 이므로 접속이 제한이 된다.

하지만 127.0.0.1:8000/blog/에 가보면 New Post 버튼이 잘 보이고, 포스트도 잘 작성이 된다.


📌포스트 수정 페이지 만들기

📖테스트 코드로 기본 요건 정의하기

포스트 수정 페이지를 만드는데 있어서 원하는 상태는 다음과 같다.

  1. 로그인 하지 않은 상태에서 접근 하는 경우
  2. 로그인은 했지만, 작성자가 아닌 경우
  3. 작성자(mike)가 접근하는 경우

이를 토대로 함수 test_update_post를 구현해보자.

class TestView(TestCase):
    def setUp(self):
        self.client = Client()
        self.user_mike = User.objects.create_user(username='mike', password='somepassword')
        self.user_steve = User.objects.create_user(username='steve', password='somepassword')
        self.user_steve.is_staff = True
        self.user_steve.save()
		(..생략..)

        self.post_003 = Post.objects.create(
            title='세 번째 포스트입니다.',
            content="미분류 카테고리 테스트 용도 입니다.",
            author=self.user_mike
        )
  (..생략..)
def test_update_post(self):
    update_post_url = f'/blog/update_post/{self.post_003.pk}/'

    # 로그인 하지 않은 경우
    response = self.client.get(update_post_url)
    self.assertNotEqual(response.status_code, 200)

    # 로그인은 했지만, 작성자가 아닌 경우
    self.assertNotEqual(self.post_003.author, self.user_steve)
    self.client.login(
        username=self.user_steve.username,
        password='somepassword'
    )
    response = self.client.get(update_post_url)
    self.assertEqual(response.status_code, 403)

    # 작성자(mike)가 접근하는 경우
    self.client.login(
        username=self.post_003.author.username,
        password='somepassword'
    )
    response = self.client.get(update_post_url)
    self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')

    self.assertEqual('Edit Post - Blog', soup.title.text)
    main_area = soup.find('div', id='main-area')
    self.assertIn('Edit Post', main_area.text)

    response = self.client.post(
        update_post_url,
        {
            'title': '세번째 포스트를 수정했습니다. ',
            'content': '안녕 세계? 우리는 하나!',
            'category': self.category_algorithm.pk
        },
        follow=True
    )
    soup = BeautifulSoup(response.content, 'html.parser')
    main_area = soup.find('div', id='main-area')
    self.assertIn('세번째 포스트를 수정했습니다.', main_area.text)
    self.assertIn('안녕 세계? 우리는 하나!', main_area.text)
    self.assertIn(self.category_algorithm.name, main_area.text)
def test_update_post(self):
    update_post_url = f'/blog/update_post/{self.post_003.pk}/'

    # 로그인 하지 않은 경우
    response = self.client.get(update_post_url)
    self.assertNotEqual(response.status_code, 200)

    # 로그인은 했지만, 작성자가 아닌 경우
    self.assertNotEqual(self.post_003.author, self.user_steve)
    self.client.login(
        username=self.user_steve.username,
        password='somepassword'
    )
    response = self.client.get(update_post_url)
    self.assertEqual(response.status_code, 403)

    # 작성자(mike)가 접근하는 경우
    self.client.login(
        username=self.post_003.author.username,
        password='somepassword'
    )
    response = self.client.get(update_post_url)
    self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')

    self.assertEqual('Edit Post - Blog', soup.title.text)
    main_area = soup.find('div', id='main-area')
    self.assertIn('Edit Post', main_area.text)

    response = self.client.post(
        update_post_url,
        {
            'title': '세번째 포스트를 수정했습니다. ',
            'content': '안녕 세계? 우리는 하나!',
            'category': self.category_algorithm.pk
        },
        follow=True
    )
    soup = BeautifulSoup(response.content, 'html.parser')
    main_area = soup.find('div', id='main-area')
    self.assertIn('세번째 포스트를 수정했습니다.', main_area.text)
    self.assertIn('안녕 세계? 우리는 하나!', main_area.text)
    self.assertIn(self.category_algorithm.name, main_area.text)

우선 수정할 포스트는 setUp() 함수에서 미리 만들어둔 self.post_003 이다. 포스트 수정 페이지의 URL 형태는 /blog/update_post/포스트의 pk/로 정했다.

또한 수정 페이지는 기존 포스트의 작성자만 접근이 가능해야 한다. 로그인을 하지 않은 경우 페이지가 열리면 안되고, 로그인은 했지만 작성자가 아닌 경우에는 접근 권한이 없음을 나타내는 403 에러가 발생하는지 테스트한다. 물론 post_003의 작성자인 mike가 접근하는 경우 페이지가 열리는지도 테스트한다.

페이지가 열렸다면 웹 브라우저의 타이틀이 'Edit Post - Blog' 인지, 메인 영역에 'Edit Post'라는 문구가 있는지 확인한다. 이후 title, content, category 값을 모두 수정한 다음 POST 방식으로 update_post_url에 날린다. 이때 수정한 사항이 모두 반영되었는지 체크한다.

POST를 날릴 때 pk를 붙이는 이유

self.category_algorithm.pk처럼 왜 pk를 붙여야 하는지 헷갈렸다. 왜냐하면 setUp 함수에서 포스트를 생성할때는 pk를 붙이지 않았기 때문이다.

self.post_002 = Post.objects.create(...) 코드에서는 create() 메서드를 사용하여 Post 모델의 새로운 인스턴스를 생성하는 방식을 사용한다.
create() 메서드는 필드의 값을 직접 전달하여 새로운 인스턴스를 생성하므로 pk를 사용할 필요 없이, category=self.category_algorithm와 같이 self.category_algorithm을 사용하여 category 필드에 Category 모델의 인스턴스를 전달할 수 있다. 이 경우 self.category_algorithmCategory 모델의 인스턴스 자체를 참조하고 있기 때문에 pk를 사용할 필요가 없다.

하지만 self.client.post()를 사용하여 뷰(view)를 테스트할 때는 데이터를 HTTP POST 요청으로 전달해야 한다. 이때는 필드에 직접 인스턴스를 전달할 수 없으므로 해당 필드에 대한 기본 키(primary key)를 전달해야 한다.
'category': self.category_algorithm.pk와 같이 category 필드에 self.category_algorithm.pk를 전달하는 경우, 장고는 해당 기본 키를 사용하여 데이터베이스에서 Category 모델의 인스턴스를 찾는다.

📖URL 연결하고 View 작성

먼저 blog/urls.py에서 /blog/update_post/포스트의 pk/로 접근할 때 blog/views.pyPostUpdate 클래스를 사용하게 구현하자.

from django.urls import path
from . import views

urlpatterns = [
    path('', views.PostList.as_view()),
    path('<int:pk>/', views.PostDetail.as_view()),
    path('category/<str:slug>/', views.category_page),
    path('tag/<str:slug>/', views.tag_page),
    path('create_post/', views.PostCreate.as_view()),
    path('update_post/<int:pk>/', views.PostUpdate.as_view()), # 추가
]

그리고 UpdateView를 사용하여 PostUpdate를 만든다. PostCreate는 Post 레코드의 author 필드를 로그인한 사용자로 채워주는 기능을 추가하기 위해 form_valid()를 사용했지만, PostUpdate에서는 사용하지 않는다. 이미 작성자가 존재하기 때문이다.

또한 중요한 것은 포스트의 작성자만이 해당 포스트에 접근할 수 있어야 한다.

👉포스트 작성자만 수정할 수 있게 view 구현 by dispatch

해당 기능은 CBV에 포함되어 있는 dispatch 메서드를 사용하면 된다.

Django에서 dispatch() 메서드는 HTTP 요청을 처리하고 요청의 HTTP 메서드(GET, POST 등)에 따라 적절한 메서드로 라우팅하는 역할을 한다. CBV에 대한 요청이 있을 때 Django는 먼저 dispatch() 메서드를 호출한 다음, 요청을 처리할 적절한 HTTP 메서드를 결정한다. dispatch() 메서드는 다음 작업을 수행합니다.

  1. 요청의 HTTP 메서드(예: GET, POST, PUT, DELETE)를 검사합니다.

  2. 요청의 HTTP 메서드를 기반으로 해당 HTTP 메서드별 메서드를 호출합니다.

  3. 인증, 권한 부여, 양식 유효성 검사 및 응답 렌더링과 같은 일반적인 작업을 처리합니다.

    CreateViewUpdateView의 경우 방문자가 서버에 GET 방식으로 들어오면 포스트를 작성할 수 있는 폼 페이지를 보내준다. 반면에 같은경로로 폼에 내용을 담아 POST 방식으로 들어오는 경우에는 폼이 유효한지 확인하고, 문제가 없다면 데이터베이스에 내용을 저장하도록 되어있다.

    만약 권한이 없는 사용자가 PostUpdate를 사용하려고 한다면, 서버와 통신하는 방식이 GET이든 POST든 상관없이 접근할 수 없게 해야한다. 따라서 dispatch()가 실행되는 순간 방문자가 포스트의 작성자가 맞는지 확인하도록 다음과 같이 구현한다.

from django.shortcuts import render, redirect
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from .models import Post, Category, Tag
from django.core.exceptions import PermissionDenied


class PostUpdate(LoginRequiredMixin, UpdateView):
    model = Post
    fields = ['title', 'hook_text', 'content', 'head_image', 'file_upload', 'category']

    def dispatch(self, request, *args, **kwargs):
        if request.user.is_authenticated and request.user == self.get_object().author:
            return super(PostUpdate, self).dispatch(request, *args, **kwargs)
        else:
            raise PermissionDenied

방문자인 request.user은 로그인한 상태여야 한다. 그리고 self.get_object().author에서 self.get_object()UpdateView의 메서드로 Post.objects.get(pk=pk)와 동일한 역할을 한다.

이렇게 가져온 Post 인스턴스(레코드)의 author 필드가 방문자와 동일한 경우에만 dispatch() 메서드가 원래 역할을 하게한다. 만약 이 조건을 만족시키지 못한다면 권한이 없음을 나타내기 위해 raise PermissionDenied를 실행한다.

이렇게 해두면 권한이 없는 방문자가 타인의 포스트를 수정하려고 할 때 403 에러를 발생시킨다.

📖템플릿 만들기

CreateView, UpdateView는 모델명 뒤에 _form.html이 붙은 템플릿을 사용하는게 기본으로 설정되어 있다. 앞서 템플릿 작성 페이지를 만들 때 post_form.html을 만들어 놨기 때문에 해당 내용을 복사하여 그대로 붙여넣기 한 post_update_form.html을 만들자.

그리고 테스트코드에 적힌 내용에 맞게 다음과 같이 수정을 한다.

{% extends 'blog/base_full_width.html' %}

{% block head_title %}Edit Post - Blog{% endblock %}

{% block main_area %}
    <h1>Edit Post</h1>
    <hr/>
    <form method="post" enctype="multipart/form-data">{% csrf_token %}
        <table>
            {{ form }}
        </table>
        <button type="submit" class="btn btn-primary float-right">Submit</button>
    </form>
{% endblock %}

또한 템플릿 파일 이름을 기본 설정과 다르게 했으니, PostUpdate 클래스가 해당 템플릿을 찾을 수 있게 template_name을 다음과 같이 지정하자.

# blog/views.py
class PostUpdate(LoginRequiredMixin, UpdateView):
    model = Post
    fields = ['title', 'hook_text', 'content', 'head_image', 'file_upload', 'category']

    template_name = 'blog/post_update_form.html'

    def dispatch(self, request, *args, **kwargs):
        if request.user.is_authenticated and request.user == self.get_object().author:
            return super(PostUpdate, self).dispatch(request, *args, **kwargs)
        else:
            raise PermissionDenied

이제 테스트를 진행하여 OK가 뜨는지 확인한다.

📖포스트 상세 페이지에 수정 버튼 추가하기

앞서 포스트 수정 페이지에 대하여 URL을 만들고, View를 구현하고, 템플릿을 만들었다. 하지만 한 가지 빠트린게 있다. 바로 포스트 수정 페이지로 가는 버튼을 추가하지 못했다.

보통 포스트 수정은 상세페이지에 들어가서 수정버튼을 클릭하고 수정하기 마련이다. 그러므고 포스트 상세 페이지에 수정 버튼을 추가해보자.

<Edit Post> 버튼 만들기

포스트 목록 페이지에서 <New Post> 버튼을 만드는 과정과 거의 똑같다. if 문을 활용하여 웹 사이트 방문자가 로그인 되어 있는 상태이고, 해당 포스트의 작성자일 경우에만 버튼이 보이도록 한다.

또한 버튼에 <a> 태그를 활용한 것을 선택하여 href 링크를 urls.py에 정의한 경로로 수정한다.

<!-- post_detail.html -->
  
(..생략..)
<!-- Author -->
    <p class="lead">
        by
        <a href="#">{{ post.author | upper }}</a>
    </p>

    <hr>
    {% if user.is_authenticated and user == post.author %}
        <a class="btn btn-info btn-sm float-right" href="/blog/update_post/{{ post.pk }}/" role="button"><i class="fas fa-pen"></i>  Edit Post</a>
    {% endif %}
    <!-- Date/Time -->
(..생략..)

직접 포스트 상세 페이지를 열어보니 다음과 같이 버튼이 잘 보인다.

마찬가지로 수정 내용도 잘 반영이 되는 모습이다.



📌태그 선택란 추가하기

앞서 CreateViewUpdateView로 포스트 작성과 포스트 수정 페이지를 만들 때 태그 기능은 빼고 구현했다.

임시로 PostCreate 클래스의 fields 리스트 tag를 추가하고, 포스트 작성 페이지를 열어봤다.

위 그림처럼 현재 존재하는 태그만 선택할 수 있다. Post 레코드의 tags 필드가 ManyToManyField로 지정되어 있어 기본적으로 제공하는 폼이 어련 형태를 가지게 된 것이다. 현재 상태에서는 새로운 태그를 만들 수가 없다.

태그1; 태그2; 태그3; 처럼 작성자가 원하는 태그를 텍스트로 직접 입력할 수 있게 만들어보자.

📖CreateView가 만들어준 내용 분석

앞서 포스트 작성 페이지를 만들 때 CreateView를 활용한 form을 사용하였다. 하지만 CreateView가 만들어준 내용 즉, html의 {{ form }}에 어떤 내용이 들어가는지 모른다.

서버를 실행시키고, 127.0.0.1:8000/blog/create_post/로 가서 Ctrl + U를 눌러보자. 그러면 현재 페이지의 소스가 최종적으로 어떻게 구성되어 있는지 확인할 수 있다.

위 사진과 같이 <table> 태그 안에 {{ form }}을 넣었던 곳에는 PostCreate 클래스에서 fields 리스트에 지정한 필드들이 테이블의 한 줄씩 차지하고 있다.

그리고 필드마다 <tr> 태그로 줄을 하나씩 추가하고, <th>로 필드명을 표시한 이후, <td> 안에 input 요소를 필드의 데이터 타입에 맞게 만들어서 제공하고 있는 모습이다.

📖템플릿 파일에 input 추가하기

post_form.html{{ form }} 밑에 tags 필드를 추가해주자.

<tr> 태그로 한 줄을 더 추가하고, 그 안에 labelinput요소를 넣어줬다. input 요소에는 문자를 입력 받을 수 있도록 type="text" name="tags_str" id="id_tags_str"로 속성을 추가해줬다.

이제 웹 브라우저에서 어떻게 보이는지 확인해보면, 원래 있던 태그 입력란과 새로 만든 Tags 입력란이 보인다.

이제 원래 있던 태그 입력란은 필요없으니 PostCreate클래스에서 fields리스트에 추가했던 tags를 삭제하자.

📖포스트 작성 페이지에 태그 입력란 추가하기

태그를 텍스트로 작성하여 추가할 수 있는 기능을 구현해보자.

예를 들어 입력란에 파이썬; 세미콜론, 쉼표를 작성하고 제출을 누르면 python, 한글 태그 new tag라는 태그가 추가된다. 이 중에서 python 태그는 이미 존재하므로 새로 생성하지 않아야하고, 한글 태그, new tag를 새로 생성한 다음 새로 만든 포스트에 연결되도록 해야한다.

👉테스트 코드 작성

  1. 포스트 작성 페이지의 main_areaid='id_tags_strinput이 존재하는지 확인
  2. self.client.posttags_str를 추가하고 new tag; 한글 태그, python을 담아 보낸다.
    세미콜론과 쉼표를 모두 사용한 이유는 두 가지 모두가 구분자로 잘 작동하는지 테스트하기 위함이다.
  3. POST 방식으로 서버에 전송/요청하고 나면 새로운 포스트가 생성되어 데이터베이스에 저장(save)되어야 하고, 이 포스트의 태그는 3개여야 한다.
  4. 이미 존재하는 python 태그와 달리 new tag한글 태그는 새로 생성되어 있어야한다. 즉, 데이터베이스에 총 5개의 태그가 저장되어 있어야 한다.

👉views.py 수정하기

post_form.html에 추가한 name='tags_str'input 요소에 입력된 값을 가져오기 위해 form_valid()를 오버라이딩 한다.

  1. response = super(PostCreate, self).form_valid(form) : 태그와 관련된 작업을 하기 전에 form_valid() 함수의 결괏값을 response라는 변수에 임시로 담아둔다.

  2. self.request.POST.get('tags_str') : 앞서 post_form.html<form> 태그를 보면 method='post'로 되어 있다. 또한 해당 폼 안에 name='tags_str'input을 추가하였다. 즉, 작성자가 태그를 입력하여 제출 버튼을 누르면, 해당 정보들이 POST 방식으로 서버로 넘어오는데, 해당 내용 중 태그와 관련된 값은 self.request.POST.get('tags_str')로 얻을 수 있다.

  3. tag_str이 존재한다면 쉼펴, 세미콜론을 기준으로 문자열을 다 자르고 tags_list라는 리스트에 담는다.

  4. tags_list에 담겨 있는 값은 문자열 형태이므로, Tag 모델의 인스턴스로 변환해야한다. 만약 태그가 존재한다면 가져오고, 없다면 새로 만들어야 하는데, 이는 Tag.objects.get_or_create()로 가능하다.
    첫 번째는 Tag 모델의 인스턴스 이고, 두 번째는 이 인스턴스가 새로 생성되었는지를 나타내는 bool 값이다.

  5. 만약 태그가 새로 생성되었다면, slug값이 아직 없는 상태이므로 생성해준다. 관리자 페이지에서 작업할 때는 자동으로 생성해줬지만 이번에는 get_or_create() 메서드로 생성했기 때문에 slugify로 직접 생성해줘야한다. 이후 save로 name과 slug 필드를 모두 채운 채로 DB에 저장.

👀그런데 1번에서 왜 태그 관련 내용을 추가하기에 앞서 form_valid()를 사용할까?
CreateView 혹은 UpdateViewform_valid()는 폼 안에 들어온 값을 바탕으로 모델에 해당하는 인스턴스를 만들어 데이터베이스에 저장한 다음 그 인스턴스의 경로로 리다이렉트하는 역할을 한다. 이때 CreateViewform_valid() 함수를 오버라이딩하여 데이터베이스에 저장하기 전에 폼에 담고 있지 않았던 작성자 정보를 추가하고 싶었기 때문이다. 문제는 포스트에 태그를 추가하기 위해서는 포스트가 이미 데이터베이스에 저장되어 pk를 부텨받은 다음이어야 한다는 점이다. Post 모델과 Tag 모델은 다대다 관계이므로 Post 레코드가 이미 존재해야 하기 떄문이다. 그래서 태그와 관련된 작업을 하기 전에 form_valid()함수를 사용하고 그 결과를 임시로 저장해두는 것이다. 새로 저장된 포스트는 self.object라고 가져올 수 있게 장고가 구성하므로 여기에서 tags 필드에 원하는 태그를 추가할 수 있게된다.


이제 테스트 코드를 돌려보고, 웹 브라우저에서 정말로 테스트가 잘 되었는지 봐보자.

📖포스트 수정 시 태그 입력란 추가하기

포스트 수정 페이지에서도 태그를 텍스트로 입력하여 새로 추가할 수 있도록 하자.

👉템플릿 수정하기

앞서 post_form.html을 수정했을 때 처럼 post_update_form.htmltags_str을 담기 위한 내용을 다음과 같이 추가한다.

👉뷰 수정하기

포스트 수정 페이지에서는 현재 포스트의 태그가 나열되어 있어야 사용자가 수정하지 않고 제출을 했을 때 현재 상태가 그대로 유지될 수 있다. 즉, 해당 포스트의 기존 태그가 자동으로 입력되어야 한다는 것이다.

기존 태그가 자동으로 입력되도록 만들이 위해서는 post_update_form.html에서 Tags를 입력하기 위해 만든 input 요소에 value라는 속성을 추가하면 된다.

위 사진처럼 tags_str_default를 value 속성의 값으로 넣어주고, 구현해보자.

CBV로 뷰를 만들 때 템플릿으로 추가 인자를 넘기려면 get_context_data()를 이용한다는 것을 PostList와 PostDetail` 클래스에서 배웠다.

get_context_data()메소드를 다음과 같이 재정의한다.

  1. 만약 해당 포스트에 tags가 존재한다면, 이 tags의 name을 리스트 형태로 담는다.

  2. 이 리스트의 값들을 세미콜론으로 결합하여 하나의 문자열로 만든다.

  3. 그 결과를 context['tags_str_default']에 담아 리턴하여 템플릿에서 사용할 수 있게 한다.

기존 태그가 자동으로 입력되게 하는 기능을 구현 했다.

이제는 앞서 포스트 생성 시 태그를 텍스트로 입력하여 추가할 수 있도록 해야한다. 똑같이 form_valid()를 재정의 하여 사용한다.

먼저 self.object로 가져온 포스트의 태그를 모두 삭제하는 내용을 추가한다. 왜냐하면 포스트 수정 시 이미 입력되어 있는 태그들이 있고, 해당 태그들은 또 POST를 통해 서버에 들어오므로 중복되기 때문이다. 그러므로 애초에 포스트의 태그를 모두 비워버리고 tags_str로 들어온 값으로 다시 채운다. 나머지는 앞서 했던 내용과 같다.


이제 웹 브라우저에서 직접 확인해보자. 앞서 생성한 포스트엣 Edit Post를 눌러 포스트 수정 페이지에 들어간 이후, 태그에 장고를 추가해봤다.

그럼 다음과 같이 장고가 태그에 추가된 모습을 볼 수 있었다!!!


정리

  1. form에 대해서 알았다.
  2. LoginRequiredMixin, UserPassesTestMixin 클래스와 test_func, form_valid, dispatch 메서드에 대해서 더 공부하고 알아보자.
  3. 카테고리도 태그처럼 직접 추가해보자.
profile
AI 서비스 엔지니어를 목표로 공부하고 있습니다.

0개의 댓글