7
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Djangoで作った備忘録登録WEBアプリの高機能化①(タイトル・タグ・本文検索機能の付加)

Last updated at Posted at 2019-06-02

◇はじめに

前回のこの記事(WEBアプリの勉強を兼ねてDjangoで備忘録登録アプリを作ってみる)からの続きになります。

◇記事投稿順(2019/06/29追記)

今回は、ベースのWEBアプリを1.の記事で作成し、2.以降の記事で機能の追加や改善を行っています。必要に応じてほかの記事も参照ください。

  1. WEBアプリの勉強を兼ねてDjangoで備忘録登録アプリを作ってみる
  2. 【本記事】Djangoで作った備忘録登録WEBアプリの高機能化①(タイトル・タグ・本文検索機能の付加)
  3. Djangoで作った備忘録登録WEBアプリの高機能化②(タグ一覧表示、タグ別記事の追加など)

◇今回追加した機能

前回の記事の最後に課題として挙げていた、以下の項目のうち、タイトル検索、タグ検索機能の実装をおこないました。

  • タイトル検索、タグ検索機能の実装(現状、タグが役に立っていない・・)。
  • タグの管理メニュー(現状追加しかできない・・)。
  • 新規タグ追加画面からタグを追加した場合にtopページに飛んでしまうため、その点の修正。
  • フォームの改善(forms.pyの作成)。
  • CSSフレームワーク(Bootstrapなど)の導入(今回のアプリレベルでcssファイルが雑多になってきたため・・)
    ⇒フレームワークどーせ入れるならSass(SCSS or SASS)も試してみたい。

当初、タグの管理メニューまで挑戦する予定でしたが、検索機能の実装でいろいろハマったポイントがあり、実装に時間がかかってしまったため、検索機能までとしています。
ハマったポイントについては、途中途中で記載していきます。

◇開発環境(前回と同じ)

  • OS : Ubuntu 18.04.2 LTS(Windows Subsystem for Linux)
  • 言語 : Python 3.6.7
  • Webアプリフレームワーク : Django (2.2)
  • DB : SQLite3

◇実装内容

今回の記事ではプログラミングした順にのっとり、

  • 外観部分(HTML)の作成 ⇒
  • 検索データのPOST処理(JavaScript) ⇒
  • サーバ側での記事検索・該当記事の返答処理(Django) ⇒
  • レスポンスデータによるHTMLの部分更新(JavaScript)
    という流れで記事を書いています。

◆検索ボックス作成(HTML部分)

まず、トップページ(記事一覧表示)に検索ボックスを追加していきます。
検索ボックスの構成は、ドロップダウンリストでタイトル検索タグ検索本文検索を選択し、テキストボックスに検索ワードを入力してSearchボタンを押すと該当する記事のみが表示される流れとしています。

index.html

{% extends 'memorandum/base-layout.html' %}

{% load static %}

---略---

{% block content %}
  <div class="local-nav">
    <ul class="local-nav-list">
      <li class="local-nav-item"><a class="local-nav-link-text item-normal" href="{% url 'memorandum:create' %}">新規記事</a></li>
      <li class="local-nav-item"><a class="local-nav-link-text item-normal" href="{% url 'memorandum:create_tag' %}">新規TAG</a></li>
      <li class="local-search-item">
        <select id="js-search-item-list" name="search-item" size="1">
          <option value="title">タイトル検索</option>
          <option value="tag">タグ検索</option>
          <option value="body">本文検索</option>
        </select>
        <input id="js-search-text" type="search" placeholder="検索ワード">
        <button type="button" class="button-common" id="js-search-btn">Search</button>
      </li>
    </ul>
  </div>

---略---

{% endblock content %}

001.png

これでとりあえず外観はできたので、次にSearchボタンを押したときのJavaScriptの処理を実装していきます。

◆検索データPOST処理(JavaScript部分)

検索ボックスに入力したデータのPOST処理ですが、今回AjaxでのHTML部分更新(記事リストの部分のみ更新し、検索ボックス部分は更新させない)を行うため、JavaScriptを追加しています。
そのため、前回のファイル構成からjs/index.jsを追加しています。

