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

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

13장에서 공부한 다대일 관계와 달리 인스타그램의 해시태그처럼 blog 앱에 태그 기능을 추가하면서 다대다 관계에 대해 학습한다.

📌Tag 모델 만들기

다음 그림과 같이 태그는 카테고리와는 다르게 포스트 모델의 레코드는 Tag 모델의 여러 레코드와 연결될 수 있으며, Tag 모델의 레코드도 여러 포스트 모델의 레코드와 연결될 수 있다. 이러한 관계를 다대다 관계(Many To Many) 라고 한다.

📖Tag 모델 구현하기

👉models.py에 Tag 모델 작성하기

Tag 모델은 Category 모델을 복사하여 get_absolute_url() 함수에서 category 부분을 tag로 수정하고, Meta 클래스를 지워 구현했다.

class Tag(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

    def get_absolute_url(self):
        return f'/blog/tag/{self.slug}/'

이제 Post 모델에 tags 필드를 추가해야 하는데, 이때 ForeingKey가 아니라 ManyToManyField를 사용하여 Tag 모델을 연결한다.

이때 tags는 빈칸으로 남겨둘 수 있도록 blank=True로 설정했다.

on_delete=models.SET_NULL은 설정하지 않는다. 왜냐하면 연결된 태그가 삭제되면 해당 포스트의 tags 필드는 알아서 빈 칸으로 바뀌기 때문이다.

그리고 ManyToManyField는 기본적으로 null=True가 설정되어 있다.

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)  
    updated_at = models.DateTimeField(auto_now=True)


    author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
    category = models.ForeignKey(Category, null=True, on_delete=models.SET_NULL)
    tags = models.ManyToManyField(Tag, blank=True)

이후 모델이 변경됐으니 마이그레이션을 진행한다.

👉관리자 페이지에 태그 추가하기

blog/admin.py에 들어가서 Tag 모델을 임포트한다. 그리고 앞서 만들었던, name필드를 이용해 slug를 자동으로 채워줬던 CategoryAdmin을 그대로 응용한다.

class TagAdmin(admin.ModelAdmin):
    prepopulated_fields = {'slug': ('name',)}
    
admin.site.register(Tag, TagAdmin)

관리자 페이지에 들어가면 다음과 같이 Tags 메뉴가 잘 나온 것을 볼 수 있다.

📌포스트 목록과 상세 페이지에 태그 기능 추가

📖테스트 코드에 태그 추가

먼저 태그에 관한 테스트 코드를 작성해보자.

카테고리 테스트 코드를 만들때처럼 setUp() 함수에 'hello', 'python', '파이썬 공부'라는 이름으로 3개의 태그를 다음과 같이 만든다.

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.tag_python_kor = Tag.objects.create(name="파이썬 공부", slug="파이썬-공부")
        self.tag_python = Tag.objects.create(name="python", slug="python")
        self.tag_hello = Tag.objects.create(name="hello", slug="hello")
        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.tag_python_kor = Tag.objects.create(name="파이썬 공부", slug="파이썬-공부")
        self.tag_python = Tag.objects.create(name="python", slug="python")
        self.tag_hello = Tag.objects.create(name="hello", slug="hello")
(..생략..)

3개의 태그를 포스트에 연결해야 한다. Post 모델의 tags 필드는 다른 필드들과 달리 Post.objects.create() 안에 인자로 넣지 않는다.

ManyToManyField는 여러 개의 레코드를 연결할 수 있기 때문에, 이미 만들어진 포스트에 add() 함수로 추가한다.

필자는 다음과 같이 post_001에는 'hello' 태그 하나만 추가하고, post_002에는 아무 태그도 연결하지 않았고, post_003에는 'python'과 '파이썬 공부' 태그를 연결했다.

