다대다 관계 구현하기
Posted on 2021-08-25 by GKSRUDTN99
Django로 웹사이트 만들기
장고
Tag 모델 만들기
다대다 관계를 구현하기 위해서, ManyToManyField
를 사용한다.
Category Model을 복사하여 Tag 모델을 작성한다.
# blog/models.py
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 모델에 tag 필드를 추가한다.
blank=True
는 적용하되, on_delete=models.SET_NULL
은 필요없다.
연결된 태그가 사라지만 알아서 null이 되기 때문이다.(ManyToManyField
의 특징이다.)
같은 맥락으로, null=True
도 필요없다.
# blog/models.py
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)
tags = models.ManyToManyField(Tag, blank=True)
def __str__(self):
return f'[{self.pk}]{self.title} :: {self.author}'
def get_absolute_url(self):
return f'/blog/{self.pk}/'
def get_file_name(self):
return os.path.basename(self.file_upload.name)
def get_file_ext(self):
return self.get_file_name().split('.')[-1]
models.py를 작성한 뒤, 마이그레이션을 진행한다.
관리자 페이지에 태그를 추가하기 위해, CategoryAdmin
을 복사하여 TagAdmin
을 만들고, 등록한다.
# blog/admin.py
from django.contrib import admin
from .models import Post, Category, Tag
admin.site.register(Post)
class CategoryAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
class TagAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
admin.site.register(Category, CategoryAdmin)
admin.site.register(Tag, TagAdmin)
테스트 코드 작성하기
test.py에서 setUp()
에 태그 3개를 추가한다.
만들어진 태그들을 포스트에 연결하는데, create 함수 안에 인자로 넣지 않고, add()
를 통해 추가한다.
# blog/tests.py의 TestView.setUp()
def setUp(self):
self.client = Client()
self.user_trump = User.objects.create(username='trump', password='somepassword')
self.user_obama = User.objects.create(username='obama', password='somepassword')
self.category_programming = Category.objects.create(name='programming', slug='programming')
self.category_music = Category.objects.create(name='music', slug='music')
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')
self.post_001 = Post.objects.create(
title='첫 번째 포스트입니다.',
content='Hello World. We are the world.',
category=self.category_programming,
author=self.user_trump,
)
self.post_001.tags.add(self.tag_hello)
self.post_002 = Post.objects.create(
title='두 번째 포스트입니다.',
content='1등이 전부는 아니잖아요!',
category=self.category_music,
author=self.user_obama,
)
self.post_003 = Post.objects.create(
title='세 번째 포스트입니다',
content='category가 없을수도 있죠',
author=self.user_obama,
)
self.post_003.tags.add(self.tag_python_kor)
self.post_003.tags.add(self.tag_python)
포스트 목록 페이지에 태그 기능을 추가하기 위해, 테스트 코드를 수정한다.
# blog/tests.py TestView.test_post_list(self)
def test_post_list(self):
# 포스트가 있는 경우
self.assertEqual(Post.objects.count(), 3)
response = self.client.get('/blog/')
self.assertEqual(response.status_code, 200)
soup = BeautifulSoup(response.content, 'html.parser')
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')
self.assertIn(self.post_001.title, post_001_card.text)
self.assertIn(self.post_001.category.name, post_001_card.text)
self.assertIn(self.user_trump.username.upper(), main_area.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.user_obama.username.upper(), main_area.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(self.post_003.title, post_003_card.text)
self.assertIn('미분류', post_003_card.text)
self.assertIn(self.user_obama.username.upper(), main_area.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.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')
self.assertIn('아직 게시물이 없습니다', main_area.text)
템플릿 수정하기
포스트 카드가 태그를 출력하도록 그에 맞게 post_list.html을 수정합니다
<!-- blog/templates/blog/post_list.html -->
<!-- Blog Post -->
<div class="card mb-4" id="post-{{ p.pk }}">
<a href="#!">
{% if p.head_image %}
<img class="card-img-top" src="{{ p.head_image.url }}"
alt="{{ p }}"/>
{% else %}
<img class="card-img-top" src="https://picsum.photos/seed/{{ p.id }}/800/200"
alt="random_image">
{% endif %}
</a>
<div class="card-body">
<div class="small text-muted">
Posted on {{ p.created_at }} by
<a href="#">{{ p.author | upper }}</a>
{% if p.category %}
<span class="badge bg-secondary float-end">{{ p.category }}</span>
{% else %}
<span class="badge bg-secondary float-end">미분류</span>
{% endif %}
</div>
<h2 class="card-title">{{ 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.all %}
<a href="{{ tag.get_absolute_url }}"><span class="badge bg-secondary">{{ tag }}</span></a>
{% endfor %}
<br/>
<br/>
{% endif %}
<a class="btn btn-primary" href="{{ p.get_absolute_url }}">Read more →</a>
</div>
</div>
포스트 상세페이지에 태그 출력을 위한 테스트 코드 작성하기
# blog/tests.py TestView.test_post_detal(self)
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.post_001.category.name, post_area.text)
self.assertIn(self.post_001.author.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)
이에 맞게 템플릿도 수정해줍니다.
<!-- blog/templates/blog/post_detail.html -->
<!-- Post content-->
<div id="post-area">
<!-- Post header-->
<header class="mb-4">
<!-- Post title-->
<h1 class="fw-bolder mb-1">{{ post.title }}</h1>
{% if post.hook_text %}
<h5 class="text-muted">{{ post.hook_text }}</h5>
{% endif %}
<!-- Post meta content-->
<div class="text-muted fst-italic mb-2">Posted on {{ post.created_at }} by {{ post.author | upper }}</div>
{% if post.category %}
<span class="badge bg-secondary float-end">{{ post.category }}</span>
{% else %}
<span class="badge bg-secondary float-end">미분류</span>
{% endif %}
<!-- Post categories-->
{% if post.tags.exists %}
<i class="fas fa-tags"></i>
{% for tag in post.tags.all %}
<a class="badge bg-secondary text-decoration-none link-light"
href="{{ tag.get_absolute_url }}">{{ tag }}</a>
{% endfor %}
{% endif %}
</header>
<!-- Preview image figure-->
<figure class="mb-4">
{% if post.head_image %}
<img class="img-fluid rounded" src="{{ post.head_image.url }}" alt="{{ post }}"/>
{% else %}
<img class="img-fluid rounded" src="https://picsum.photos/seed/{{ post.id }}/800/200"
alt="random_image"/>
{% endif %}
</figure>
<!-- Post content-->
<section class="mb-5">
<p>{{ post.content }}</p>
{% if post.file_upload %}
<a href="{{ post.file_upload.url }}" class="btn btn-outline-dark" role="button" download>
Download:
{% if post.get_file_ext == 'csv' %}
<i class="fas fa-file-csv"></i>
{% elif post.get_file_ext == 'xlsx' or post.get_file_ext == 'xls' %}
<i class="fas fa-file-excel"></i>
{% elif post.get_file_ext == 'docx' or post.get_file_ext == 'doc' %}
<i class="fas fa-file-word"></i>
{% else %}
<i class="fas fa-file"></i>
{% endif %}
{{ post.get_file_name }}
</a>
{% endif %}
</section>
</div>
태그 페이지 만들기
테스트 코드 작성하기
hello라는 태그를 눌러 이동했을 때를 가정하여 테스트 코드를 작성한다.
hello라는 글자가 main영역에 존재하고, 1번 포스트만 보이고 2,3번은 보이지 않는지 테스트한다.
# blog/tests.py TestView.test_tag_page(self)
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_text(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)
urls.py 수정하기
category와 마찬가지로 tag도 urls.py을 수정해준다.
# blog/urls.py
from django.contrib import admin
from django.urls import path
from . import views
urlpatterns = [
path('tag/<str:slug>/', views.tag_page),
path('category/<str:slug>/', views.category_page),
path('<int:pk>/', views.PostDetail.as_view()),
path('', views.PostList.as_view()),
]
뷰 수정하기
category와 마찬가지로 수정한다.
# blog/views.py
def category_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,
'categories': Category.objects.all(),
'no_category_post_count': Post.objects.filter(category=None).count(),
'tag': tag,
}
)
템플릿 수정하기
post_list.html에서 태그 페이지임을 표시하도록 수정한다.
<h1>Blog
{% if category %}<span class="badge bg-secondary float-end">{{ category }}</span>{% endif %}
{% if tag %}<span class="badge bg-warning float-end"><i class="fas fa-tags"></i>{{ tag }} ({{ tag.post_set.count }})</span>{% endif %}
</h1>