IT/WEB

[Django] 검색 게시판 만들기 ④ : 검색 기능 만들기

개발자 두더지 2020. 9. 15. 23:53
728x90

검색 & 태그 검색 기능 만들기


복수의 태그를 지정하여 검색을 할 수 있도록 할 것이다.  태그 선택을 풀다운 형식으로 보여지도록 하기에는 복수 선택에 불편함이 있으므로 체크박스를 사용하여 커스텀할 것이다. 커스텀 체크 박스에 관련된 포스팅은 여기를 참고하길 바란다.

 어플리케이션 내에 widgets.py 파일을 만들어 다음과 같이 작성한다. 

from django import forms


class CustomCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
    template_name = 'search/widgets/custom_checkbox.html'
    option_template_name = 'search/widgets/custom_checkbox_option.html'

    def __init__(self, attrs=None):
        super().__init__(attrs)
        if 'class' in self.attrs:
            self.attrs['class'] += ' custom-checkbox'
        else:
            self.attrs['class'] = 'custom-checkbox'

 그리고 템플릿 폴더 아래에 widgets파일을 만들어 두 가지 html파일을 작성한다. 하나는 custom_checkbox.html (templates/search/widgets/custom_checkbox.html)다른 하나는 custom_checkbox_option.html (templates/search/widgets/custom_checkbox_option.html)이다. 

1) custom_checkbox.html

{% for group, options, index in widget.optgroups %}{% for option in options %}{% include option.template_name with widget=option %}{% endfor %}{% endfor %}

2) custom_checkbox_option.html

{% include "django/forms/widgets/input.html" %}<label for="{{ widget.attrs.id }}" class="custom-checkbox-label">{{ widget.label }}</label>

그리고 방금 만든 위젯을 사용하기 위해 어플리케이션 내에 forms.py (search/forms.py)을 만든다.

from django import forms
from .models import Tag
from .widgets import CustomCheckboxSelectMultiple


class PostSearchForm(forms.Form):
    """게시글 검색 폼"""
    key_word = forms.CharField(
        label='검색키워드',
        required=False,
    )

    tags = forms.ModelMultipleChoiceField(
        label='태그범위축소검색',
        required=False,
        queryset=Tag.objects.order_by('name'),
        widget=CustomCheckboxSelectMultiple,
    )

 그리고 CSS를 적용하기 위해 style.css에 체크 박스관련 CSS를 추가 기입하자.

/*커스텀 체크 박스관련 */
.custom-checkbox {
    display: none;
}

.custom-checkbox:checked + .custom-checkbox-label {
    background: #00809d;
    color: #fff;
}

.custom-checkbox-label {
    box-sizing: border-box;
    display: inline-block;
    border-radius: 4px;
    text-align: center;
    text-decoration: none;
    border: solid 1px #ccc;
    transition: 0.25s;
    padding: 4px 16px;
    cursor: pointer;
    font-size: 14px;
    margin: 3px;
}

.custom-checkbox-label:hover {
    opacity: 0.5;
}

그리고 검색에 대해 쿼리를 날려 검색에 대한 결과 데이터를 가져오도록 view.py파일을 다음과 변경하자.

from django.conf import settings
from django.shortcuts import render
from django.views import generic 
from .models import Post # 추가
from .forms import PostSearchForm # 추가


class PublicPostIndexView(generic.ListView):
    
    model = Post
    queryset = Post.objects.filter(is_public=True)

    def get_queryset(self):
        queryset = super().get_queryset()
        self.form = form = PostSearchForm(self.request.GET or None)
        if form.is_valid():
            # 선택된 태그가 포함된 게시글
            tags = form.cleaned_data.get('tags')
            if tags:
                for tag in tags:
                    queryset = queryset.filter(tags=tag)

            # 타이틀이나 본문에 검색 키워드가 포함된 게시글
            # 키워드가 반각 스페이스로 구분되어있다면 그 횟수만큼 filter한다.즉AND.
            key_word = form.cleaned_data.get('key_word')
            if key_word:
                for word in key_word.split():
                    queryset = queryset.filter(Q(title__icontains=word) | Q(text__icontains=word))

        queryset = queryset.order_by('-updated_at').prefetch_related('tags')
        return queryset

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['search_form'] = self.form
        return context

