[REAL Python – Django] – “Django – 카테고리 구현”
[REAL Python – Django] – “Django – 카테고리 구현”
카테고리 모델 코드 작성
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
는 폼에서 빈 채로 저장되는 것을 허용하는 것입니다. 결국 정리하자면 DB
에 NULL
로 저장되는 것을 허용할 때에는 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)
다른 부분 말고 설명을 해 놓은 주석 부분을 체크해 봅시다. “이 뷰에 대한 컨텍스트를 가져옵니다.” 라고 작성되어 있는데, 컨텍스트가 과연 무엇인지에 대해 찾아보니 아래에서 답을 얻을 수 있었습니다.
A 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
를 반환’ 하므로 위의 코드로 미분류인 경우와 아닌 경우에 대해서 코드를 작성할 수 있었습니다.
내용이 생각보다 많아졌는데.. 나중에 이 프로젝트를 수정할 때에 위의 내용들이 완전히 제 것이 되었으면 좋겠네요.ㅠㅠ