13장에서 공부한 다대일 관계와 달리 인스타그램의 해시태그처럼 blog 앱에 태그 기능을 추가하면서 다대다 관계에 대해 학습한다.
다음 그림과 같이 태그는 카테고리와는 다르게 포스트 모델의 레코드는 Tag 모델의 여러 레코드와 연결될 수 있으며, Tag 모델의 레코드도 여러 포스트 모델의 레코드와 연결될 수 있다. 이러한 관계를 다대다 관계(Many To Many) 라고 한다.
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()
함수에는 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_area
에 self.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의 제목은 없는지 확인한다.
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()
함수 안에 딕셔너리로 담는다.
마지막으로 태그 페이지 오른쪽에도 카테고리 카드를 보여줘야 하기 때문에 categories
와 no_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장에서 했던 카테고리 기능과 매우 유사하여 쉬웠다.