post_list.html에 검색 폼과 태그 선택창이 뜨도록 {% block content %} 태그 바로 아래에 다음의 코드를 추가하자.

{% extends 'search/base.html' %}
{% load search %}
{% load humanize %}
{% block meta_title %} 게시글 목록 {{ block.super }}{% endblock %}

{% block content %}
  
  	<form id="search-form" action="" method="GET">
        {{ search_form.key_word }}
        <button type="submit" id="search-button">검색</button>
        <div class="inline-checkbox">
            {{ search_form.tags }}
        </div>
    </form>
    
# 뒤의 코드는 생략

 그럼 아래와 같이 Admin 페이지에서 작성했던 태그들과 검색 창이 페이지 상단에 뜨게 될 것이다. 태그들을 조금 더 이쁘게 보여지도록 하기 위해 css(static/search/css/style.css)에 다음의 코드를 추가하자.

.inline-checkbox {
    overflow-x: auto;
    white-space: nowrap;
    -webkit-overflow-scrolling: touch;
    padding: 0;
}

input[type=text], input[type=email], input[type=number],
select, textarea {
    font-size: 14px;
    padding: 4px 8px;
    box-sizing: border-box;
    border-radius: 4px;
    border: solid 1px #ccc;
    background-color: #fff;
    font-family: "Ubuntu", "Noto Sans JP", sans-serif;
}

button, a.button {
    font-size: 14px;
    -webkit-appearance: none;
    padding: 4px 16px;
    border-radius: 4px;
    background-color: #fff;
    border: solid 1px #ccc;
    vertical-align: bottom;
    font-family: "Ubuntu", "Noto Sans JP", sans-serif;

    /* a요소 설정 */
    box-sizing: border-box;
    display: inline-block;
    text-decoration: none;
    text-align: center;
    color: #333;

    /* button요소 설정 */
    cursor: pointer;
}

button:hover, a.button:hover {
    opacity: 0.5;
}

button.btn-link, a.btn-link {
    border: none;
    color: #00809d;
    vertical-align: initial;
}

 여기까지 한 결과 화면은 다음과 같이 보일 것이다.

 

 

조금 더 편리하게 만들기


검색 폼을 조금 더 사용하기 쉽도록 만들어보자. 먼저 체크박스를 클릭하면 바로 검색되도록 해보자. 즉 태그를 선택하면 동적으로 데이터를 가져와 화면에 보여지도록 해보자. 굉장히 간단하게 구현할 수 있는데, 체크박스가 눌리면 폼을 submmit하여 구현할 것이다. 

다음의 script요소를 넣는다. 나는 base.html의 </body>바로 직전에 작성하였다.

<script>
    document.addEventListener('DOMContentLoaded', e => {

        const searchForm = document.getElementById('search-form');

        for (const check of document.getElementsByName('tags')) {
            check.addEventListener('change', () => {
                searchForm.submit();
            });
        }
    });
</script>

이 태그 작성 후 포스팅 목록 페이지에서 태그를 누르면 동적으로 데이터를 가져와 화면에 보여주고 있다는 것을 알 수 있다.

검색 폼 내의 태그에 관해 매번 스크롤을 위아래로 움직이며, 태그를 재검색하는 것은 힘드므로 포스팅 목록 내에 있는 태그를 클릭해도 클릭된 태그에 대해서도 클릭하면 태그가 선택되거나 삭제되도록 해보자. 포스팅 목록 내에 태그는 다음을 의미한다.

 방금 작성한 <script>요소를 다음과 같이 바꿔 쓴다.

