[Do it 장고 + 부트스트랩] 13장. 다대일 관계 구현하기

정재욱·2023년 6월 14일
0
post-thumbnail

13장에서는 Post 모델에 작성자를 추가하고 카테고리 기능을 구현한다. 이들은 각기 다른 모델끼리 연결해야 하는데, 이런 모델 간의 관계를 정의하는 방법에 대해서 알아본다.

📌작성자 추가하기

웹 개발을 하다 보면 각기 다른 정보를 연결해야 할 때가 있다. 예를 들어 웹 사이트에 James라는 사용자가 여러 개의 블로그 포스트를 작성한 경우를 생각해보자.

  1. 포스트에 작성자 정보를 담을 필요할 것이다.
  2. 어떤 사용자가 어떤 블로그 포스트에 댓글을 남겼다면 이 댓글이 어떤 포스트에 대한 댓글이고 작성자는 누구인지등의 정보를 담을 필드가 필요.

이처럼 정보를 연결하는 방법으로 다대일 관계와 다대다 관계가 있다. 이 장에서는 다대일 관계에 대해 살펴본다.

📖다대일 관계 이해하기

다대일 관계(One-to-Many relationship)는 장고 모델 간의 관계를 말한다.
한 모델의 인스턴스가 다른 모델의 여러 인스턴스와 연결되는 경우 혹은 하나의 모델이 여러개의 모델과 연결되는 경우 사용된다. 다음 그림을 보자.

예를 들어, '게시물(Post)'과 '작성자(Author)'라는 두 개의 모델이 있다고 가정해 보자.
James라는 사용자가 여러 개의 Post를 작성한 것 처럼, 여러개의 게시물이 한 명의 작성자와 연결될 수 있고, 하나의 게시물에는 한 명의 작성자가 존재하기 때문에 이는 다대일 관계이다.

카테고리를 봐보자. 마찬가지고 하나의 게시물에는 하나의 카테고리만 지정할 수 있다. 반면에 하나의 카테고리에는 여러개의 게시물이 포함될 수 있다. 이 역시 다대일 관계이다.

📖author 필드에는 어떤 내용이 필요할까?

이제 그동안 미뤘던 author 필드를 구현할 차례다. 그렇다면 author 필드에는 어떤 내용을 담아야 할까?

  1. 어떤 사용자가 포스트를 작성했을 때 사용자명을 문자열로 저장해야 한다.

  2. 사용자가 사용자명을 바꿨을 때 이전에 작성한 글의 작성자명도 함께 바뀌어야 한다.

  3. 만약 사용자가 탈퇴하거나 글을 삭제하면 작성자명을 'unknown'으로 표시하는 기능도 필요하다.

이처럼 작성자 정보를 담는 author 필드에는 여러 기능이 필요ㅕ하므로 따로 만든 다음 포스트와 연결하는 것이 현명하다.

작성자 정보 하나에 여러 포스트를 연결하는 다대일 관계는 장고 모델에서 ForeignKey 필드를 사용하여 정의한다.

📖ForeignKey로 author 필드 구현하기

우선 ForeignKey로 연결된 다른 모델의 레코드가 삭제되었을 때 함께 삭제되는 방식으로 author 필드를 구현해보자.

👉models.py에 author 필드 추가하기

User 모델을 사용해야 하므로 from django.contrib.auth.models import UserUser를 임포트 한다. User은 장고에서 기본적으로 제공하는 모델이다.

User를 사용해 다음과 같이 author 필드를 만든다.

from django.db import models
from django.contrib.auth.models import User
import os


class Post(models.Model):  # models 모듈의 Model 클래스를 확장하여 만든 클래스
    """
    포스트의 형태를 정의하는 Post 모델
    제목(title), 내용(content), 작성일(created_at), 작성자 정보(author)
    """
    title = models.CharField(max_length=30)  # CharField : 문자를 담는 필드
    hook_text = models.CharField(max_length=100, blank=True)
    content = models.TextField()  # TextField : 문자열의 길이 제한이 없는 필드

    head_image = models.ImageField(upload_to='blog/images/%Y/%m/%d/', blank=True)
    file_upload = models.FileField(upload_to='blog/files/%Y/%m/%d/', blank=True)

    created_at = models.DateTimeField(auto_now_add=True)  # DateTimeField : 월, 일, 시, 분, 초까지 기록하게 해주는 필드
    updated_at = models.DateTimeField(auto_now=True)


    # author: 추후 작성 예정, 외래키를 구현할 시 다룰 것.
    author = models.ForeignKey(User, on_delete=models.CASCADE)

    def __str__(self):
        return f'[{self.pk}]{self.title} :: {self.author}'

author = models.ForeignKey(User, on_delete=models.CASCADE)에서 on_delete=models.CASCADE는 이 포스트의 작성자가 데이터베이스에서 삭제되었을 때 이 포스트도 같이 삭제한다는 의미다.

마지막으로 포스트 목록에서 작성자 정보까지 출력되도록 __str__() 함수를 수정했다.

👉마이그레이션하기

Post 모델의 변경 내용을 데이터베이스에 적용시킨다.

터미널에서 python manage.py makemigrations를 입력해보면 다음과 같이 처음 보는 경고메시지가 나온다.