class TestView(TestCase):
    def setUp(self):
    	(..생략..)
        
        self.post_001 = Post.objects.create(
            title='첫 번째 포스트입니다.',
            content="Hello World.",
            category=self.category_programming,
            author=self.user_mike,
        )
        self.post_001.tags.add(self.tag_hello)  # 연결

        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
        )
        self.post_003.tags.add(self.tag_python_kor)  # 연결
        self.post_003.tags.add(self.tag_python)

📖포스트 목록 페이지에 태그 기능 추가하기

다음 그림과 같이 포스트 카드 아랫부분에 각 포스트에 연결된 태그가 노출되도록 해보자.

👉test_post_list() 수정하기

test_post_list() 함수에는 3개의 포스트 카드에 우리가 원하는 내용이 있는지 확인하는 테스트 코드를 작성했었다. 여기에 추가적으로 포스트별로 각기 다르게 추가한 태그가 잘 출력되는지 확인하는 내용을 추가해보자.

# blog/tests.py
(..생략..)
class TestView(TestCase):
	def setUp(self):
    	(..생략..)
   	def test_post_list(self):
    	(..생략..)
        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)  # 작성자명이 있는지
        self.assertIn(self.tag_hello.name, post_001_card.text)
        self.assertNotIn(self.tag_python.name, post_001_card.text)
        self.assertNotIn(self.tag_python_kor.name, 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)
        self.assertNotIn(self.tag_hello.name, post_002_card.text)
        self.assertNotIn(self.tag_python.name, post_002_card.text)
        self.assertNotIn(self.tag_python_kor.name, 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)
        self.assertNotIn(self.tag_hello.name, post_003_card.text)
        self.assertIn(self.tag_python.name, post_003_card.text)
        self.assertIn(self.tag_python_kor.name, post_003_card.text)
(..생략..)

post_001_card에는 'hello' 태그를 추가했으므로, 'hello'라는 단어가 존재하는지 확인한다. 그리고 'python'과 '파이썬 공부'라는 단어는 나타나지 않아야 한다.

post_002_card에는 아무런 태그를 연결하지 않았으니, 모든 태그 단어들이 나타나지 않아야 한다.

post_003_card에는 'hello'가 나타나면 안되고, 'python'과 '파이썬 공부'가 있는지 확인한다.

👉템플릿 수정

post_list.html에 아직 태그를 출력하는 코드를 넣지 않았으므로 다음과 같이 수정한다.

{% for p in post_list %}
<!-- Blog post-->
	(..생략..)
    <div class="card-body">
        {% if p.category %}
        	<span class="badge badge-secondary float-right">{{ p.category }}</span>
        {% else %}
        	<span class="badge badge-secondary float-right">미분류</span>
        {% endif %}
        
      	<h2 class="card-title h4">{{ p.title }}</h2>
        {% if p.hook_text %}
        	<h5 class="text-muted">{{ p.hook_text }}</h5>
        {% endif %}
        <p class="card-text">{{ p.content | truncatewords:45 }}</p>
        
      	<!-- 추가 -->
      	{% if p.tags.exists %}
        	<i class="fas fa-tags"></i>
        	{% for tag in p.tags.iterator %}
        		<a href="{{ tag.get_absolute_url }}"><span class="badge badge-pill badge-light">{{ tag }}</span></a>
        	{% endfor %}
        	<br/>
        	<br/>
        {% endif %}
(..생략..)

우선 {% if p.tags.exists %}로 태그가 존재하는지 확인한다. 태그가 존재하면, 태그를 보여주는데 <i class="fas fa-tags"></i>로 태그 모양 아이콘을 하나 추가하고, 그 뒤로 연결된 모든 태그를 나열한다.

또한 뒤에 카테고리 페이지처럼 태그 페이지를 만들 것 이므로, 해당 태그 뱃지를 클릭하면 태그 페이지로 이동하도록 만들기 위해 부트스트랩의 <a> 태그를 활용한 뱃지를 사용하여 URL 링크(아직 미구현)와 태그 name을 넣는다.

