◇はじめに
前回のこの記事(WEBアプリの勉強を兼ねてDjangoで備忘録登録アプリを作ってみる)からの続きになります。
◇記事投稿順(2019/06/29追記)
今回は、ベースのWEBアプリを1.の記事で作成し、2.以降の記事で機能の追加や改善を行っています。必要に応じてほかの記事も参照ください。
- WEBアプリの勉強を兼ねてDjangoで備忘録登録アプリを作ってみる
- 【本記事】Djangoで作った備忘録登録WEBアプリの高機能化①(タイトル・タグ・本文検索機能の付加)
- 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
ボタンを押すと該当する記事のみが表示される流れとしています。
{% 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 %}
これでとりあえず外観はできたので、次にSearch
ボタンを押したときのJavaScript
の処理を実装していきます。
◆検索データPOST処理(JavaScript部分)
検索ボックスに入力したデータのPOST処理ですが、今回Ajax
でのHTML部分更新(記事リストの部分のみ更新し、検索ボックス部分は更新させない)を行うため、JavaScript
を追加しています。
そのため、前回のファイル構成からjs/index.js
を追加しています。
mysite/
├── memorandum
├── static
└── memorandum
├── css
│ └── memorandum.css <-スタイルシート用ファイル
├── js
└── index.js <-トップページ用JavaScript
index.js
では、以下の順で処理を行います。
-
Search
ボタンクリック時のイベントを追加する。 - クリックされたら、ドロップダウンリストの選択項目(
item
)とテキストフォームの入力値(value
)を取得する。 - 2つのデータを
json
化してfetch
関数を用いてPOSTする。
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) 対策
参考URL:DjangoでPOSTメッセージにCSRFtokenを含ませる方法まとめ
参考URL:A 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 を参照
{% 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のヘッダーにそのトークンを追加する処理を追記しました。
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
の値によってフィルタをかける項目を分けています。
記事の検索(フィルタリング)については、以下のサイトを参考にしました。
参考URL:Django データベース操作 についてのまとめ
参考URL:ManyToMany フィルターなどのオブジェクト操作一覧
参考URL:クエリを作成する
POSTデータのtext
の値を検索ワードとして、検索条件は__部分一致(大文字小文字区別無し)__としています。
※SQLiteの場合は大文字小文字の区別有りの検索条件としても大文字小文字の区別がされないので注意
また、タグ検索については、Article
モデルとTag
モデルをManyToManyField
でつなげていますが、この場合もtag__name
というような書き方で検索をかけることが可能でした。
参考URL:リレーションを横断するルックアップ
最後に、検索に該当する記事のデータをjson
化して、レスポンスを行います。
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()
の中身が空っぽになりました。
---略---
const res = await fetch("./", postData)
console.log(res.json());
}
□Django
からの応答の中身が空っぽ(ハマったポイント②)
この問題については、
-
Django
側でのJsonResponse()
の引数の指定の仕方が悪かったのか? - レスポンスのデータの中身を
res.json()
でうまくデコードできなかったのか?
など色々調べましたが、原因を特定するのに3日ほどかかってしまいました、、
結論としては、(答えがわかれば単純ですが)res.json()
に__await
__を付けていなかったことが原因でした。
__await
__をつけることにより、レスポンスのデータが正しく出力されることが確認できました。
---略---
const res = await fetch("./", postData)
const json = await res.json();
console.log(json);
}
原因特定に時間がかかった~~理由~~言い訳・・・(本筋から外れるので、折りたたみにします)
-
fetch
関数のようなサーバにアクセスするもの(メッセージを送る感じのもの)については、await
を入れないと応答があるまで待機してくれないとは思っていたが、メソッドを呼び出す場合は常にそのメソッド処理の完了を待つものだと思い込んでいた。 - 元々C言語から学び始めたため、関数呼び出しの場合はその関数が終わるまで次の処理に進むというイメージがつかみにくかった。
というのが言い訳です。。
今回のことをうけて、async/await
演算子について、もう少し理解する必要があると感じました。
なんとなくの感じでawait
演算子をつけていましたが、どの処理には必要かというのを理解していないと、_とりあえずawait
つけとけ!_という感じになってしまいそうなので・・
参考URL:async/await地獄
◆レスポンスデータによるHTMLの部分更新(JavaScript)
最後にDjango
からのレスポンスを受け取り、HTMLを更新します。
index.html
では、要素の更新を行うための基準となる、記事リストのul
タグにid
を追加します。
また、年月日の表示形式をY年m月d日(D)
からY-m-d
に変更しています。これは、Ajax
でPOSTした際のレスポンスデータの年月日の表示形式に合わせるためです(JavaScript
側で年月日の表示形式を合わせることもできそうでしたが、そこまでこだわりがなかったため、簡単に修正できる方法を選択しました)。
---略---
{% 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
のテンプレート言語で生成されていたため、今回はレスポンスデータのpk
やtitle
を利用して、JavaScript
側で以下のように生成しています。
const linkText = "/memorandum/" + String(id) + "/detail/";
※URL生成部分については、サーバ側のURLパスの変更にともなってこの部分も変更する必要がでてしまうため、もう少しスマートなやり方がないかとは思いましたが、良案が思いつかなかったため、この実装にしています
- 参考書籍:JavaSccriptコードレシピ集
---略---
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
だと文字化けが発生することが判明し、それをもとにネット情報を調べると以下のサイトが見つかりました。
参考URL:Chrome 57 からエンコーディングの自動判別がダメになった
この記事によると、サーバ側のレスポンスヘッダーにUTF-8
の文字コードを設定してあげれば解決可能とのことでしたので、実際にコードを修正したところChrome
での文字化けがなくなりました。
(以下は、タグ検索でDjango
を検索ワードとして検索した例)
◆今回修正したファイルのソースコード
ソースコードが小出しになってしまったため、一応今回修正したファイルのコード一覧を折りたたみで載せます
{% 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 %}
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);
}
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")
◇おわりに
- 前回の投稿から、記事検索機能を実装できました。
- 今回は、前回以上にいろいろとハマってしまい完成までに時間がかかりましたが、今後もマイペースで機能追加していきたいと思います(その際は、今回のようにハマったポイントもあわせて掲載していければと思います)。
- つぎはタグの管理メニュー(削除、一覧表示など)関係を実装していければと思います。