메시지 내용을 보면, "Post 모델의 author 필드는 null일 수 없는데, default 값도 설정하지 않고 추가하려고 한다"는 내용이다. 그래서 default 값을 지금 추가하기(1) 또는 일단 취소하고 models.py 수정하기(2) 중에 하나를 선택하라고 하는 내용이다.

이런 메시지가 나오는 이유는 이미 필자가 Post 모델의 레코드를 여러 개 만들어 놓았기 때문이었다. 새로 만드는 author 필드를 null로 처리할 수 없으니 뭔가를 채워넣어야 하는데, 무엇을 채워넣어야 할지 모르겠다는 이야기다.

(1)을 선택하면 이미 존재하는 Post 레코드의 author에 바로 값을 넣을 수 있다.
(2)를 선택하고 빠져나와 models.py의 author 필드 부분을 author = models.ForeignKey(User, null=True, on_delete=models.CASCADE)로 수정할 수도 있다. 이러면 author 필드를 null로 둘 수 있고, 이미 존재하는 Post 레코드는 author가 빈 상태가 된다.

필자는 (1)을 선택하고, 1을 입력하여 User 중에 pk 값이 1인 jaeookk을 작성자로 지정하였다. 여기서 jaeookk은 관리자 페이지를 만들때 등록한 계정이다.

이제 데이터베이스에 적용하기 위해 python manage.py migrate를 진행하고 서버를 실행하여 관리자 페이지에서 포스트 하나를 열어보자.

그럼 위 그림과 같이 Author 입력란이 생기고, 작성자가 jaeookk으로 설정된 것도 볼 수 있었다.

👉다른 사용자가 게시물을 작성할 때 테스트하기

내 관리자 계정이 아닌 다른 사용자가 다른 게시물을 작성하는 경우도 테스트해봤다.

먼저 다음과 같이 관리자 페이지에서 새로운 자용자를 만든다.

정보를 입력하고 SAVE 버튼을 누르면 다음과 같이 새로운 User인 Mike가 생성된 것을 볼 수 있었다.

이제 관리자 페이지의 BLOG -> Posts로 들어가 새로운 포스트를 작성하고, Author로 Mike를 지정한다.

포스트 목록을 보면 새로 작성한 포스트에 새로 만든 작성자명까지 잘 출력된다.

👉작성자 정보가 삭제될 때 포스트까지 삭제되는지 확인하기

앞서 models.py의 author 필드에서 on_delete=models.CASCADE로 지정하였다.

사용자 Mike의 아이디가 삭제되었을 때 Mike가 작성한 포스트까지 삭제되는지 확인해보자.

관리자 페이지의 Users에 가서 Mike를 선택하고 Action을 Delete selected users로 선택한 후 Go 버튼을 클릭하자.

그러면 다음과 같이 포스트도 삭제된다는 경고 메시지가 나온다.

계속 진행하여 포스트 목록에 가보면 Mike가 작성자인 포스트가 삭제된 것을 볼 수 있었다.


📖연결된 사용자가 삭제되면 빈 칸으로 두기

이번에는 사용자가 삭제되어도, 사용자가 작성한 글은 남겨두고 author 필드 값만 null로 바뀌도록 해봤다.

👉on_delete=models.SET_NULL 사용하기

on_delete=models.CASCADEon_delete=models.SET_NULL로 수정하고, null=True도 역시 추가하자.

on_delete=models.SET_NULL은 포스트의 작성자가 데이터베이스에서 삭제되었을 때 작성자명을 빈 칸으로 둔다는 의미다.

이후 마이그레이션을 진행한다.

👉포스트 작성자가 삭제되었을 때 테스트하기

앞서 사용자를 추가했던 것과 같은 방식으로 Steve라는 사용자를 추가하고 몇 개의 포스트를 작성해봤다.

이제 Users로 가서 Steve를 삭제해봤다. 그럼 이전과 다르게 작성자를 None으로 표시하는 것을 볼 수 있었다.


📖포스트 목록 페이지와 포스트 상세 페이지에 author 반영하기

작성자 정보가 포스트 목록 페이지와 포스트 상세 페이지에 나오도록 해보자.

👉포스트 목록 페이지에 작성자 추가하기

테스트 코드부터 먼저 만들어 봤다. User 모델을 사용하기 위해 from django.contrib.auth.models import User로 User 모델을 임포트한다. 그리고 TestView 클래스의 setUp() 함수에 다음과 같이 User 레코드를 2개 생성했다.

from django.test import TestCase, Client
from bs4 import BeautifulSoup
from django.contrib.auth.models import User
from .models import Post


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

User.objects.create_user()로 이름이 'mike'이고 비밀번호가 'somepassword'인 사용자를 생성했다. 같은 방법으로 steve 계정도 만들었다.

test_post_list() 함수에서는 Post 모델의 post_001과 post_002 레코드를 생성할 때 각각 author=self.user_mike와 author=self.user_steve를 인자로 추가했다. 그리고 메인 영역에서 작성자명으로 mike와 steve가 나오는지 확인하기 위해 test_post_list() 함수의 맨 마지막에 코드를 추가했다. 이때 username 뒤에 .upper()를 추가하여 작성자명이 대문자로 표기되도록 설정하였다.

