[REAL Python – Django] – “Django – 카테고리 구현”

[REAL Python – Django] – “Django – 카테고리 구현”

2월 27, 2022

카테고리 모델 코드 작성

class Category(models.Model):
    name = models.CharField(max_length=50, unique=True)
    # 카테고리의 이름을 담는 필드, unique=True로 하여 같은 이름의 카테고리를 만들지 못하도록 함
    slug = models.SlugField(max_length=200, unique=True, allow_unicode=True)
    # 슬러그 필드, allow_unicode=True로 한글도 사용할 수 있도록 함

Post 모델에 외래키로 연결해 주기

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

저자에 대해서 작업할 때처럼 포스트 모델에 카테고리 필드를 외래키로 연결해 주었습니다. null=True 는 카테고리 미분류인 포스트를 위해서 작성해 준 것입니다. 이는 필드의 값 자체가 NULL로 저장되는 것을 허용하는 것이고, blank=True폼에서 빈 채로 저장되는 것을 허용하는 것입니다. 결국 정리하자면 DBNULL로 저장되는 것을 허용할 때에는 null=True를, 폼 양식에서 빈 채로 저장되는 것(DB에는 ''로 저장됨)을 허용할 때에는 blank=True를 사용하면 됩니다.

on_delete=models.SET_NULL 은 포스트 삭제 시 카테고리만 null이 되게끔 하기 위해 작성한 코드입니다. 저자 필드에 관련해서 작업할 때에 같은 코드를 사용했었죠.

admin.py에 클래스 코드 작성

슬러그를 자동으로 생성해 줍니다. 저는 아래의 코드를 입력했습니다.

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

어드민 페이지에서 카테고리를 관리할 수 있는 부분이 생겼고, 카테고리 이름을 입력해 주면 자동으로 슬러그를 생성해 주는 것을 확인할 수 있습니다.

테스트 코드 작성

tests.py에 테스트용 코드를 작성했습니다.

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_user1 = User.objects.create_user(username='user1', password='somepassword')
        self.user_user2 = User.objects.create_user(username='user2', password='somepassword')

        # 임의의 카테고리 만들기
        self.category_python = Category.objects.create(name='python', slug='python')
        self.category_javascript = Category.objects.create(name='javascript', slug='javascript')

        # 임의의 포스트 3개 만들기
        self.post_001 = Post.objects.create(
            title = '첫 번째 포스트입니다.',
            content = 'Hello World!',
            category=self.category_python,
            author = self.user_user1
        )
        self.post_002 = Post.objects.create(
            title = '두 번째 포스트입니다.',
            content = 'Nice to meet you.',
            category=self.category_javascript,
            author=self.user_user2
        )

        # 아래의 포스트는 카테고리를 지정해두지 않음
        self.post_003 = Post.objects.create(
            title='세 번째 포스트입니다.',
            content='category가 없을 수도 있죠',
            author=self.user_user2
        )

    # 네비게이션 바 테스트 코드
    def navbar_test(self, soup):
        navbar = soup.nav
        self.assertIn('Blog',navbar.text)
        self.assertIn('About Me', navbar.text)

        logo_btn = navbar.find('a', text='Do It Django')
        self.assertEqual(logo_btn.attrs['href'], '/')

        home_btn = navbar.find('a', text='Home')
        self.assertEqual(home_btn.attrs['href'], '/')

        blog_btn = navbar.find('a', text='Blog')
        self.assertEqual(blog_btn.attrs['href'], '/blog/')

        about_me_btn = navbar.find('a', text='About Me')
        self.assertEqual(about_me_btn.attrs['href'], '/about_me/')

    # 카테고리 카드 테스트 코드
    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_python.name} ({self.category_python.post_set.count()})', categories_card.text)
        self.assertIn(f'{self.category_javascript.name} ({self.category_javascript.post_set.count()})', categories_card.text)
        # 미분류인 포스트도 잘 표시되는지 확인
        self.a인sertIn(f'미분류 (1)', categories_card.text)

    # 포스트 리스트 페이지 테스트 코드
    def test_post_list(self):

        # 포스트가 있는 경우

        # 위에서 만든 포스트 갯수가 3과 같은지 확인
        self.assertEqual(Post.objects.count(), 3)

        # /blog/ 에 접속했을 때에
        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)

        # 카테고리가 있는 포스트의 경우, 제목과 카테고리명이 카테고리 카드 안에 포함되어 있는지 확인
        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)

        # 포스트 카드 안에 '미분류' 라는 문구가 있는지, 미분류인 포스트의 제목이 포함되어 있는지 확인
        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.user_user1.username.upper(), main_area.text)
        self.assertIn(self.user_user2.username.upper(), main_area.text)


        # 포스트가 없는 경우 테스트
        
        # 포스트 모두 삭제
        Post.objects.all().delete()

        # 포스트 갯수가 0개인지 확인
        self.assertEqual(Post.objects.count(), 0)

        # /blog/ 에 접속
        response = self.client.get('/blogl/')
        soup = BeautifulSoup(response.content, 'html.parser')
        main_area = soup.find('div', id='main-area')
        
        # /blog/의 main-area 에 '아직 게시물이 없습니다' 라는 문구가 나오는 지 확인
        self.assertIn('아직 게시물이 없습니다', main_area.text)