Terminal
mysite/
├── memorandum
    ├── static
        └── memorandum
            ├── css
            │   └── memorandum.css   <-スタイルシート用ファイル
            ├── js
                └── index.js   <-トップページ用JavaScript

index.jsでは、以下の順で処理を行います。

  1. Searchボタンクリック時のイベントを追加する。
  2. クリックされたら、ドロップダウンリストの選択項目(item)とテキストフォームの入力値(value)を取得する。
  3. 2つのデータをjson化してfetch関数を用いてPOSTする。
index.js(未完成)
const searchItemList = document.getElementById("js-search-item-list");
const searchText = document.getElementById("js-search-text");
const searchBtn = document.getElementById("js-search-btn");

searchBtn.addEventListener("click", function() {
    console.log("hoge");
    const item = searchItemList.value;
    const text = searchText.value;
    postSearchText(item, text);
}, false);

async function postSearchText(searchItem, searchText){
    const postBody = {
        item: searchItem,
        text: searchText,
    };
    console.log(postBody);
    const postData = {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify(postBody)
    };
    console.log(postData);
    const res = await fetch("./", postData)
    console.log(res.json());
}

上のindex.jsはデータをPOSTするところまでのコードです。
とりあえずこの状態でDjango側にPOSTデータを受けるだけのコードを追加し、データが正しくPOSTされているかのテストを行いましたが、Searchボタンをクリックすると403 (Forbidden)エラーが発生しました。

403 (Forbidden)の発生(ハマったポイント①)

ドキュメントなどで調べたところ、POSTフォームを__内部 URL__に対して行う際には基本的にCSRFトークンを付けてあげる必要があり、それをつけていないことが原因でした(逆に__外部URL__にPOSTする際はトークンが外部に漏れるためつけてはいけない)。
ちなみに、DjangoのテンプレートからPOSTフォームする場合は以下のよう{% csrf_token %}を付けるだけでOKです。
参考URLクロスサイトリクエストフォージェリ (CSRF) 対策

<form method="post">{% csrf_token %}

今回は、AjaxでフォームのPOSTを行おうとしているため、その場合のCSRFトークン付加の処理を実装します。
※Django側でCSRFトークンのチェックを無効化する方法もありそうでしたが、根本解決にはならないため、今回は見送りました

Ajax使用時のCSRFトークン付加

実装にあたっては、以下のサイトを参考にしました。

参考URLクロスサイトリクエストフォージェリ (CSRF) 対策
参考URLDjangoでPOSTメッセージにCSRFtokenを含ませる方法まとめ
参考URLA simple, lightweight JavaScript API for handling browser cookies

以下、クロスサイトリクエストフォージェリ (CSRF) 対策 ページからの引用です。

上記のコードは JavaScript Cookie library を使って getCookie を置き換えればシンプルにできます:
var csrftoken = Cookies.get('csrftoken');

JavaScript Cookie libraryを使うと、CSRFトークンの取得が簡単にできるため、今回はこのライブラリを使用しました。
ライブラリのダウンロードはCDNを用いてダウンロードしていますので、以下のようにindex.htmlに追記しています。
※npm経由で手動ダウンロードも可能です。それぞれの手順については、A simple, lightweight JavaScript API for handling browser cookies を参照

index.html
{% extends 'memorandum/base-layout.html' %}

{% load static %}

{% block head %}
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  ↑ 追記部分
  <script src="{% static 'memorandum/js/index.js' %}" defer></script>
{% endblock head %}

---略---

index.jsでは、CSRFトークンの取得とPOSTのヘッダーにそのトークンを追加する処理を追記しました。

index.js(未完成)
const csrftoken = Cookies.get('csrftoken');  <- 追記部分

------

async function postSearchText(searchItem, searchText){
    const postBody = {
        item: searchItem,
        text: searchText,
    };
    console.log(postBody);
    const postData = {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "X-CSRFToken": csrftoken,  <- 追記部分
        },
        body: JSON.stringify(postBody)
    };
    console.log(postData);
    const res = await fetch("./", postData)
    console.log(res.json());
}