def test_post_list(self):
	# 1.1 포스트 목록 페이지를 가져온다.
    response = self.client.get('/blog/')
    # 1.2 정상적으로 페이지가 로드된다.
    self.assertEqual(response.status_code, 200)
    # 1.3 페이지 타이틀은 'Blog'이다.
    soup = BeautifulSoup(response.content, 'html.parser')
    self.assertEqual(soup.title.text, 'Blog')
    # 1.4 내비게이션 바가 있다.
    self.navbar_test(soup)

	# 2.1 메인 영역에 게이물이 하나도 없다면
    self.assertEqual(Post.objects.count(), 0)
    # 2.2 main_area에 '아직 게시물이 없습니다'라는 문구가 보인다
    main_area = soup.find('div', id='main-area')
    self.assertIn('아직 게시물이 없습니다.', main_area.text)
    
    
    # 3.1 게시물이 2개 있다면
    post_001 = Post.objects.create(
    	title='첫 번째 포스트입니다.',
    	content="Hello World.",
        author=self.user_mike
        )
    post_002 = Post.objects.create(
    	title='두 번째 포스트입니다.',
        content="We are the World.",
        author=self.user_steve
        )
    self.assertEqual(Post.objects.count(), 2)
    # 3.2 포스트 목록 페이지를 새로고침했을 때
    response = self.client.get('/blog/')
    soup = BeautifulSoup(response.content, 'html.parser')
    self.assertEqual(response.status_code, 200)
    # 3.3 메인 영역에 포스트 2개의 타이틀이 존재한다
    main_area = soup.find('div', id='main-area')
    self.assertIn(post_001.title, main_area.text)
    self.assertIn(post_002.title, main_area.text)
    # 3.4 '아직 게시물이 없습니다'라는 문구는 더 이상 보이지 않는다
    self.assertNotIn('아직 게시물이 없습니다', main_area.text)
    
    self.assertIn(self.user_mike.username.upper(), main_area.text)
    self.assertIn(self.user_steve.username.upper(), main_area.text)

이제 post_list.html에서 사용자 이름이 보이도록 파일을 수정해야 한다. 미리 작성해 둔 '작성자명 쓸 위치(개발예정)'을 지우고 그 자리에 {{ p.author | upper }}를 입력했다.

여기서 파이프기호 |는 템플릿 필터이다. 템플릿 필터는 변수의 값을 특정 형식으로 변환할 때 사용한다. 주로 문자열이나 리스트 등의 길이구하기, 슬라이싱, 합치기 등에 활용된다. https://velog.io/@jewon119/Django-%EA%B8%B0%EC%B4%88-Template-Language를 참고하자!!

그리고 테스트를 해보니 OK가 나왔다. 서버를 실행시켜 웹 브라우저에서도 확인해봤다.

잘 나온다!!!

👉포스트 상세 페이지에 작성자 추가하기

마찬가지로 test_post_detail() 함수에도 Post 모델 레코드를 하나 생성할 때 작성자명을 포함하도록 수정했다. 또한 작성자가 화면에 잘 출력되는지 확인하는 코드도 추가한다.

def test_post_detail(self):
	post_000 = Post.objects.create(
    	title = '첫 번째 포스트입니다.',
        content='Hello World',
        author=self.user_mike, # 추가
        )
    self.assertEqual(post_000.get_absolute_url(), '/blog/1/')


	response = self.client.get(post_000.get_absolute_url())
	self.assertEqual(response.status_code, 200)
	soup = BeautifulSoup(response.content, 'html.parser')

	self.navbar_test(soup)
	
	self.assertIn(post_000.title, soup.title.text)
	
	main_area = soup.find('div', id='main-area')
	post_area = main_area.find('div', id='post-area')
	self.assertIn(post_000.title, post_area.text)
	
	self.assertIn(self.user_mike.username.upper(), post_area.text) # 추가
	
	self.assertIn(post_000.content, post_area.text)

마찬가지로 post_detail.html을 post_list.html과 같이 수정한다.

이후 테스트를 실행하면 OK가 나오고, 웹 브라우저를 통해 확인해봐도 상세 페이지에 작성자명이 잘 나오는 것을 볼 수 있었다.


📌카테고리 기능 구현하기

현재 구상한 카테고리 카드와 뱃지, 그리고 카테고리를 클릭시 나오는 카테고리 페이지의 모습은 다음과 같다.

각각의 포스트는 하나의 카테고리만 지정할 수 있다. 하지만 카테고리에는 여러 개의 포스트가 포함될 수 있다.

즉, 포스트와 카테고리도 다대일 관계인 것이다.

author 필드는 장고에서 제공하는 User 모델을 사용했지만, 카테고리 모델은 직접 개발해봤다.

📖Category 모델 만들기

👉models.py에 Category 모델 만들기

blog/models.py에 Category 모델을 만들어 보자.

Category 모델에는 name과 slug라는 필드를 만들었다.

name 필드는 각 카테고리의 이름을 담는 필드로, CharField를 사용했고, unique=True를 추가하였다. 이 설정은 동일한 name을 갖는 카테고리를 또 만들 수 없게 해준다.