테스트 코드를 돌려보면 OK가 나오는 것을 볼 수 있었다.

또한 관리자 페이지에서 포스트에 태그를 붙인 다음 웹 브라우저에서 결과를 확인해 보면 다음과 같이 잘 나오는 것을 볼 수 있었다.

📖포스트 상세 페이지에 태그 기능 추가하기

포스트 상세 페이지에도 역시 태그 기능을 추가할 것이다. 구상한 위치는 다음과 같다.

👉테스트 코드 수정

우선 테스트 코드를 수정하자. 내용은 단순하다. post_001을 테스트 할 것이므로, post_areaself.tag_hello의 name인 'hello'가 존재하는지, 그리고 다른 태그들은 post_area에 존재하지 않는지 확인할 것이다.

class TestView(TestCase):
	(..생략..)
    def test_post_detail(self):
        self.assertEqual(self.post_001.get_absolute_url(), '/blog/1/')

        response = self.client.get(self.post_001.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.post_001.title, soup.title.text)

        main_area = soup.find('div', id='main-area')
        post_area = main_area.find('div', id='post-area')
        self.assertIn(self.post_001.title, post_area.text)

        self.assertIn(self.category_programming.name, post_area.text)

        self.assertIn(self.user_mike.username.upper(), post_area.text)

        self.assertIn(self.post_001.content, post_area.text)
		
        # 추가
        self.assertIn(self.tag_hello.name, post_area.text)
        self.assertNotIn(self.tag_python.name, post_area.text)
        self.assertNotIn(self.tag_python_kor.name, post_area.text)

👉템플릿 수정

post_detail.html에 태그 기능을 추가하지 않았으므로 수정한다. 앞서 post_list.html에서 태그를 노출시키기 위해 추가했던 부분을 그대로 복사해서 붙여넣는다. 이때 p.tags.~post.tags.~로 바꿔주고, 붙여넣는 위치만 주의하면 된다.

<!-- Post Content -->
    <p>{{ post.content }}</p>

    {% if post.tags.exists %}
    	<i class="fas fa-tags"></i>
    	{% for tag in post.tags.iterator %}
    		<a href="{{ tag.get_absolute_url }}"><span class="badge badge-pill badge-light">{{ tag }}</span></a>
    	{% endfor %}
    	<br/>
    	<br/>
    {% endif %}

    {% if post.file_upload %}

이제 테스트 코드를 돌려보고 성공했으면 웹 브라우저에서 확인해보자.


📌태그 페이지 만들기

이제 태그 페이지를 만들어보자. 앞서 13장에서 만들었던 카테고리 페이지와 유사하여 아주 쉽다. 다음은 구상해놓은 태그 페이지 예시이다.

📖테스트 코드 작성하기

test.py에 태그 페이지를 테스트할 test_tag_page() 함수를 만든다. 카테고리 페이지 테스트 함수와 유사하다.

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)
    
def test_tag_page(self):
    response = self.client.get(self.tag_hello.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.tag_hello.name, soup.h1.text)
    
    main_area = soup.find('div', id='main-area')
    self.assertIn(self.tag_hello.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)

먼저 테스트할 태그 페이지로 setUp() 함수에서 만든 태그 중 name 필드가 'hello'인 것을 가져온다.
당연히 내비게이션 바와 카테고리 카드 영역도 존재해야 하므로 해당 내용을 테스트하는 navbar_test()category_card_test() 함수를 붙어넣는다.
그리고 'hello'태그가 self.post_001의 타이틀 옆에 존재하는지 확인한다.

이후 main-area에 해당 태그(hello)의 게시물인 post_001의 제목이 있는지, 다른 태그의 게시물인 post_002와 post_003의 제목은 없는지 확인한다.

📖URL 지정