これで、Searchボタンを押すと、検索用データがDjango側にPOSTできるようになりました。
つづいて、Django側の処理を実装していきます。

◆記事検索・該当記事の返答処理(Django)

views.py内のArticleListViewクラス内にPOSTがきた場合の処理を追加で実装します。
POSTデータのitemの値によってフィルタをかける項目を分けています。

記事の検索(フィルタリング)については、以下のサイトを参考にしました。

参考URLDjango データベース操作 についてのまとめ
参考URLManyToMany フィルターなどのオブジェクト操作一覧
参考URLクエリを作成する

POSTデータのtextの値を検索ワードとして、検索条件は__部分一致(大文字小文字区別無し)__としています。
※SQLiteの場合は大文字小文字の区別有りの検索条件としても大文字小文字の区別がされないので注意
また、タグ検索については、ArticleモデルとTagモデルをManyToManyFieldでつなげていますが、この場合もtag__nameというような書き方で検索をかけることが可能でした。
参考URLリレーションを横断するルックアップ

最後に、検索に該当する記事のデータをjson化して、レスポンスを行います。

views.py
class ArticleListView(generic.ListView):
    model = Article

    # 参照するhtmlファイルを指定
    template_name = "memorandum/index.html"

    def post(self, request, *args, **kwargs):
        json_body = request.body.decode("utf-8")
        body = json.loads(json_body)

        item = body["item"]
        text = body["text"]

        if item == "title":
            print("Search title word={}".format(text))
            model_data = self.model.objects.filter(title__icontains=text)
        elif item == "tag":
            print("Search tag word={}".format(text))
            model_data = self.model.objects.filter(tag__name__icontains=text)
        elif item == "body":
            print("Search body word={}".format(text))
            model_data = self.model.objects.filter(body__icontains=text)
        else:
            print("Search ??? word={}".format(text))
            model_data = self.model.objects.all()

        json_data = serializers.serialize("json", model_data, ensure_ascii=False, indent=2)

        print("json_data:{}".format(type(json_data)))
        print("json_data:{}".format(json_data))

        return JsonResponse(json_data, safe=False)
        

ここまでで、Djangoからレスポンスが返され、先ほど実装したindex.jsでそのレスポンスがコンソール出力される状態になったはずでした。
が、実際にコンソールを確認すると、res.json()の中身が空っぽになりました。

index.js(未完成)
    ------
    const res = await fetch("./", postData)
    console.log(res.json());
}

Djangoからの応答の中身が空っぽ(ハマったポイント②)

この問題については、

  • Django側でのJsonResponse()の引数の指定の仕方が悪かったのか?
  • レスポンスのデータの中身をres.json()でうまくデコードできなかったのか?

など色々調べましたが、原因を特定するのに3日ほどかかってしまいました、、

結論としては、(答えがわかれば単純ですが)res.json()に__await__を付けていなかったことが原因でした。
__await__をつけることにより、レスポンスのデータが正しく出力されることが確認できました。

index.js(未完成)
    ------
    const res = await fetch("./", postData)
    const json = await res.json();
    console.log(json);
}
原因特定に時間がかかった~~理由~~言い訳・・・(本筋から外れるので、折りたたみにします)
  • fetch関数のようなサーバにアクセスするもの(メッセージを送る感じのもの)については、awaitを入れないと応答があるまで待機してくれないとは思っていたが、メソッドを呼び出す場合は常にそのメソッド処理の完了を待つものだと思い込んでいた。
  • 元々C言語から学び始めたため、関数呼び出しの場合はその関数が終わるまで次の処理に進むというイメージがつかみにくかった。
    というのが言い訳です。。

今回のことをうけて、async/await演算子について、もう少し理解する必要があると感じました。
なんとなくの感じでawait演算子をつけていましたが、どの処理には必要かというのを理解していないと、_とりあえずawaitつけとけ!_という感じになってしまいそうなので・・
参考URLasync/await地獄

◆レスポンスデータによるHTMLの部分更新(JavaScript)