slug = models.SlugField(max_length=200, unique=True, allow_unicode=True)를 통해 slug필드를 만들었다. 이때 사용한 SlugField는 사람이 읽을 수 있는 텍스트로 고유 URL을 만들어 준다. Category 모델도 Post 모델처럼 pk를 활용하여 URL을 만들 수 있지만, 카테고리는 포스트만큼 개수가 많지 않을 것이기 때문에 사람이 읽고 그 뜻을 알 수 있게 고유 URL을 사용하게 했다.
SlugField는 한글을 지원하지 않지만, allow_unicode=True를 통해 한글로도 만들 수 있게 하였다.

Category 모델의 메타 설정에서 verbose_name_plural을 추가해 이름을 Categories로 수정했다.
class Meta는 Django 모델 클래스에서 사용되는 내부 클래스이다. Meta 클래스는 모델에 대한 메타데이터를 정의하는 데 사용된다. verbose_name_plural은 모델의 복수형 표현을 지정하는 속성이다. 이는 Django admin 사이트나 다른 부분에서 모델 이름을 표시할 때 사용될 수 있다. 예를 들어, Django admin에서 카테고리 모델의 리스트를 표시할 때 "Categories"라는 표현이 사용될 것이다.

from django.db import models
from django.contrib.auth.models import User
import os


class Category(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=200, unique=True, allow_unicode=True)

    def __str__(self):
        return self.name
        
    class Meta:
        verbose_name_plural = 'Categories'

class Post(models.Model):
(..생략..)

📖Post 모델에 category 필드 추가하기

category = models.ForeignKey(Category, null=True, on_delete=models.SET_NULL)

카테고리는 미분류인 포스트도 있을 수 있으므로 null=True로 하였다. 그리고 ForeignKey로 연결되어 있던 카테고리가 삭제된 경우 연결된 포스트까지 삭제되지 않고 해당 포스트의 category 필드만 null이 되도록 on_delete=models.SET_NULL로 설정했다.

새로운 모델을 만들었으니 마이그레이션을 하자.

(..생략..)
class Post(models.Model):
    title = models.CharField(max_length=30)
    hook_text = models.CharField(max_length=100, blank=True)
    content = models.TextField()
    head_image = models.ImageField(upload_to='blog/images/%Y/%m/%d/', blank=True)
    file_upload = models.FileField(upload_to='blog/files/%Y/%m/%d/', blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)

    category = models.ForeignKey(Category, null=True, blank=True, on_delete=models.SET_NULL)

    def __str__(self):
        return f'[{self.pk}]{self.title} :: {self.author}'
(..생략..)

👉admin.py에 Category 모델 등록하기

blog/admin.py를 열어 Category 모델을 임포트한다.

Post 모델을 등록할 때와는 다르게, CategoryAdmin이라는 클래스를 추가로 만들었다. 그리고 prepopulated_fields = {'slug': ('name',)}를 지정한다. 이렇게 하면 Category 모델의 name 필드에 값이 입력됐을 대 자동으로 slug가 만들어진다. 이는 나중에 관리자 페이지에서의 실습을 통해 더 자세히 알아보자.

from django.contrib import admin
from .models import Post, Category

# Register your models here.
admin.site.register(Post)  # 관리자 페이지에 Post 등록하기


class CategoryAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}


admin.site.register(Category, CategoryAdmin)

관리자 페이지에 들어가보면 다음과 같이 Categorys 메뉴가 추가되어 있다.

Categories의 Add 버튼을 클릭하여 카테고리 생성 페이지로 들어간다. 그리고 Name에 "프로그래밍"과 "문화 & 예술" 이라는 이름으로 카테고리를 만들어보면, 신기하게도 Name에 입력한 특수기호나 빈칸 등 URL로 사용하기 적합하지 않은 문자를 적절하게 변환한 다음 Slug에 채워준다.

이는 admin.py 에서 만든 CategoryAdmin 클래스 덕분이다.


📖포스트 목록 페이지 수정하기

👉페이지 모양 구상하기

Category 모델을 만들었으니 이제 사용자가 이를 선택할 수 있게 해야한다. 먼저 앞서 구상한대로 포스트 목록 페이지를 수정해야한다...

현재 부트스트랩으로 가져온 포스트 목록 페이지는 다음과 같다.

이를 다음 그림처럼 수정하고자 한다.

위 그림처럼 포스트 목록 페이지의 오른쪽에 카테고리 카드를 위치시키고, 그 안에 카테고리를 나열하고, 각각의 카테고리 옆에는 해당 카테고리에 속한 포스트의 개수를 괄호 안에 표시하려고 한다. 또한 포스트 요약 부분에도 카테고리를 뱃지 모양으로 표시하고자 한다. 이를 구현해보자.

👉테스트 코드 작성하기

먼저 setUp() 함수에서 programming과 algorithm이라는 이름으로 Category 레코드를 2개 만든다.

setUp() 함수는 TestCase의 초기 데이터베이스 상태를 정의할 수 있기 때문에 같은 클래스 안에 있는 다른 테스트 함수에도 공통적으로 적용이 된다.

따라서 TestView 클래스 내의 모든 테스트를 시작할 때, 이미 테스트 데이터베이스에 카테고리가 2개 생성되어 있는 상태로 만들 수 있다!!!

또한 매번 테스트할 때마다 여러 포스트를 만들지 않고 setUp() 함수에서 미리 만드는 방식으로 수정했다. test_post_list()에서 Post.objects.create()로 만들었던 요소를 모두 복사하여 setUp() 함수로 옮겼다.