models.py에서 Tag모델을 만들 때 get_absolute_url() 함수에서 반환하는 URL 경로의 형태를 blog/tag/self.slug/로 지정했었다. 그리고 slug필드가 고유의 값을 갖도록 unique=True로 설정했다.

이제 이 URL을 blog/urls.py에 추가해야 한다.

카테고리 페이지의 URL를 추가했을 때 작성한 코드에서 category만 tag로 바꾸면 된다.

카테고리 페이지와 마찬가지로 slug 필드를 사용하여 Tag 모델 각각의 레코드들이 고유의 경로를 가질 수 있도록 한 것이다.

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), # 추가
]

이제 views.py에 가서 tag_page() 함수를 만들어야 한다.

이 또한 카테고리 페이지를 구현할 당시 사용했던 category_page() 함수를 재활용하여 FBV 방식으로 만들었다.

def tag_page(request, slug):
    tag = Tag.objects.get(slug=slug)
    post_list = tag.post_set.all()

    return render(
        request,
        'blog/post_list.html',
        {
            'post_list': post_list,
            'tag': tag,
            'categories': Category.objects.all(),
            'no_category_post_count': Post.objects.filter(category=None).count(),
        }
    )

먼저 URL에서 인자로 넘어온 slug과 동일한 slug를 가진 태그를 쿼리셋으로 가져와 tag에 저장한다.

그리고 가져온 태그에 연결된 포스트 전체를 post_list에 저장한다. 이때 tag.post_set.all()처럼 역참조를 사용했는데, Post.objects.filter(tag=tag)를 사용해도 결과는 똑같다.

이렇게 쿼리셋으로 가져온 인자를 render() 함수 안에 딕셔너리로 담는다.

마지막으로 태그 페이지 오른쪽에도 카테고리 카드를 보여줘야 하기 때문에 categoriesno_category_post_count도 넣어준다.

📖템플릿 수정

테스트를 해보니, 메인 영역에 "hello"라는 태그가 없다고 Fail이 떴다. 이는 post_list.html을 재활용하기로 했는데 태그 페이지에 맞게 수정하는 작업을 하지 않았기 때문이다.

즉, 다음 그림처럼 제목 옆에 태그가 나와야 하는데, 태그를 노출하는 코드가 아직 post_list.html에 없어서 테스트에 실패한것이다.

다음과 같이 {% if tag %}를 통해 tag가 있다면, 뱃지로 태그를 보여주도록 수정하자.

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

{% block main_area %}
<h1>Blog
    {% if category %}<span class="badge badge-info">{{ category }}</span>{% endif %}
    {% if tag %}<span class="badge badge-light">{{ tag }}</span>{% endif %}
</h1>

그러면 테스트가 통과되고, 태그를 클릭하여 태그페이지로 넘어가면 다음과 같은 화면을 볼 수 있다.

그런데 문제가 있다. 다음 그림을 보면 태그 스타일과 카테고리 스타일이 유사해 어떤 페이지인지 구분이 잘 가지 않는다.

태그의 이름을 보여주는 뱃지에 Font awesome 아이콘을 붙여주고, 색상도 밝게 바꿔줬다. 또한, 태그의 name 필드 값만 출력되는 것이 아니라 그 옆에 해당 태그에 연결된 포스트가 몇 개인지도 출력하도록 했다.

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

{% block main_area %}
<h1>Blog
    {% if category %}<span class="badge badge-info">{{ category }}</span>{% endif %}
    {% if tag %}<span class="badge badge-light"><i class="fas fa-tags"></i> {{ tag }} ({{ tag.post_set.count }})</span>{% endif %}
</h1>


📌정리

  • 다대다 관계를 사용하는 방법을 배웠다.

  • ManyToManyField를 사용하여 다른 모델과 연결할 때는 다대일 관계와는 다르게 add()함수를 써야한다는 점을 기억해야겠다.

  • 태그 기능 구현은 13장에서 했던 카테고리 기능과 매우 유사하여 쉬웠다.

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

0개의 댓글