Engineer's Memorandum

某メーカー勤務エンジニアの備忘録です。書籍・投資・プログラミングなどについて記載します。

Djangoでの検索機能の追加

WebアプリフレームワークDjangoの書籍1を勉強中です。 書籍の1章で説明されているコードSnippetの共有アプリをベースに機能を追加して勉強を継続しています。

今回、キーワードとタグでSnippetを検索する機能を追加したので、その内容をメモしています。 機能追加にあたっては、Naritoさんのブログ2を参考にさせて頂きました。

なお、tagのラジオボタンを非選択の状態に戻せない、一般的なtagでの検索のようにボタンを押すだけでの遷移ができない、といった問題があります。 これらの機能を実装したら、追記したいと思います。

検索機能の動作画面

Snippetの一覧ページに検索機能を追加しました。 Snippetのテーブルの上に、検索キーワード、tagのラジオボタン、検索実行ボタンを追加しています。

search form追加後のSnippet一覧画面

Pythonタグのラジオボタンをクリックし、検索ボタンを押すとPythonタグを付与されたSnippetが検索されます。

Python tagでの検索結果

さらに、キーワードに"title"と入力すると、Pythonタグとのand検索が行えます。

Python tagとキーワードでの検索結果

ファイル構成

migrationディレクトリなど、機能追加に関係のないディレクトリやファイルは省略しています。

$ tree
.
├── __init__.py
├── admin.py
├── apps.py
├── forms.py # ファイル更新
├── models.py
├── static
│   └── snippets
│       └── css
│           └── style.css
├── templates
│   └── snippets
│       ├── snippet_delete.html
│       ├── snippet_detail.html
│       ├── snippet_edit.html
│       ├── snippet_new.html
│       ├── top.html # ファイル更新
├── tests.py
├── urls.py
└── views.py # ファイル更新

ソースコード

本稿では、ListViewクラスベースで作成したSnippet一覧ページを基にしています。 検索用のSnippetSearchFormクラスを追加し、 ListViewクラスのget_querysetメソッドをオーバーライドすることで検索機能を実装しています。

forms.py

キーワード検索用フォームのkey_word変数と、 tag検索用フォームのtag変数を用意します。 key_wordは入力しないこともあるので、requiredはFalseにしています。

tagはDBに登録されているものを取得して自動でラジオボタンの項目を作成します。 入力項目がいたずらに増えるのを避けるため、 Snippetに一つも登録されていないtagはannotateで表示させないようにしています。 また、querysetはイテラブル3なので、スライスを使って表示件数の上限を設けることもできます。

from taggit.models import Tag
from django.db.models import Count
 
 
class SnippetSearchForm(forms.Form):
    key_word = forms.CharField(
        label="検索キーワード",
        required=False,
        widget=forms.TextInput(attrs={"class": "form",
                                      "autocomplete": "off",
                                      "placeholder": "キーワード",
                                      })
    )
 
    # 登録件数が多い順に、件数1以上のタグのみ表示
    tag_queryset = Tag.objects.all().annotate(
        count=Count("taggit_taggeditem_items")).filter(count__gt=0).order_by("-count")
    tag = forms.ModelChoiceField(
        label="タグでの絞り込み",
        required=False,
        queryset=tag_queryset,
        widget=forms.RadioSelect(attrs={"class": "form"})
    )

views.py

form.pyで定義したSnippetSearchFormを利用して、ListView内で取得するquerysetを絞り込むようにします。 新たに、get_querysetメソッドを追加しています。 メンバー変数self.formにSnippetSearchFormを設定します。 formの入力が妥当であった場合、key_wordを一つ一つに分解し、or検索を行います。 ここでは、Snippetの説明文、作成者、タイトルでfilter処理をすることで、querysetの絞り込みを行っています。

次に、get_context_dataでformに名前を設定して呼び出せるようにします。 get_context_dataメソッドは元々peginationのために記述していたもので、 context["search_form"] = self.formのみ追加すればよいです。 get_querysetが必ず先に呼ばれる[^2]ため、このような書き方ができるようです。

from snippets.forms import SnippetSearchForm  # 追加
from django.db.models import Q   # 追加
 
 
class TopView(ListView):
    template_name = "snippets/top.html"
    paginate_by = 5
    model = Snippet
    context_object_name = "snippets"
    ordering = "id"
 
    # 追加
    def get_queryset(self):
        queryset = super().get_queryset()
        self.form = form = SnippetSearchForm(self.request.GET or None)
  
        if form.is_valid():
            # キーワードの絞り込み
            key_word = form.cleaned_data.get("key_word")
            if key_word:
                for word in key_word.split():
                    queryset = queryset.filter(
                        Q(description__icontains=word) |
                        Q(created_by__username__icontains=word) |
                        Q(title__icontains=word)).distinct()
 
            tag = form.cleaned_data.get("tag")
            if tag:
                queryset = queryset.filter(tags=tag)
 
        return queryset

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data(**kwargs)
        # 追加
        context["search_form"] = self.form
 
        page: Page = context['page_obj']
        context['paginator_range'] = page.paginator.get_elided_page_range(
            page.number,
            on_each_side=2,
            on_ends=1)
 
        return context

top.html

最後に、template HTMLです。 ここでは、Snippetの表示用のtableタグの上部に、検索フォームを追加しています。 tagフォームに関しては、for文で回さなくとも1行で書くことはできるのですが、縦に並べられてしまうのを避けるためにfor文で記載しています。

  <!-- 追加 -->
<form id="search-form" action="" method="GET">
  <div class="form-group">
    {{ search_form.key_word }}
  </div>
  
  <div class="form-check">
    {% for tag in search_form.tag %}
      {{ tag }}
    {% endfor %}
  </div>
  <button class="btn btn-primary" type="submit">検索</button>
</form>
 
<p class="text-primary"> {{ page_obj.paginator.count }}件の検索結果 </p>
 
{% if snippets %}
<table class="table">
  ...
</table>

書籍

まず実際にWebアプリを作り、その後詳細な機能を解説する形式で、Djangoを学ぶことができます。


  1. 芝田 将 "実践Django Pythonによる本格Webアプリケーション開発"
  2. Django、ブログ②の記事一覧を作る
  3. QuerySet API リファレンス