最後にDjangoからのレスポンスを受け取り、HTMLを更新します。
index.htmlでは、要素の更新を行うための基準となる、記事リストのulタグにidを追加します。
また、年月日の表示形式をY年m月d日(D)からY-m-dに変更しています。これは、AjaxでPOSTした際のレスポンスデータの年月日の表示形式に合わせるためです(JavaScript側で年月日の表示形式を合わせることもできそうでしたが、そこまでこだわりがなかったため、簡単に修正できる方法を選択しました)。

index.html
---略---

{% block content %}

  ---略---

  <ul id="js-article-list" class="article-list">  <- idの追加
    {% for item in object_list %}
      <li class="article-list-item">
        <a class="link-text" href="{% url 'memorandum:detail' item.pk %}">{{ item.title }}</a>
        {% comment %} <div class="article-date">作成日: {{ item.published_date|date:"Y年m月d日(D)" }}<br>更新日: {{ item.modified_date|date:"Y年m月d日(D)" }}</div> {% endcomment %}
        <div class="article-date">作成日: {{ item.published_date|date:"Y-m-d" }}<br>更新日: {{ item.modified_date|date:"Y-m-d" }}</div>  <- 年月日の表示フォーマットを変更
      </li>
    {% endfor %}
  </ul>
{% endblock content %}
---略---

index.jsでは、必要な要素をつくり、そこにクラスの適用やテキストの代入を行い、最終的にappendChildで子要素を追加しています。
なお、記事詳細へのリンクのURL部分は元々Djangoのテンプレート言語で生成されていたため、今回はレスポンスデータのpktitleを利用して、JavaScript側で以下のように生成しています。
const linkText = "/memorandum/" + String(id) + "/detail/";
※URL生成部分については、サーバ側のURLパスの変更にともなってこの部分も変更する必要がでてしまうため、もう少しスマートなやり方がないかとは思いましたが、良案が思いつかなかったため、この実装にしています

index.js

------

const articleListElement = document.getElementById("js-article-list");

------

async function postSearchText(searchItem, searchText) {
    const postBody = {
        item: searchItem,
        text: searchText,
    };
    const postData = {
        method: "POST",
        headers: {
            // "Content-Type": "application/x-www-form-urlencoded",
            "Content-Type": "application/json",
            "X-CSRFToken": csrftoken,
        },
        body: JSON.stringify(postBody)
    };
    console.log(postData);
    let res = await fetch("./", postData)
    console.log(res.statusText, res.url);

    const json = await res.json();
    // console.log(json);
    const filteredArticles = await JSON.parse(json);
    // console.log(filteredArticles);

    while( articleListElement.firstChild )
    {
        articleListElement.removeChild(articleListElement.firstChild);
    }

    for(let article of filteredArticles)
    {
        console.log("pk:", article.pk);
        console.log("title:", article.fields.title);
        console.log("published_date:", article.fields.published_date);
        console.log("modified_date:", article.fields.modified_date);
        createFilteredElement(article.pk, article.fields.title, article.fields.published_date, article.fields.modified_date);
    }

}

function createFilteredElement(id, title, publishedDate, modifiedDate) {
    // async function createFilteredElement(title, link, publishedDate, modifiedDate) {
    const listItemElement = document.createElement("li");
    listItemElement.classList.add("article-list-item");
    console.log(listItemElement);

    const linkText = "/memorandum/" + String(id) + "/detail/";
    console.log(linkText);
    const listLinkElement = document.createElement("a");
    listLinkElement.classList.add("link-text");
    listLinkElement.setAttribute("href", linkText);
    listLinkElement.textContent = title;
    console.log(listLinkElement);

    const listDateElement = document.createElement("div");
    listDateElement.classList.add("article-date");
    listDateElement.innerHTML = "作成日:"+publishedDate+"<br>更新日:"+modifiedDate;
    console.log(listDateElement);

    listItemElement.appendChild(listLinkElement);
    listItemElement.appendChild(listDateElement);

    articleListElement.appendChild(listItemElement);
}

これでひととおりプログラムが完成したので、実際に記事の検索がおこなえるか確認を行いました。
その結果、検索ワードに該当する記事のリスト表示はできましたが、一部文字化けが発生してしまいました。