from django.test import TestCase, Client
from bs4 import BeautifulSoup
from django.contrib.auth.models import User
from .models import Post, Category


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.category_programming = Category.objects.create(name='programming', slug='programming')
        self.category_algorithm = Category.objects.create(name='algorithm', slug='algorithm')

        self.post_001 = Post.objects.create(
            title='첫 번째 포스트입니다.',
            content="Hello World.",
            category=self.category_programming,
            author=self.user_mike,
        )
        self.post_002 = Post.objects.create(
            title='두 번째 포스트입니다.',
            content="We are the World.",
            category=self.category_algorithm,
            author=self.user_steve
        )
        self.post_003 = Post.objects.create(
            title='세 번째 포스트입니다.',
            content="미분류 카테고리 테스트 용도 입니다.",
            author=self.user_mike
        )
(..생략..)

👉category_card_test() 함수 만들기

navbar_test()함수와 마찬가지로 카테고리 영역을 체크하는 함수를 만들것이다. 왜 따로 만드냐면, 다음 그림에 나와 있는 것 처럼 포스트 목록 페이지와 포스트 상세 페이지 모두 카테고리 영역이 존재할것이다.

따라서 각각의 테스트 함수 안에 작성하는 것 보다 따로 구현하는 것이 효율성이 좋을 것이다.

category_card_test() 함수는 다음과 같은 기능을 수행한다.

  1. id가 category-card인 div요소를 찾는다.

  2. 그 요소 안에 'Categories'라는 문구가 있는지 확인한다.

  3. 또한 모든 카테고리가 카테고리 이름 (개수)처럼 제대로 출력되어 있는지 확인한다.

  4. 카테고리가 없는 포스트 개수가 '미분류' 항목 옆 괄호에 써 있는지도 확인한다.

from django.test import TestCase, Client
from bs4 import BeautifulSoup
from django.contrib.auth.models import User
from .models import Post, Category


class TestView(TestCase):
	(..생략..)
    
    def navbar_test(self, soup):
		(..생략..)

    def category_card_test(self, soup):
        categories_card = soup.find('div', id='categories-card')
        self.assertIn('Categories', categories_card.text)
        self.assertIn(
            f'{self.category_programming.name} ({self.category_programming.post_set.count()})',
            categories_card.text
        )
        self.assertIn(
            f'{self.category_algorithm.name} ({self.category_algorithm.post_set.count()})',
            categories_card.text
        )
        self.assertIn(f'미분류 ({Post.objects.filter(category=None).count()})', categories_card.text)
(..생략..)

역참조
역참조는 관계 필드를 통해 역으로 연결된 객체를 가져오는 것을 의미한다.

장고에서 관계 필드(ForeignKey, ManyToManyField 등)를 정의하면, 역참조 매니저(reverse manager)가 자동으로 생성된다. 이 역참조 매니저는 관계된 모델의 객체에 접근하고 쿼리를 실행할 수 있는 기능을 제공한다.

기본적으로, 장고는 역참조 매니저의 이름을 <모델이름>_set 형태로 생성한다.
따라서 post_setCategory 모델과 Post 모델 간의 관계에서 Category를 역참조하여 해당 카테고리에 속한 Post 객체에 접근하기 위한 매니저 이름이다.

역참조를 사용하여 관련된 객체를 가져오거나 쿼리를 수행할 수 있으며, .count() 메서드를 호출하여 해당 역참조 매니저의 개수를 반환할 수 있다.

👉test_post_list() 함수 갈아엎기

포스트 목록을 테스트하는 함수 test_post_list()를 갈아엎었다. 다음 그림을 참고하면서 각 영역에는 무엇을 검사해야 할지 생각해보며 작성했다.


우선 포스트를 생성하는 코드를 test_post_list()에서 setUp() 함수로 옮겼기 때문에라도 기존 test_post_list()함수를 갈아엎어야 했다.

위 사진은 기존의test_post_list()함수인데, line 49에서 포스트를 생성하기 전에 "메인영역에 게시물이 없는지 체크"하고 있는데, 바뀐 setUp()함수에 의해 이미 포스트 3개가 생성될것이기 때문에 저 코드는 의미가 없다.

그래서 먼저 포스트가 있는 경우, 해야할 테스트를 다 하고 나서 포스트를 모조리 삭제하고 포스트가 없는 경우를 검사하도록 바꿨다.