포스트 상세 페이지 테스트 함수는 나중에 수정할 것이므로 일단 목록 페이지 테스트 코드까지만 수정해 주었습니다. 정리 겸 코드의 해석을 주석으로 달아 놓았습니다.

테스트 후 문제 해결하기

    self.assertIn(f'{self.category_python.name} ({self.category_python.post_set.count()})', categories_card.text)
AssertionError: 'python (1)' not found in '\nCategories\n\n\n\n\nWeb Design\n\n\nHTML\n\n\nFreebies\n\n\n\n\n'

테스트를 하면 위의 오류가 뜨는데 이는 카테코리 카드의 문구에 카테고리명(포스트갯수) 가 포함되지 않았기 때문입니다.

Views.py에서 dir() 을 통해서 그것이 가지고 있는 속성을 출력할 수 있습니다. print(dir(ListView)) 을 찍어 보면, 다음과 같이 나올 것입니다.

['class', 'delattr', 'dict', 'dir', 'doc', 'eq', 'format', 'ge', 'getattribute', 'gt', 'hash', 'init', 'init_subclass', 'le', 'lt', 'module', 'ne', 'new', 'reduce', 'reduce_ex', 'repr', 'setattr', 'sizeof', 'str', 'subclasshook', 'weakref', '_allowed_methods', 'allow_empty', 'as_view', 'content_type', 'context_object_name', 'dispatch', 'extra_context', 'get', 'get_allow_empty', 'get_context_data', 'get_context_object_name', 'get_ordering', 'get_paginate_by', 'get_paginate_orphans', 'get_paginator', 'get_queryset', 'get_template_names', 'http_method_names', 'http_method_not_allowed', 'model', 'options', 'ordering', 'page_kwarg', 'paginate_by', 'paginate_orphans', 'paginate_queryset', 'paginator_class', 'queryset', 'render_to_response', 'response_class', 'setup', 'template_engine', 'template_name', 'template_name_suffix']

ListView 클래스에는'get_context_data' 라는 메서드가 포함되어 있는 것을 알 수 있죠.

class PostList(ListView):
    model = Post # model을 정해 줌.

위의 코드에서 model = Post 라는 코드를 정해 주면 get_context_data 라는 메서드가 post_list = Post.objects.all() 을 수행한다고 하는데.. 그 이해를 위해 함수의 선언 위치를 찾아보며 뜯어보기로 했습니다. 아래는 제가 순서대로 이해를 위해 정리한 글입니다.

첫째로 ListView의 선언은 다음과 같이 되어 있습니다.