Ajaxによる部分更新を行った際に文字化けが発生(ハマったポイント③)

当初、HTMLやJavaScriptの文字コードがUTF-8に統一されていないためと思われましたが、問題が解決しません。
その後もいろいろ調べた結果、Firefoxでは文字化けは起きず、Chromeだと文字化けが発生することが判明し、それをもとにネット情報を調べると以下のサイトが見つかりました。

参考URLChrome 57 からエンコーディングの自動判別がダメになった

この記事によると、サーバ側のレスポンスヘッダーにUTF-8の文字コードを設定してあげれば解決可能とのことでしたので、実際にコードを修正したところChromeでの文字化けがなくなりました。
(以下は、タグ検索でDjangoを検索ワードとして検索した例)

003.png

◆今回修正したファイルのソースコード

ソースコードが小出しになってしまったため、一応今回修正したファイルのコード一覧を折りたたみで載せます
index.html
{% extends 'memorandum/base-layout.html' %}

{% load static %}

{% block head %}
  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <script src="{% static 'memorandum/js/index.js' %}" charset="UTF-8" defer></script>
{% endblock head %}

{% block content %}
  <div class="local-nav">
    <ul class="local-nav-list">
      <li class="local-nav-item"><a class="local-nav-link-text item-normal" href="{% url 'memorandum:create' %}">新規記事</a></li>
      <li class="local-nav-item"><a class="local-nav-link-text item-normal" href="{% url 'memorandum:create_tag' %}">新規TAG</a></li>
      <li class="local-search-item">
        <select id="js-search-item-list" name="search-item" size="1">
          <option value="title">タイトル検索</option>
          <option value="tag">タグ検索</option>
          <option value="body">本文検索</option>
        </select>
        <input id="js-search-text" type="search" placeholder="検索ワード">
        <button id="js-search-btn" type="button" class="button-common">Search</button>
      </li>
    </ul>
  </div>
  <ul id="js-article-list" class="article-list">
    {% for item in object_list %}
      <li class="article-list-item">
        <a class="link-text" href="{% url 'memorandum:detail' item.pk %}">{{ item.title }}</a>
        {% comment %} <div class="article-date">作成日: {{ item.published_date|date:"Y年m月d日(D)" }}<br>更新日: {{ item.modified_date|date:"Y年m月d日(D)" }}</div> {% endcomment %}
        <div class="article-date">作成日: {{ item.published_date|date:"Y-m-d" }}<br>更新日: {{ item.modified_date|date:"Y-m-d" }}</div>
      </li>
    {% endfor %}
  </ul>
{% endblock content %}
index.js

const searchItemListElement = document.getElementById("js-search-item-list");
const searchTextElement = document.getElementById("js-search-text");
const searchBtnElement = document.getElementById("js-search-btn");

const articleListElement = document.getElementById("js-article-list");

const csrftoken = Cookies.get('csrftoken');
console.log(csrftoken);

const x = document.characterSet;
console.log(x)

searchBtnElement.addEventListener("click", function() {
    console.log("hoge");
    const item = searchItemListElement.value;
    const text = searchTextElement.value;
    postSearchText(item, text);
}, false);

async function postSearchText(searchItem, searchText) {
    const postBody = {
        item: searchItem,
        text: searchText,
    };
    const postData = {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "X-CSRFToken": csrftoken,
        },
        body: JSON.stringify(postBody)
    };
    console.log(postData);
    let res = await fetch("./", postData)
    console.log(res.statusText, res.url);

    const json = await res.json();
    // console.log(json);
    const filteredArticles = await JSON.parse(json);
    // console.log(filteredArticles);

    while( articleListElement.firstChild )
    {
        articleListElement.removeChild(articleListElement.firstChild);
    }

    for(let article of filteredArticles)
    {
        console.log("pk:", article.pk);
        console.log("title:", article.fields.title);
        console.log("published_date:", article.fields.published_date);
        console.log("modified_date:", article.fields.modified_date);
        createFilteredElement(article.pk, article.fields.title, article.fields.published_date, article.fields.modified_date);
    }

}

