다대다 관계 구현하기

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>