class ListView(MultipleObjectTemplateResponseMixin, BaseListView):
    """
    Render some list of objects, set by `self.model` or `self.queryset`.
    `self.queryset` can actually be any iterable of items, not just a queryset.
    """

MultipleObjectTemplateResponseMixin, BaseListView 를 상속받은 클래스라는 것을 알 수 있고, 주석에서는

self.model 또는 self.queryset에 의해 설정된 일부 객체 목록을 렌더링합니다.
self.queryset은 실제로 쿼리 세트가 아니라 항목의 모든 이터러블이 될 수 있습니다.

라는 내용이 적혀 있는 것을 확인할 수 있습니다.

queryset 은 다음을 의미한다고 하네요.

핵심만 말하자면, 쿼리셋(QuerySet)은 전달받은 모델의 객체 목록입니다. 쿼리셋은 데이터베이스로부터 데이터를 읽고, 필터를 걸거나 정렬을 할 수 있습니다.

https://tutorial.djangogirls.org/ko/django_orm/

정리하자면, 여기까지 알아낸 내용은 ListView 클래스는 쿼리셋이나 모델의 객체 목록을 나타내는 것이며 두 개의 클래스를 상속받은 클래스라는 것입니다. MultipleObjectTemplateResponseMixin, BaseListView 의 정의 부분으로 넘어가 보겠습니다.

class MultipleObjectTemplateResponseMixin(TemplateResponseMixin):
    """Mixin for responding with a template and list of objects."""
    template_name_suffix = '_list'

이름이 참 긴 위의 클래스는 TemplateResponseMixin 클래스를 상속받은 클래스인데, 템플릿 이름 접미사를 '_list' 로 정의해주고 있네요. 주석으로는, 위의 클래스는 “템플릿이나 객체의 목록으로 응답하기 위한 믹스인” 이라는 내용이 들어있습니다. 처음에는 ‘Mixin‘ 이라는 단어가 무언지 몰랐는데 찾아보니 ‘Mixin은 다른 클래스의 부모클래스가 되지 않으면서 다른 클래스에서 사용할 수 있는 메서드를 포함하는 클래스‘ 라고 합니다.

아래는 BaseListView 의 정의 부분입니다. 주석으로 “개체 목록을 표시하기 위한 기본 View 입니다.” 라고 나와 있네요.

class BaseListView(MultipleObjectMixin, View):
    """A base view for displaying a list of objects."""
    def get(self, request, *args, **kwargs):
        self.object_list = self.get_queryset()
        allow_empty = self.get_allow_empty()

        if not allow_empty:
            # When pagination is enabled and object_list is a queryset,
            # it's better to do a cheap query than to load the unpaginated
            # queryset in memory.
            if self.get_paginate_by(self.object_list) is not None and hasattr(self.object_list, 'exists'):
                is_empty = not self.object_list.exists()
            else:
                is_empty = not self.object_list
            if is_empty:
                raise Http404(_('Empty list and “%(class_name)s.allow_empty” is False.') % {
                    'class_name': self.__class__.__name__,
                })
        context = self.get_context_data()
        return self.render_to_response(context)

마지막 두 줄에 찾던 get_context_data() 가 나오네요. 아래의 정의부분을 살펴보겠습니다.

    def get_context_data(self, *, object_list=None, **kwargs):
        """Get the context for this view."""
        queryset = object_list if object_list is not None else self.object_list
        page_size = self.get_paginate_by(queryset)
        context_object_name = self.get_context_object_name(queryset)
        if page_size:
            paginator, page, queryset, is_paginated = self.paginate_queryset(queryset, page_size)
            context = {
                'paginator': paginator,
                'page_obj': page,
                'is_paginated': is_paginated,
                'object_list': queryset
            }
        else:
            context = {
                'paginator': None,
                'page_obj': None,
                'is_paginated': False,
                'object_list': queryset
            }
        if context_object_name is not None:
            context[context_object_name] = queryset
        context.update(kwargs)
        return super().get_context_data(**context)