def test_post_list(self):
	# Post가 있는 경우
    self.assertEqual(Post.objects.count(), 3)
    
    response = self.client.get('/blog/')
    self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')
    
    self.assertEqual(soup.title.text, 'Blog')
    
    self.navbar_test(soup)
    self.category_card_test(soup)
    
    main_area = soup.find('div', id='main-area')
    self.assertNotIn('아직 게시물이 없습니다', main_area.text)

	post_001_card = main_area.find('div', id='post-1')  # id가 post-1인 div를 찾아서, 그 안에
	self.assertIn(self.post_001.title, post_001_card.text)  # title이 있는지
	self.assertIn(self.post_001.category.name, post_001_card.text)  # category가 있는지
	self.assertIn(self.post_001.author.username.upper(), post_001_card.text)  # 작성자명이 있는지

	post_002_card = main_area.find('div', id='post-2')
	self.assertIn(self.post_002.title, post_002_card.text)
    self.assertIn(self.post_002.category.name, post_002_card.text)
    self.assertIn(self.post_002.author.username.upper(), post_002_card.text)
    
    post_003_card = main_area.find('div', id='post-3')
    self.assertIn('미분류', post_003_card.text)
    self.assertIn(self.post_003.title, post_003_card.text)
    self.assertIn(self.post_003.author.username.upper(), post_003_card.text)
    
    # Post가 없는 경우
    Post.objects.all().delete()  # 모든 포스트 삭제
    self.assertEqual(Post.objects.count(), 0)
    response = self.client.get('/blog/')
    soup = BeautifulSoup(response.content, 'html.parser')
    main_area = soup.find('div', id='main-area')  # id가 main-area인 div태그를 찾습니다.
    self.assertIn('아직 게시물이 없습니다', main_area.text)  # 게시물이 없다는 문구가 나오는지 확인
  1. setUp() 함수에서 포스트를 3개 만들었느니, 3개가 잘 만들어졌는지 확인한다.
  2. 페이지를 잘 읽어오는지 확인하고, BeautifulSoup으로 html을 파싱한다.
  3. 페이지 타이틀에 'Blog'가 있는지 확인
  4. 내비게이션 바와 카테고리 카드 영역 확인
  5. main-area영역을 찾고, post가 있으므로 '아직 게시물이 없습니다.'라는 문구가 나오면 안됨
  6. 각 포스트마다 그 안에 title, category 뱃지, 작성자명이 있는지 확인
  7. 세 번째 포스트는 "미분류" 카테고리가 잘 있는지 확인
  8. 모든 포스트를 삭제하고, 포스트가 없는 경우의 문구들이 잘 나오는지 확인

👉div요소에 id 부여하기

base.html에 들어가서 카드에 해당하는 div 요소에 categories-id라는 id를 부여하자.
또한 부트스트랩에서 가져온 양식은 카테고리 카드 안을 좌우로 나누므로 해당 코드는 삭제했다.

<!-- Categories widget-->
<div class="card mb-4" id="categories-card">
  <div class="card-header">Categories</div>
  <div class="card-body">
    <div class="row">
      <ul class="list-unstyled mb-0">
        <li><a href="#!">Web Design</a></li>
        <li><a href="#!">HTML</a></li>
        <li><a href="#!">Freebies</a></li>
      </ul>
    </div>
  </div>
</div>

이후 python manage.py test blog.tests.TestView.test_post_listtest_post_list()함수만 테스트해봤다.

'programming (1)'이 없어 실패....

👉view.py에서 get_context_data() 메서드로 category 관련 인자 넘기기

오류를 천천히 살펴보면, 아직 base.html에서 categories-card 영역에 카테고리 이름을 수정하지 않았다..

해당 부분을 수정해보자. 앞서 우리가 post_list.html에서 {% for p in post_list %}를 사용한 거와 같이 템플릿 언어를 사용하여 모든 카테고리를 가져와야 한다.

그러기 위해선 blog/views.py에서 Category 레코드를 가져오는 코드를 우선 작성해야 한다. 이때 get_context_data라는 메서드를 사용할것이다.

get_context_data 란?
ListViewDetailView와 같은 클래스는 기본적으로 get_context_data 메서드를 내장하고 있다. ListView를 상속받은 PostList에서 단지 model = Post 라고 선언하면 get_context_data에서 자동으로 post_list = Post.objects.all()을 명령한다. 그래서 post_list.html에서 {% for p in post_list %}와 같은 명령어를 바로 활용할 수 있었던 것이었다.

blog/views.pyPostList 클래스에서 get_context_data를 정의해 오버라이딩해보자.

먼저 context=super(PostList, self).get_context_data()get_context_data에서 기존에 제공했던 기능을 그대로 가져와 context에 저장한다.
그리고 원하는 쿼리셋을 만들어 딕셔너리 형태로 context에 담으면 끝이다!!

  1. Category.objects.all()로 모든 카테고리를 가져와 categories라는 이름의 키에 연결해 담는다.
  2. Post.objects.filter(category=None).count()로 카테고리가 지정되지 않은 포스트의 개수를 세어 no_category_post_count에 담는다.
# blog/view.py
from django.shortcuts import render
from django.views.generic import ListView, DetailView
from .models import Post, Category


class PostList(ListView):
    model = Post
    ordering = '-pk'

    def get_context_data(self, **kwargs):
        context = super(PostList, self).get_context_data()
        context['categories'] = Category.objects.all()
        context['no_category_post_count'] = Post.objects.filter(category=None).count()
        return context

그리고 base.html을 다음과 같이 또 수정했다...

아직 구현하지 않았지만, category.get_absolute_url을 넣어 카테고리의 고유 URL을 링크로 만들었다.

그리고 미분류인 포스트의 개수도 나타나도록 아까 get_context_data() 함수에서 추가한 no_category_post_count가 출력되게 했다. 이 부분도 <a> 태그로 감싸고 href를 지정했는데, 나중에 이 부분을 클릭하면 미분류 포스트만 모아서 목록으로 보여주게 할 것이다.(아직 미구현)

어찌됐든 다시 테스트 해봤더니 또 실패....