<script>
    document.addEventListener('DOMContentLoaded', e => {

        const searchForm = document.getElementById('search-form');

        for (const tag of document.getElementsByClassName('tag')) {
            tag.addEventListener('click', () => {
                const pk = tag.dataset.pk;
                const checkbox = document.querySelector(`input[name="tags"][value="${pk}"]`);
                if (checkbox.checked) {
                    checkbox.checked = false;
                } else {
                    checkbox.checked = true;
                }
                searchForm.submit();
            });
        }

        for (const check of document.getElementsByName('tags')) {
            check.addEventListener('change', () => {
                searchForm.submit();
            });
        }
    });
</script>

게시글 목록 내의 태그 부분에는 tag라는 css의 클래스명을 붙이고 있어, data-pk="{{ tag.pk }}" 로 하여 태그 모델의 pk도 미리 갖고 있도록 하고 있다. 

더욱이 현재 선택된 태그를 상부에 표시하도록 하기위해 post_list.html의 <section> 태그 바로 아래에 아래의 코드를 추가하자.

{# 생략 #}
    <section>
       {% if search_form.cleaned_data.tags %}
            <p class="tags" id="select-tags">선택된 태그: {% for tag in search_form.cleaned_data.tags %}
                <span class="tag" data-pk="{{ tag.pk }}">{{ tag.name }}</span>{% endfor %}</p>
        {% endif %}  
        {% for post in post_list %}
            <article class="post">
{# 생략 #}

 이 부분에 태그에도 <span class="tag" data-pk="{{ tag.pk }}">{{ tag.name }}</span>의 코드를 사용하고 있다. 이것에 의해 게시글의 목록 내의 태그도 동일하게 적용된다. 클릭으로 선택하거나 삭제할 수 있다. 결과적으로, 아래의 세 군데있는 태그명을 클릭하면 태그 범위 내에 검색하거나 삭제하는 것이 가능하다.

1. 검색 폼내의 태그

2. 게시글 목록 내의 태그

3. '선택된 태그:'에 표시된 태그

그리고 선택된 태그에 대한 css도 style.css파일에 추가해두자.

/* 선택된 태그 영역 */
#select-tags {
    margin-bottom: 48px;
    font-size: 14px;
}

그 결과 화면은 다음과 같다.

 

 

태그마다 게시글 수 표시하기


마지막으로 검색 폼내의 태그마다 작성된 게시글이 몇 개있는지 표시하도록 만들어보자. forms.py를 먼저 편집하자.

from django import forms
from django.db.models import Count  # 추가
from .models import Tag
from .widgets import CustomCheckboxSelectMultiple


class PostSearchForm(forms.Form):
    """게시글 검색 폼"""
    key_word = forms.CharField(
        label='검색키워드',
        required=False,
    )

    tags = forms.ModelMultipleChoiceField(
        label='범위축소태그',
        required=False,
        queryset=Tag.objects.annotate(post_count=Count('post')).order_by('name'),  # 신규 작성 코드
        widget=CustomCheckboxSelectMultiple,
    )

.annotate(post_conunt=Count('post'))의 부분이 추가되었다. 이에 대한 자세한 내용은 Django에서의 계산처리에서 설명하고 있다. 

 여기서 끝이 아니다. models.py 파일을 열어 Tag모델의 __str__를 다음과 같이 바꿔 게시글 수 전용의 속성이 있으면 이것을 표시하도록 하자.

class Tag(models.Model):
    name = models.CharField('태그명', max_length=255, unique=True)

    def __str__(self):
        # 검색 폼 등에서 태그관련 게시글 수를 표시한다. 여기서에는 post_count라는 속성으로 게시글 수를 가진다.
        if hasattr(self, 'post_count'):
            return f'{self.name}({self.post_count})'
        else:
            return self.name

 

 

 이것으로 검색 폼 내 태그에 다음과 같이 게시글 수가 각각 표시되게 되었다.

 

+) 참고

지금까지의 파일구조


참고자료

blog.narito.ninja/detail/176/

blog.narito.ninja/detail/155/

728x90