function createFilteredElement(id, title, publishedDate, modifiedDate) {
    const listItemElement = document.createElement("li");
    listItemElement.classList.add("article-list-item");
    console.log(listItemElement);

    const linkText = "/memorandum/" + String(id) + "/detail/";
    console.log(linkText);
    const listLinkElement = document.createElement("a");
    listLinkElement.classList.add("link-text");
    listLinkElement.setAttribute("href", linkText);
    listLinkElement.textContent = title;
    console.log(listLinkElement);

    const listDateElement = document.createElement("div");
    listDateElement.classList.add("article-date");
    listDateElement.innerHTML = "作成日:"+publishedDate+"<br>更新日:"+modifiedDate;
    console.log(listDateElement);

    listItemElement.appendChild(listLinkElement);
    listItemElement.appendChild(listDateElement);

    articleListElement.appendChild(listItemElement);
}

views.py
from django.shortcuts import render, redirect
from django.urls import reverse_lazy
from django.http import HttpResponse, JsonResponse
from django.views import generic
from django.core import serializers
import json
import time

from .models import Tag, Article


class ArticleListView(generic.ListView):
    model = Article

    # 参照するhtmlファイルを指定
    template_name = "memorandum/index.html"

    def post(self, request, *args, **kwargs):
        json_body = request.body.decode("utf-8")
        body = json.loads(json_body)

        item = body["item"]
        text = body["text"]

        if item == "title":
            print("Search title word={}".format(text))
            model_data = self.model.objects.filter(title__icontains=text)
        elif item == "tag":
            print("Search tag word={}".format(text))
            model_data = self.model.objects.filter(tag__name__icontains=text)
        elif item == "body":
            print("Search body word={}".format(text))
            model_data = self.model.objects.filter(body__icontains=text)
        else:
            print("Search ??? word={}".format(text))
            model_data = self.model.objects.all()

        json_data = serializers.serialize("json", model_data, ensure_ascii=False, indent=2)

        print("json_data:{}".format(type(json_data)))
        print("json_data:{}".format(json_data))

        # return JsonResponse(json_data, safe=False)
        return JsonResponse(json_data, content_type="application/json; charset=utf-8", safe=False)


class ArticleDetailView(generic.DetailView):
    model = Article
    # 参照するhtmlファイルを指定
    template_name = "memorandum/detail.html"


class ArticleCreateView(generic.edit.CreateView):
    model = Article
    fields = ["title", "tag", "body"]
    # 参照するhtmlファイルを指定
    template_name = "memorandum/create.html"
    success_url = reverse_lazy('memorandum:index')  # POSTが正しく行われた際に飛ばすURL


class ArticleUpdateView(generic.edit.UpdateView):
    model = Article
    fields = ["title", "tag", "body"]
    # 参照するhtmlファイルを指定
    template_name = "memorandum/update.html"
    success_url = reverse_lazy('memorandum:index')  # POSTが正しく行われた際に飛ばすURL


class ArticleDeleteView(generic.edit.DeleteView):
    model = Article
    # 参照するhtmlファイルを指定
    template_name = "memorandum/delete.html"
    success_url = reverse_lazy('memorandum:index')  # POSTが正しく行われた際に飛ばすURL


class TagCreateView(generic.edit.CreateView):
    model = Tag
    fields = ["name"]
    # 参照するhtmlファイルを指定
    template_name = "memorandum/create-tag.html"
    success_url = reverse_lazy('memorandum:index')  # POSTが正しく行われた際に飛ばすURL

    def form_invalid(self, form):
        print("---form_invalid Called")
        print(form)
        print("form_invalid Called---")
        return super().form_invalid(form)
        # return redirect("memorandum:create_tag")

◇おわりに

  • 前回の投稿から、記事検索機能を実装できました。
  • 今回は、前回以上にいろいろとハマってしまい完成までに時間がかかりましたが、今後もマイペースで機能追加していきたいと思います(その際は、今回のようにハマったポイントもあわせて掲載していければと思います)。
  • つぎはタグの管理メニュー(削除、一覧表示など)関係を実装していければと思います。

🔚

7
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?