에러메세지를 보니 post_001_card가 None이었다...

post_001_card = main_area.find('div', id='post-1') 인데 id='post-1'을 지정하지 않아서 발생한 문제다.

👉템플릿 수정

다음과 같이 post_list.html을 수정했다.

다시 테스트를 실행했더니 또 다음과 같은 에러가 발생했다... (미치겠다)

id="post-1"div요소는 찾았지만, 그 안에 programming 이라는 문구가 없다는 에러다.

즉, 다음과 같이 카테고리 뱃지가 아직 구현이 되지 않아서 발생한 에러인것이다.

👉카테고리 뱃지 구현

부트스트랩에서 제공하는 뱃지를 사용해 카테고리 뱃지 기능을 구현했다.

if문을 사용하여 카테고리가 있는 경우에는 카테고리 뱃지를 생성하고, 카테고리가 없는 경우에는 미분류 뱃지를 생성한다.

그리고 float-right를 추가하여 뱃지를 오른쪽 끝에 위치시켰다.

다시 테스트를 돌려보니 드디어 OK가 나왔다...

웹 브라우저에서도 확인해보자.

생각했던 대로 잘 나왔다!!!!!!


📖포스트 상세 페이지 수정하기

놀랍게도 지금까지 포스트 목록 페이지만 수정한 것이다ㅋㅋ.....

다음 그림과 같이 수정하자.

포스트 상세 페이지에 구현하려는 내용이 목록 페이지와는 크게 다르진 않다.

👉테스트 코드 작성하기

마찬가지로 먼저 테스트 코드를 작성하자. (목록페이지 구현할 때 테스트 코드 없었으면 더 힘들었을듯..)

앞서 test_post_list() 함수에서 많은 부분을 만들었기 때문에 test_post_detail()함수를 수정하는 것은 쉽다!


일단 setUp() 함수에서 포스트를 만들어 두었기 때문에 test_post_detail() 안에서 포스트를 생성하는 부분은 삭제한다. 그리고 post_001self.post_001로 수정한다.

또한 카테고리 카드가 잘 만들어졌는지 확인하기 위한 self.category_card_test(soup)를 추가하고, 마지막으로 포스트 영역에 카테고리 뱃지가 있는지 확인하는 코드를 추가한다.

👉get_context_data()로 카테고리 인자 넘기고 템플릿 수정

blog/views.pyPostDetail 클래스를 PostList 클래스처럼 수정한다.

post_list.html에서와 마찬가지로 post_detail.html에서 id="post-area"div영역에 카테고리 뱃지를 생성하는 코드를 추가해준다.

테스트 코드를 돌려 보니 OK가 나왔다.

웹 브라우저에서도 확인해보니 다음과 같이 잘 된 것을 볼 수 있었다.


📖카테고리 페이지 만들기

이제는 카테고리를 클릭했을 때, 해당 카테고리와 관계있는 포스트만 보여주는 카테고리 페이지를 만들 차례다.

구상한 페이지 모양은 다음과 같다.

앞서 만들었던 목록이나 상세 페이지와 유사한데, 페이지 타이틀 옆에 카테고리 이름을 뱃지로 표시하는 것만 추가 구현하면 된다!

👉테스트 코드 작성

당연하게 또 테스트코드를 작성해보자.

(..생략..)
def test_category_page(self):
	response = self.client.get(self.category_programming.get_absolute_url())
    self.assertEqual(response.status_code, 200)
    soup = BeautifulSoup(response.content, 'html.parser')
    
    self.navbar_test(soup)
    self.category_card_test(soup)
    
    self.assertIn(self.category_programming.name, soup.h1.text)
    
    main_area = soup.find('div', id='main-area')
    self.assertIn(self.category_programming.name, main_area.text)
    self.assertIn(self.post_001.title, main_area.text)
    self.assertNotIn(self.post_002.title, main_area.text)
    self.assertNotIn(self.post_003.title, main_area.text)

우선, 테스트할 카테고리는 programming 카테고리로 정했다.

첫 번째로 response = self.client.get(self.category_programming.get_absolute_url())를 통해 programming 카테고리 페이지에 대한 고유 URL(아직 미구현)을 가져오고, 페이지가 잘 열리는지, 그리고 BeautifulSoup을 통해 html을 파싱한다.

이후 내비게이션 바 테스트와 카테고리 카드 영역 테스트를 거친다.

그리고 self.assertIn(self.category_programming.name, soup.h1.text)으로 페이지 상단에 카테고리 뱃지가 잘 나타나는지 확인한다. 카테고리 페이지에서는 <h1> 태그를 한 번만 쓸 거기 때문에 <h1> 태그에 카테고리 이름이 있는지 확인할 것이다.

마지막으로 id="main-area"div 영역에서 카테고리 뱃지가 있는지, 이 카테고리에 해당하는 포스트만 노출되어 있는지 확인한다. 여기서는 programming 카테고리 페이지를 기준으로 확인할 것 이므로, post_002와 post_003은 있으면 안된다.

👉Category 모델 수정; get_absolute_url 만들기

앞서 테스트 코드에 작성했다시피, 카테고리 페이지도 고유 URL을 가지도록 해야한다. 그리고 그러한 고유 URL을 가져올 수 있는 get_absolute_url()도 구현해야 한다.