다른 부분 말고 설명을 해 놓은 주석 부분을 체크해 봅시다. “이 뷰에 대한 컨텍스트를 가져옵니다.” 라고 작성되어 있는데, 컨텍스트가 과연 무엇인지에 대해 찾아보니 아래에서 답을 얻을 수 있었습니다.

context is a variable name -> variable value mapping that is passed to a template.

컨텍스트는 템플릿에 전달되는 변수 이름 -> 변수 값 매핑입니다.

https://stackoverflow.com/questions/20957388/what-is-a-context-in-django

이러면 코드의 중간 부분쯤에 컨텍스트를 딕셔너리 형식으로 정의해 놓은 것이 눈에 들어옵니다. 'object_list': queryset 의 부분처럼 오브젝트 리스트 = 쿼리셋(전달받은 모델의 객체 목록)라고 정해두고 있습니다.. -> 모델을 정해 주면 컨텍스트를 반환해 주고, 이는 템플릿에서 우리가 {% post_list ... %} 와 같은 변수명을 그대로 사용할 수 있는 이유입니다.

안쪽 코드를 상세하게 뜯어서 분석해 보고 싶었는데, 일단은 ListView 안의 get_context_data 라는 메서드가 context 를 가져오고, 그렇기 때문에 템플릿에서 변수를 바로 사용할 수 있다.. 정도로 이해하려고 합니다. 실력의 부족입니다.ㅠㅠ

    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

get_context_data 메서드를 오버라이딩하여 몇 가지 컨텍스트를 추가했습니다. 이러면 다음과 같이 변수명 ->변수값 으로 매핑되는 딕셔너리 관계를 활용하여 템플릿을 작성할 수 있을 겁니다.

                            {% for category in categories %}
                            <li>
                                <a href="{{ category.get_absoulte_url }}">{{ category }} ({{ category.post_set.count }})</a>
                            </li>
                            {% endfor %}
                            <li>
                                <a href="/blog/categroy/no_category">미분류 ({{ no_category_post_count }})</a>
                            </li>

위의 과정들을 통해 카테고리명(카테고리안 게시글 갯수) 가 표현되도록 할 수 있었습니다.

필자의 작업 화면.

위와 비슷한 작업을 수행하여 포스트 상세 페이지에 카테고리 기능을, 카테고리 개별 페이지 기능을 구현할 수 있었습니다.

필자의 포스트 상세 페이지 작업 화면. 오른쪽 위에 카테고리명이 출력되는 것을 확인할 수 있습니다.
필자의 작업 화면. 카테고리 카드의 카테고리를 누르면 위처럼 해당 카테고리의 글만 표시되는 것을 확인할 수 있습니다.
def category_page(request, slug):

# 카테고리가 미분류인 경우와 아닌 경우

    # 슬러그가 no_category일 경우
    if slug == 'no_category':
        category = '미분류'
        # 포스트 리스트는 카테고리가 없는 것들만 모아오기
        post_list = Post.objects.filter(category=None)

    # 슬러그가 no_category가 아닌 경우
    else:
        category = Category.objects.get(slug=slug)
        post_list = Post.objects.filter(category=category)


    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(),
            'category' : category,
        }
    )

views.py의 코드를 정리해두도록 하겠습니다.

urls.py 에서 정해둔 패턴에 의해서 views.py category_page 함수에서 처리하고 -> 그것은 ‘어떤 템플릿을 써야 하는지를 알려주고, 템플릿에서 사용할 수 있는 변수이름->변수값 매핑인 context를 반환’ 하므로 위의 코드로 미분류인 경우와 아닌 경우에 대해서 코드를 작성할 수 있었습니다.

내용이 생각보다 많아졌는데.. 나중에 이 프로젝트를 수정할 때에 위의 내용들이 완전히 제 것이 되었으면 좋겠네요.ㅠㅠ

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.