또한 위에서 활용했던 slug 필드를 이용하여 URL만 봐도 사용자가 어떤 내용인지 알 수 있도록 고유 URL을 만들것이다.

👉URL 정의하기

이제 URL이 처리될 수 있도록 urls.py를 수정해야 한다. Category 모델의 get_absolute_url()/blog/로 시작하므로 blog/urls.py만 수정하면 된다.

사용자가 category/ 뒤에 문자열이 붙는 URL을 입력하면, 그 문자열을 views.py에 정의할 category_page() 함수의 매개변수인 slug의 인자로 넘겨주도록 했다.

예를 들어 사용자가 URL을 127.0.0.1:8000/blog/category/programming/ 이라고 입력하면, programming/만 떼어 views.pycategory_page()함수(이따가 구현)로 보낸다.

FBV 방식으로 만들 것 이므로 as_view()를 사용하진 않는다!

👉FBV로 category_page() 함수 만들기

이제 views.py에 FBV로 category_page() 함수를 만든다.

  1. category_page() 함수의 매개변수로는 FBV 방식에 꼭 필요한 request 이외에 slug 까지 설정. 그리고 URL에서 추출하여 category_page() 함수의 인자로 받은 slug와 동일한 slug를 갖는 카테고리를 불러오는 쿼리셋을 만들어 category 변수에 저장.

  2. 템플릿은 포스트 목록 페이지를 만들 때 사용했던 blog/post_list.html을 사용

  3. post_list.html을 사용하기 때문에 PostList 클래스에서 context로 정의했던 부분을 딕셔너리 형태로 직접 정의해야 한다. 그렇다면 post_list.html에 넘겨줘야 할 정보들은 뭐가 있을까? 일단 기본적으로 post_list.html에서 사용할 post_list 필요할 것이다. 또한 PostList 클래스에서 context로 넘겨줬던 정보들은 필수로 들어가야한다. context로 넘겨줬던 정보에는 카테고리 카드를 구현하기 위한 categoriesno_category_post_count가 있다. 마지막으로 이후에 post_list.html에 구현할 카테고리 페이지에서만 나타나는 페이지 타이틀 옆에 표시할 카테고리 뱃지에 사용되는 category도 필요하다.

    • 'post_list': Post.objects.filter(category=category) : 포스트중에서 Category.objects.get(slug=slug)로 필터링한 카테고리만 가져오라는 의미다.

    • 'categories': Category.objects.all() : 모든 카테고리를 가져온다. 이는 base.html에서 카테고리 카드 영역을 구현하기 위한 데이터로 사용된다.

    • 'no_category_post_count': Post.objects.filter(category=None).count() : 카테고리 카드에 있는 미분류 포스트와 그 개수를 알려준다.

    • 'category': category : 카테고리 페이지 타이틀 옆에 카테고리 뱃지를 구현하기 위한 데이터.

👉템플릿 수정하기

카테고리 페이지에 추가할 뱃지를 구현해야한다.

여기서 신경써야 할 것은 카테고리 페이지에 추가한 뱃지가 포스트 목록 페이지에서는 보이면 안된다는 것이다. -> if문을 사용하여 해결!

웹 브라우저에서도 잘 된다!

👉미분류 카테고리 처리하기

그런데 미분류 카테고리를 클릭하면 에러 메시지가 나온다..?

앞서 base.html에서 다음과 같이 카테고리 카드 영역에 있는 카테고리들의 링크를 달아뒀다.
카테고리가 있는 경우에는 get_absolute_url()을 통해 경로를 잘 찾아갔지만, 미분류인 경우에는 /blog/category/no_category/으로 링크를 걸어 놨는데, Category 모델의 레코드 중 name 필드가 no_category인 레코드가 없다는 것이다.

앞서 미분류 카테고리를 어떻게 다뤘는지 생각해봤다.

카테고리가 없는, 즉 미분류 카테고리의 개수를 no_category_post_count에 값을 저장할 때 어떻게 했을까? 바로 Post.objects.filter(category=None).count()를 사용했다. 여기서 특히 filter(category=None)에 주목해야 한다. 즉, 실제 데이터베이스에서는 미분류 카테고리를 Category 모델의 name 필드에 None으로 저장했다는 것이다.

그렇기 때문에 blog/views.py를 다음과 같이 수정할 필요가 있었다.

category_page() 함수의 slug 인자로 no_category가 넘어오는 경우 카테고리가 없는 포스트만 보여주고, 카테고리 페이지의 제목 바로 옆에 들어갈 뱃지에 사용되는 category 변수에도 실제 Category 모델의 레코드가 아니라 미분류 라는 문자열을 저장하게 수정했다.

웹 브라우저에서도 확인해보니 잘 나온다!


📌정리...

  • 다대일 관계에 대해서 알았다.

  • 내용이 너무 방대한 만큼, 여러번 복습할 필요가 있다.

  • TDD가 아니었으면 적어도 2~3배 이상의 시간이 걸렸을 것 같다.

  • 항상 MTV 패턴을 상기하면서 개발하자

  • ForeignKey와 filter, 역참조 등 장고 문법에 대해 좀 정리를 해야 할 필요가 있다.

profile
AI 서비스 엔지니어를 목표로 공부하고 있습니다.

0개의 댓글